From 041fb28d5004ec6c0a64888ce1dc713eb4d4e46e Mon Sep 17 00:00:00 2001 From: subhajitlucky Date: Wed, 13 May 2026 08:42:04 +0530 Subject: [PATCH 1/2] feat: add regex search option --- media/styles.css | 18 +++++++-- package.json | 1 + src/WebviewManager.ts | 11 +++--- src/searchPattern.ts | 44 ++++++++++++++++++++++ src/services/SearchService.ts | 39 +++++++++---------- src/types.ts | 2 + src/webview/script.ts | 70 +++++++++++++++++++---------------- src/webview/webviewContent.ts | 7 ++++ tests/searchPattern.test.js | 50 +++++++++++++++++++++++++ 9 files changed, 181 insertions(+), 61 deletions(-) create mode 100644 src/searchPattern.ts create mode 100644 tests/searchPattern.test.js diff --git a/media/styles.css b/media/styles.css index ace7c3c..4f6d260 100644 --- a/media/styles.css +++ b/media/styles.css @@ -46,7 +46,8 @@ body { color: var(--vscode-input-placeholderForeground); } -.filter-toggle-button { +.filter-toggle-button, +.regex-toggle-button { background: var(--vscode-button-secondaryBackground); color: var(--vscode-button-secondaryForeground); border: 1px solid var(--vscode-input-border); @@ -59,11 +60,20 @@ body { transition: background 0.1s; } -.filter-toggle-button:hover { +.regex-toggle-button { + min-width: 32px; + font-weight: 600; + font-size: 12px; + font-family: var(--vscode-editor-font-family, var(--vscode-font-family)); +} + +.filter-toggle-button:hover, +.regex-toggle-button:hover { background: var(--vscode-button-secondaryHoverBackground); } -.filter-toggle-button.active { +.filter-toggle-button.active, +.regex-toggle-button.active { background: var(--vscode-button-background); color: var(--vscode-button-foreground); } @@ -401,4 +411,4 @@ body.wrap-lines .code-line { ::-webkit-scrollbar-thumb { background: var(--vscode-scrollbarSlider-hoverBackground); -} \ No newline at end of file +} diff --git a/package.json b/package.json index 685c1f1..2bd39d9 100644 --- a/package.json +++ b/package.json @@ -68,6 +68,7 @@ "compile": "tsc -p ./", "watch": "tsc -watch -p ./", "pretest": "npm run compile && npm run lint", + "test:search-pattern": "npm run compile && node tests/searchPattern.test.js", "lint": "eslint src --ext ts" }, "devDependencies": { diff --git a/src/WebviewManager.ts b/src/WebviewManager.ts index e2d7a02..d8c9b0d 100644 --- a/src/WebviewManager.ts +++ b/src/WebviewManager.ts @@ -152,7 +152,7 @@ export class WebviewManager { case 'search': if (message.text) { - await this.handleSearch(panelId, panel, message.text, message.includePattern, message.excludePattern); + await this.handleSearch(panelId, panel, message.text, message.includePattern, message.excludePattern, message.isRegex); } break; @@ -174,22 +174,23 @@ export class WebviewManager { } } - private async handleSearch(panelId: string, panel: vscode.WebviewPanel, query: string, includePattern?: string, excludePattern?: string): Promise { + private async handleSearch(panelId: string, panel: vscode.WebviewPanel, query: string, includePattern?: string, excludePattern?: string, isRegex: boolean = false): Promise { try { - this.panelSearches.set(panelId, query); + const searchId = `${isRegex ? 'regex' : 'literal'}:${query}`; + this.panelSearches.set(panelId, searchId); const searchableFiles = await this.searchService.getSearchableFiles(); const searchOptions = this.searchService.getSearchOptions(); let resultCount = 0; for (let i = 0; i < searchableFiles.length; i += searchOptions.batchSize) { - if (this.panelSearches.get(panelId) !== query) { + if (this.panelSearches.get(panelId) !== searchId) { // A new search has been initiated in this panel, abort current search return; } const batch = searchableFiles.slice(i, i + searchOptions.batchSize); - const results = await this.searchService.search(batch, query, includePattern, excludePattern); + const results = await this.searchService.search(batch, query, includePattern, excludePattern, isRegex); if (results.length === 0) { continue; } diff --git a/src/searchPattern.ts b/src/searchPattern.ts new file mode 100644 index 0000000..154d2d5 --- /dev/null +++ b/src/searchPattern.ts @@ -0,0 +1,44 @@ +import { escapeRegExp } from './util'; + +export type SearchPattern = { + expression: RegExp; +}; + +export type PatternMatch = { + index: number; + length: number; +}; + +export function createSearchPattern(query: string, isRegex: boolean): SearchPattern | null { + if (!query) { + return null; + } + + try { + return { + expression: new RegExp(isRegex ? query : escapeRegExp(query), 'gi') + }; + } catch { + return null; + } +} + +export function findPatternMatches(text: string, pattern: SearchPattern): PatternMatch[] { + const matches: PatternMatch[] = []; + pattern.expression.lastIndex = 0; + + let match: RegExpExecArray | null; + while ((match = pattern.expression.exec(text)) !== null) { + if (match[0].length === 0) { + pattern.expression.lastIndex++; + continue; + } + + matches.push({ + index: match.index, + length: match[0].length + }); + } + + return matches; +} diff --git a/src/services/SearchService.ts b/src/services/SearchService.ts index 71ad1b1..e1b2691 100644 --- a/src/services/SearchService.ts +++ b/src/services/SearchService.ts @@ -1,7 +1,8 @@ import * as vscode from 'vscode'; import { FileSearchResult, SearchMatch, SearchOptions } from '../types'; import { BINARY_EXTENSIONS, DEFAULT_SEARCH_OPTIONS } from '../constants'; -import { escapeRegExp, matchGlob } from '../util'; +import { matchGlob } from '../util'; +import { createSearchPattern, findPatternMatches, SearchPattern } from '../searchPattern'; export class SearchService { private options: SearchOptions; @@ -83,20 +84,24 @@ export class SearchService { return files; } - async search(files: vscode.Uri[], query: string, includePattern?: string, excludePattern?: string): Promise { + async search(files: vscode.Uri[], query: string, includePattern?: string, excludePattern?: string, isRegex: boolean = false): Promise { const fileMatchMap = new Map(); if (!query) { return []; } + const searchPattern = createSearchPattern(query, isRegex); + if (!searchPattern) { + return []; + } + // Filter files based on include/exclude patterns let filteredFiles = files; if (includePattern || excludePattern) { filteredFiles = this.filterFilesByPatterns(files, includePattern, excludePattern); } - const queryLower = query.toLowerCase(); - await this.searchInBatches(filteredFiles, queryLower, fileMatchMap); + await this.searchInBatches(filteredFiles, searchPattern, fileMatchMap); return this.convertMapToResults(fileMatchMap); } @@ -131,7 +136,7 @@ export class SearchService { private async searchInBatches( files: vscode.Uri[], - queryLower: string, + searchPattern: SearchPattern, fileMatchMap: Map ): Promise { for (let i = 0; i < files.length; i += this.options.batchSize) { @@ -140,7 +145,7 @@ export class SearchService { } const batch = files.slice(i, i + this.options.batchSize); - const results = await this.searchBatch(batch, queryLower); + const results = await this.searchBatch(batch, searchPattern); for (const result of results) { if (result) { @@ -152,7 +157,7 @@ export class SearchService { private async searchBatch( batch: vscode.Uri[], - queryLower: string + searchPattern: SearchPattern ): Promise> { return Promise.all(batch.map(async (file) => { try { @@ -164,13 +169,8 @@ export class SearchService { const uint8Array = await vscode.workspace.fs.readFile(file); const text = new TextDecoder('utf-8', { fatal: false }).decode(uint8Array); - const textLower = text.toLowerCase(); - - if (!textLower.includes(queryLower)) { - return null; - } - const matches = this.findMatchesInFile(file, text, textLower, queryLower); + const matches = this.findMatchesInFile(file, text, searchPattern); return matches.length > 0 ? { filePath: file.fsPath, matches } : null; } catch (error) { return null; @@ -181,19 +181,16 @@ export class SearchService { private findMatchesInFile( file: vscode.Uri, text: string, - textLower: string, - queryLower: string + searchPattern: SearchPattern ): SearchMatch[] { const regularLines = text.split('\n'); - const lowerLines = textLower.split('\n'); const matches: SearchMatch[] = []; const workspaceFolder = vscode.workspace.getWorkspaceFolder(file); const relativePath = workspaceFolder ? vscode.workspace.asRelativePath(file, false) : file.fsPath; - const matchExp = new RegExp(escapeRegExp(queryLower), 'g'); - for (let i = 0; i < lowerLines.length; i++) { + for (let i = 0; i < regularLines.length; i++) { if (this.options.maxMatchesPerFile && matches.length >= this.options.maxMatchesPerFile) { break; } @@ -201,12 +198,11 @@ export class SearchService { const previewLine = regularLines[i]; // const previewTrimOffset = regularLine.length - previewLine.length; - const lowerLine = lowerLines[i]; - const lineMatches = lowerLine.matchAll(matchExp); + const lineMatches = findPatternMatches(previewLine, searchPattern); for (const match of lineMatches) { // clamp line preview to max 50 characters before and after to prevent issues with extremely long lines const start = Math.max(0, match.index - 50); - const end = Math.min(previewLine.length, match.index + queryLower.length + 50); + const end = Math.min(previewLine.length, match.index + match.length + 50); const preview = previewLine.substring(start, end); const trimmedPreview = preview.trimStart(); @@ -220,6 +216,7 @@ export class SearchService { relativePath, line: i + 1, column: match.index, + matchLength: match.length, preview: trimmedPreview.trimEnd(), previewColumn }); diff --git a/src/types.ts b/src/types.ts index 12546a0..0d1a720 100644 --- a/src/types.ts +++ b/src/types.ts @@ -5,6 +5,7 @@ export interface SearchMatch { relativePath: string; line: number; column: number; + matchLength: number; previewColumn: number; preview: string; } @@ -41,6 +42,7 @@ export type WebviewMessage = { text: string; includePattern?: string; excludePattern?: string; + isRegex?: boolean; } | { command: 'getFileContent' filePath: string; diff --git a/src/webview/script.ts b/src/webview/script.ts index f08a1c2..ce2e064 100644 --- a/src/webview/script.ts +++ b/src/webview/script.ts @@ -15,6 +15,7 @@ type SearchMatchWithId = SearchMatch & { matchId: number, icon?: FileSearchResul }; const searchInput = document.getElementById('searchInput')!; + const regexToggleButton = document.getElementById('regexToggleButton')!; const filterToggleButton = document.getElementById('filterToggleButton')!; const filterContainer = document.getElementById('filterContainer') as HTMLElement; const fileMaskInput = document.getElementById('fileMaskInput') as HTMLInputElement; @@ -32,9 +33,9 @@ type SearchMatchWithId = SearchMatch & { matchId: number, icon?: FileSearchResul let currentScope: string = 'project'; let scopePath: string = ''; let fileMask: string = ''; + let isRegexSearch = false; let selectedMatchIndex = -1; - let currentQuery = ''; let fileContentsCache: { [key: string]: { content: string; @@ -141,7 +142,6 @@ type SearchMatchWithId = SearchMatch & { matchId: number, icon?: FileSearchResul includePattern = convertFileMaskToPattern(currentFileMask); } - currentQuery = searchText; clearTimeout(searchTimeout); // Use longer delay for short queries as they are more likely to change @@ -151,7 +151,8 @@ type SearchMatchWithId = SearchMatch & { matchId: number, icon?: FileSearchResul command: 'search', text: searchText, includePattern: includePattern, - excludePattern: undefined + excludePattern: undefined, + isRegex: isRegexSearch }); }, timeoutDelay); } @@ -160,6 +161,13 @@ type SearchMatchWithId = SearchMatch & { matchId: number, icon?: FileSearchResul performSearch(); }); + regexToggleButton.addEventListener('click', () => { + isRegexSearch = !isRegexSearch; + regexToggleButton.classList.toggle('active', isRegexSearch); + regexToggleButton.setAttribute('aria-pressed', String(isRegexSearch)); + performSearch(); + }); + // Filter toggle functionality filterToggleButton.addEventListener('click', () => { const isVisible = filterContainer.style.display !== 'none'; @@ -254,6 +262,7 @@ type SearchMatchWithId = SearchMatch & { matchId: number, icon?: FileSearchResul relativePath: result.relativePath, line: match.line, column: match.column, + matchLength: match.matchLength, preview: match.preview, previewColumn: match.previewColumn, icon: result.icon @@ -279,6 +288,7 @@ type SearchMatchWithId = SearchMatch & { matchId: number, icon?: FileSearchResul relativePath: result.relativePath, line: match.line, column: match.column, + matchLength: match.matchLength, preview: match.preview, previewColumn: match.previewColumn, icon: result.icon @@ -345,7 +355,7 @@ type SearchMatchWithId = SearchMatch & { matchId: number, icon?: FileSearchResul ${escapeHtml(fileName)} `; } - const highlighted = highlightText(match.preview, currentQuery, match.previewColumn); + const highlighted = highlightText(match.preview, match.previewColumn, match.matchLength); html += `
[${match.line}] ${highlighted} @@ -366,17 +376,15 @@ type SearchMatchWithId = SearchMatch & { matchId: number, icon?: FileSearchResul return textEscaper.innerHTML; } - function highlightText(text: string, query: string, column: number): string { - if (!query) return escapeHtml(text); - + function highlightText(text: string, column: number, matchLength: number): string { const escapedText = escapeHtml(text); - const index = text.toLowerCase().indexOf(query.toLowerCase(), column); + const index = column; - if (index === -1) return escapedText; + if (index < 0 || matchLength <= 0) return escapedText; const before = escapeHtml(text.substring(0, index)); - const match = escapeHtml(text.substring(index, index + query.length)); - const after = escapeHtml(text.substring(index + query.length)); + const match = escapeHtml(text.substring(index, index + matchLength)); + const after = escapeHtml(text.substring(index + matchLength)); return `${before}${match}${after}`; } @@ -405,7 +413,7 @@ type SearchMatchWithId = SearchMatch & { matchId: number, icon?: FileSearchResul filePath: match.filePath }); } else { - displayFilePreview(match.filePath, match.line, match.column); + displayFilePreview(match.filePath, match.line, match.column, match.matchLength); } }; // Make available globally for onclick handlers @@ -460,7 +468,12 @@ type SearchMatchWithId = SearchMatch & { matchId: number, icon?: FileSearchResul }; if (selectedMatchIndex >= 0 && allMatches[selectedMatchIndex].filePath === filePath) { - displayFilePreview(filePath, allMatches[selectedMatchIndex].line, allMatches[selectedMatchIndex].column); + displayFilePreview( + filePath, + allMatches[selectedMatchIndex].line, + allMatches[selectedMatchIndex].column, + allMatches[selectedMatchIndex].matchLength + ); } } @@ -501,7 +514,7 @@ type SearchMatchWithId = SearchMatch & { matchId: number, icon?: FileSearchResul performSearch(); } - function displayFilePreview(filePath: string, lineNumber: number, columnNumber: number) { + function displayFilePreview(filePath: string, lineNumber: number, columnNumber: number, matchLength: number) { const cached = fileContentsCache[filePath]; if (!cached) return; @@ -523,12 +536,12 @@ type SearchMatchWithId = SearchMatch & { matchId: number, icon?: FileSearchResul // Add search query highlighting on top of syntax highlighting if (isMatchLine) { - lineContent = addSearchHighlightToColorizedLine(lineContent, lines[i], currentQuery, columnNumber); + lineContent = addSearchHighlightToColorizedLine(lineContent, columnNumber, matchLength); } } else { // Fallback to plain highlighting if (isMatchLine) { - lineContent = highlightSearchQuery(lines[i], currentQuery, columnNumber); + lineContent = highlightSearchQuery(lines[i], columnNumber, matchLength); } else { lineContent = lines[i].replace(//g, '>'); } @@ -584,42 +597,37 @@ type SearchMatchWithId = SearchMatch & { matchId: number, icon?: FileSearchResul } } - function highlightSearchQuery(text: string, query: string, columnNumber: number): string { - if (!query) { + function highlightSearchQuery(text: string, columnNumber: number, matchLength: number): string { + if (matchLength <= 0) { return text.replace(//g, '>'); } - const index = text.toLowerCase().indexOf(query.toLowerCase(), columnNumber); + const index = columnNumber; if (index === -1) { return text.replace(//g, '>'); } const before = text.substring(0, index).replace(//g, '>'); - const match = text.substring(index, index + query.length).replace(//g, '>'); - const after = text.substring(index + query.length).replace(//g, '>'); + const match = text.substring(index, index + matchLength).replace(//g, '>'); + const after = text.substring(index + matchLength).replace(//g, '>'); return `${before}${match}${after}`; } - function addSearchHighlightToColorizedLine(colorizedHtml: string, plainText: string, query: string, columnNumber: number): string { - if (!query) return colorizedHtml; - - const index = plainText.toLowerCase().indexOf(query.toLowerCase()); - if (index === -1) return colorizedHtml; - + function addSearchHighlightToColorizedLine(colorizedHtml: string, columnNumber: number, matchLength: number): string { // Create a temporary div to work with the HTML const temp = document.createElement('div'); temp.innerHTML = colorizedHtml; // Get the text content and find the position const textContent = temp.textContent || ''; - const matchIndex = textContent.toLowerCase().indexOf(query.toLowerCase(), columnNumber); + const matchIndex = columnNumber; - if (matchIndex === -1) return colorizedHtml; + if (matchIndex < 0 || matchIndex >= textContent.length || matchLength <= 0) return colorizedHtml; // Walk through the nodes and wrap the matching text let currentPos = 0; - const matchEnd = matchIndex + query.length; + const matchEnd = matchIndex + matchLength; function wrapTextNodes(node: Node) { if (node.nodeType === Node.TEXT_NODE) { @@ -701,4 +709,4 @@ type SearchMatchWithId = SearchMatch & { matchId: number, icon?: FileSearchResul }); searchInput.focus(); -}()); \ No newline at end of file +}()); diff --git a/src/webview/webviewContent.ts b/src/webview/webviewContent.ts index 6264737..90dc51d 100644 --- a/src/webview/webviewContent.ts +++ b/src/webview/webviewContent.ts @@ -53,6 +53,13 @@ export function getWebviewContent(options: WebviewContentOptions): string { placeholder="Search everywhere..." autofocus /> +