diff --git a/extensions/search-result/package.json b/extensions/search-result/package.json index bc7c6cf951c..0a2af5e0a9d 100644 --- a/extensions/search-result/package.json +++ b/extensions/search-result/package.json @@ -28,6 +28,15 @@ "light": "./src/media/refresh-light.svg", "dark": "./src/media/refresh-dark.svg" } + }, + { + "command": "searchResult.rerunSearchWithContext", + "title": "%searchResult.rerunSearchWithContext.title%", + "category": "Search Result", + "icon": { + "light": "./src/media/refresh-light.svg", + "dark": "./src/media/refresh-dark.svg" + } } ], "menus": { @@ -35,6 +44,7 @@ { "command": "searchResult.rerunSearch", "when": "editorLangId == search-result", + "alt": "searchResult.rerunSearchWithContext", "group": "navigation" } ] diff --git a/extensions/search-result/package.nls.json b/extensions/search-result/package.nls.json index 694f6b61d80..a4b0fb83845 100644 --- a/extensions/search-result/package.nls.json +++ b/extensions/search-result/package.nls.json @@ -1,5 +1,6 @@ { "displayName": "Search Result", "description": "Provides syntax highlighting and language features for tabbed search results.", - "searchResult.rerunSearch.title": "Search Again" + "searchResult.rerunSearch.title": "Search Again", + "searchResult.rerunSearchWithContext.title": "Search Again (Wth Context)" } diff --git a/extensions/search-result/src/extension.ts b/extensions/search-result/src/extension.ts index 55e082f35eb..c88a4feab63 100644 --- a/extensions/search-result/src/extension.ts +++ b/extensions/search-result/src/extension.ts @@ -7,14 +7,17 @@ import * as vscode from 'vscode'; import * as pathUtils from 'path'; const FILE_LINE_REGEX = /^(\S.*):$/; -const RESULT_LINE_REGEX = /^(\s+)(\d+):(\s+)(.*)$/; +const RESULT_LINE_REGEX = /^(\s+)(\d+)(?::| )(\s+)(.*)$/; const SEARCH_RESULT_SELECTOR = { language: 'search-result' }; +const DIRECTIVES = ['# Query:', '# Flags:', '# Including:', '# Excluding:', '# ContextLines:']; +const FLAGS = ['RegExp', 'CaseSensitive', 'IgnoreExcludeSettings', 'WordMatch']; let cachedLastParse: { version: number, parse: ParsedSearchResults } | undefined; export function activate(context: vscode.ExtensionContext) { context.subscriptions.push( vscode.commands.registerCommand('searchResult.rerunSearch', () => vscode.commands.executeCommand('search.action.rerunEditorSearch')), + vscode.commands.registerCommand('searchResult.rerunSearchWithContext', () => vscode.commands.executeCommand('search.action.rerunEditorSearchWithContext')), vscode.languages.registerDocumentSymbolProvider(SEARCH_RESULT_SELECTOR, { provideDocumentSymbols(document: vscode.TextDocument, token: vscode.CancellationToken): vscode.DocumentSymbol[] { @@ -38,16 +41,16 @@ export function activate(context: vscode.ExtensionContext) { const line = document.lineAt(position.line); if (position.line > 3) { return []; } if (position.character === 0 || (position.character === 1 && line.text === '#')) { - const header = Array.from({ length: 4 }).map((_, i) => document.lineAt(i).text); + const header = Array.from({ length: DIRECTIVES.length }).map((_, i) => document.lineAt(i).text); - return ['# Query:', '# Flags:', '# Including:', '# Excluding:'] + return DIRECTIVES .filter(suggestion => header.every(line => line.indexOf(suggestion) === -1)) .map(flag => ({ label: flag, insertText: (flag.slice(position.character)) + ' ' })); } if (line.text.indexOf('# Flags:') === -1) { return []; } - return ['RegExp', 'CaseSensitive', 'IgnoreExcludeSettings', 'WordMatch'] + return FLAGS .filter(flag => line.text.indexOf(flag) === -1) .map(flag => ({ label: flag, insertText: flag + ' ' })); } diff --git a/extensions/search-result/syntaxes/searchResult.tmLanguage.json b/extensions/search-result/syntaxes/searchResult.tmLanguage.json index 4de2a40ba40..d16ecb8c97c 100644 --- a/extensions/search-result/syntaxes/searchResult.tmLanguage.json +++ b/extensions/search-result/syntaxes/searchResult.tmLanguage.json @@ -3,7 +3,7 @@ "scopeName": "text.searchResult", "patterns": [ { - "match": "^# (Query|Flags|Including|Excluding): .*$", + "match": "^# (Query|Flags|Including|Excluding|ContextLines): .*$", "name": "comment" }, { diff --git a/src/vs/workbench/contrib/search/browser/search.contribution.ts b/src/vs/workbench/contrib/search/browser/search.contribution.ts index af5b9c2f727..76763901606 100644 --- a/src/vs/workbench/contrib/search/browser/search.contribution.ts +++ b/src/vs/workbench/contrib/search/browser/search.contribution.ts @@ -41,7 +41,7 @@ import { ExplorerFolderContext, ExplorerRootContext, FilesExplorerFocusCondition import { OpenAnythingHandler } from 'vs/workbench/contrib/search/browser/openAnythingHandler'; import { OpenSymbolHandler } from 'vs/workbench/contrib/search/browser/openSymbolHandler'; import { registerContributions as replaceContributions } from 'vs/workbench/contrib/search/browser/replaceContributions'; -import { clearHistoryCommand, ClearSearchResultsAction, CloseReplaceAction, CollapseDeepestExpandedLevelAction, copyAllCommand, copyMatchCommand, copyPathCommand, FocusNextInputAction, FocusNextSearchResultAction, FocusPreviousInputAction, FocusPreviousSearchResultAction, focusSearchListCommand, getSearchView, openSearchView, OpenSearchViewletAction, RefreshAction, RemoveAction, ReplaceAction, ReplaceAllAction, ReplaceAllInFolderAction, ReplaceInFilesAction, toggleCaseSensitiveCommand, toggleRegexCommand, toggleWholeWordCommand, FindInFilesCommand, ToggleSearchOnTypeAction, OpenResultsInEditorAction, RerunEditorSearchAction } from 'vs/workbench/contrib/search/browser/searchActions'; +import { clearHistoryCommand, ClearSearchResultsAction, CloseReplaceAction, CollapseDeepestExpandedLevelAction, copyAllCommand, copyMatchCommand, copyPathCommand, FocusNextInputAction, FocusNextSearchResultAction, FocusPreviousInputAction, FocusPreviousSearchResultAction, focusSearchListCommand, getSearchView, openSearchView, OpenSearchViewletAction, RefreshAction, RemoveAction, ReplaceAction, ReplaceAllAction, ReplaceAllInFolderAction, ReplaceInFilesAction, toggleCaseSensitiveCommand, toggleRegexCommand, toggleWholeWordCommand, FindInFilesCommand, ToggleSearchOnTypeAction, OpenResultsInEditorAction, RerunEditorSearchAction, RerunEditorSearchWithContextAction } from 'vs/workbench/contrib/search/browser/searchActions'; import { SearchPanel } from 'vs/workbench/contrib/search/browser/searchPanel'; import { SearchView, SearchViewPosition } from 'vs/workbench/contrib/search/browser/searchView'; import { SearchViewlet } from 'vs/workbench/contrib/search/browser/searchViewlet'; @@ -651,6 +651,11 @@ registry.registerWorkbenchAction( 'Search Editor: Search Again', category, ContextKeyExpr.and(EditorContextKeys.languageId.isEqualTo('search-result'))); +registry.registerWorkbenchAction( + SyncActionDescriptor.create(RerunEditorSearchWithContextAction, RerunEditorSearchWithContextAction.ID, RerunEditorSearchWithContextAction.LABEL), + 'Search Editor: Search Again (With Context)', category, + ContextKeyExpr.and(EditorContextKeys.languageId.isEqualTo('search-result'))); + // Register Quick Open Handler Registry.as(QuickOpenExtensions.Quickopen).registerDefaultQuickOpenHandler( diff --git a/src/vs/workbench/contrib/search/browser/searchActions.ts b/src/vs/workbench/contrib/search/browser/searchActions.ts index c228d3a1218..b8346873f74 100644 --- a/src/vs/workbench/contrib/search/browser/searchActions.ts +++ b/src/vs/workbench/contrib/search/browser/searchActions.ts @@ -32,6 +32,7 @@ import { ITreeNavigator } from 'vs/base/browser/ui/tree/tree'; import { createEditorFromSearchResult, refreshActiveEditorSearch } from 'vs/workbench/contrib/search/browser/searchEditor'; import { IWorkspaceContextService } from 'vs/platform/workspace/common/workspace'; import { IProgressService, ProgressLocation } from 'vs/platform/progress/common/progress'; +import { IQuickInputService } from 'vs/platform/quickinput/common/quickInput'; export function isSearchViewFocused(viewletService: IViewletService, panelService: IPanelService): boolean { const searchView = getSearchView(viewletService, panelService); @@ -472,7 +473,38 @@ export class RerunEditorSearchAction extends Action { async run() { if (this.configurationService.getValue('search').enableSearchEditorPreview) { await this.progressService.withProgress({ location: ProgressLocation.Window }, - () => refreshActiveEditorSearch(this.editorService, this.instantiationService, this.contextService, this.labelService, this.configurationService)); + () => refreshActiveEditorSearch(undefined, this.editorService, this.instantiationService, this.contextService, this.labelService, this.configurationService)); + } + } +} + +export class RerunEditorSearchWithContextAction extends Action { + + static readonly ID: string = Constants.RerunEditorSearchWithContextCommandId; + static readonly LABEL = nls.localize('search.rerunEditorSearchContext', "Search Again (With Context)"); + + constructor(id: string, label: string, + @IInstantiationService private instantiationService: IInstantiationService, + @IEditorService private editorService: IEditorService, + @IConfigurationService private configurationService: IConfigurationService, + @IWorkspaceContextService private contextService: IWorkspaceContextService, + @ILabelService private labelService: ILabelService, + @IProgressService private progressService: IProgressService, + @IQuickInputService private quickPickService: IQuickInputService + ) { + super(id, label); + } + + async run() { + const lines = await this.quickPickService.input({ + prompt: nls.localize('lines', "Lines of Context"), + value: '2', + validateInput: async (value) => isNaN(parseInt(value)) ? nls.localize('mustBeInteger', "Must enter an integer") : undefined + }); + if (lines === undefined) { return; } + if (this.configurationService.getValue('search').enableSearchEditorPreview) { + await this.progressService.withProgress({ location: ProgressLocation.Window }, + () => refreshActiveEditorSearch(+lines, this.editorService, this.instantiationService, this.contextService, this.labelService, this.configurationService)); } } } diff --git a/src/vs/workbench/contrib/search/browser/searchEditor.ts b/src/vs/workbench/contrib/search/browser/searchEditor.ts index 4f515b013b1..46792e7b680 100644 --- a/src/vs/workbench/contrib/search/browser/searchEditor.ts +++ b/src/vs/workbench/contrib/search/browser/searchEditor.ts @@ -65,6 +65,7 @@ const matchToSearchResultFormat = (match: Match): { line: string, ranges: Range[ }; type SearchResultSerialization = { text: string[], matchRanges: Range[] }; + function fileMatchToSearchResultFormat(fileMatch: FileMatch, labelFormatter: (x: URI) => string): SearchResultSerialization { const serializedMatches = flatten(fileMatch.matches() .sort(searchMatchComparer) @@ -76,17 +77,37 @@ function fileMatchToSearchResultFormat(fileMatch: FileMatch, labelFormatter: (x: const targetLineNumberToOffset: Record = {}; + const context: { line: string, lineNumber: number }[] = []; + fileMatch.context.forEach((line, lineNumber) => context.push({ line, lineNumber })); + context.sort((a, b) => a.lineNumber - b.lineNumber); + + let lastLine: number | undefined = undefined; + const seenLines = new Set(); serializedMatches.forEach(match => { if (!seenLines.has(match.line)) { + while (context.length && context[0].lineNumber < +match.lineNumber) { + const { line, lineNumber } = context.shift()!; + if (lastLine !== undefined && lineNumber !== lastLine + 1) { + text.push(''); + } + text.push(` ${lineNumber} ${line}`); + lastLine = lineNumber; + } + targetLineNumberToOffset[match.lineNumber] = text.length; seenLines.add(match.line); text.push(match.line); + lastLine = +match.lineNumber; } matchRanges.push(...match.ranges.map(translateRangeLines(targetLineNumberToOffset[match.lineNumber]))); }); + while (context.length) { + const { line, lineNumber } = context.shift()!; + text.push(` ${lineNumber} ${line}`); + } return { text, matchRanges }; } @@ -104,7 +125,7 @@ const flattenSearchResultSerializations = (serializations: SearchResultSerializa return { text, matchRanges }; }; -const contentPatternToSearchResultHeader = (pattern: ITextQuery | null, includes: string, excludes: string): string[] => { +const contentPatternToSearchResultHeader = (pattern: ITextQuery | null, includes: string, excludes: string, contextLines: number): string[] => { if (!pattern) { return []; } const removeNullFalseAndUndefined = (a: (T | null | false | undefined)[]) => a.filter(a => a !== false && a !== null && a !== undefined) as T[]; @@ -123,16 +144,32 @@ const contentPatternToSearchResultHeader = (pattern: ITextQuery | null, includes ]).join(' ')}`, includes ? `# Including: ${includes}` : undefined, excludes ? `# Excluding: ${excludes}` : undefined, + contextLines ? `# ContextLines: ${contextLines}` : undefined, '' ]); }; -const searchHeaderToContentPattern = (header: string[]): { pattern: string, flags: { regex: boolean, wholeWord: boolean, caseSensitive: boolean, ignoreExcludes: boolean }, includes: string, excludes: string } => { - const query = { + +type SearchHeader = { + pattern: string; + flags: { + regex: boolean; + wholeWord: boolean; + caseSensitive: boolean; + ignoreExcludes: boolean; + }; + includes: string; + excludes: string; + context: number | undefined; +}; + +const searchHeaderToContentPattern = (header: string[]): SearchHeader => { + const query: SearchHeader = { pattern: '', flags: { regex: false, caseSensitive: false, ignoreExcludes: false, wholeWord: false }, includes: '', - excludes: '' + excludes: '', + context: undefined }; const unescapeNewlines = (str: string) => str.replace(/\\\\/g, '\\').replace(/\\n/g, '\n'); @@ -145,6 +182,7 @@ const searchHeaderToContentPattern = (header: string[]): { pattern: string, flag case 'Query': query.pattern = unescapeNewlines(value); break; case 'Including': query.includes = value; break; case 'Excluding': query.excludes = value; break; + case 'ContextLines': query.context = +value; break; case 'Flags': { query.flags = { regex: value.indexOf('RegExp') !== -1, @@ -159,19 +197,20 @@ const searchHeaderToContentPattern = (header: string[]): { pattern: string, flag return query; }; -const serializeSearchResultForEditor = (searchResult: SearchResult, rawIncludePattern: string, rawExcludePattern: string, labelFormatter: (x: URI) => string): SearchResultSerialization => { - const header = contentPatternToSearchResultHeader(searchResult.query, rawIncludePattern, rawExcludePattern); +const serializeSearchResultForEditor = (searchResult: SearchResult, rawIncludePattern: string, rawExcludePattern: string, contextLines: number, labelFormatter: (x: URI) => string): SearchResultSerialization => { + const header = contentPatternToSearchResultHeader(searchResult.query, rawIncludePattern, rawExcludePattern, contextLines); const allResults = flattenSearchResultSerializations( - flatten(searchResult.folderMatches().sort(searchMatchComparer) - .map(folderMatch => folderMatch.matches().sort(searchMatchComparer) - .map(fileMatch => fileMatchToSearchResultFormat(fileMatch, labelFormatter))))); + flatten( + searchResult.folderMatches().sort(searchMatchComparer) + .map(folderMatch => folderMatch.matches().sort(searchMatchComparer) + .map(fileMatch => fileMatchToSearchResultFormat(fileMatch, labelFormatter))))); return { matchRanges: allResults.matchRanges.map(translateRangeLines(header.length)), text: header.concat(allResults.text) }; }; export const refreshActiveEditorSearch = - async (editorService: IEditorService, instantiationService: IInstantiationService, contextService: IWorkspaceContextService, labelService: ILabelService, configurationService: IConfigurationService) => { + async (contextLines: number | undefined, editorService: IEditorService, instantiationService: IInstantiationService, contextService: IWorkspaceContextService, labelService: ILabelService, configurationService: IConfigurationService) => { const model = editorService.activeTextEditorWidget?.getModel(); if (!model) { return; } @@ -190,6 +229,8 @@ export const refreshActiveEditorSearch = isWordMatch: contentPattern.flags.wholeWord }; + contextLines = contextLines ?? contentPattern.context ?? 0; + const options: ITextQueryBuilderOptions = { _reason: 'searchEditor', extraFileResources: instantiationService.invokeFunction(getOutOfWorkspaceEditorResources), @@ -202,6 +243,8 @@ export const refreshActiveEditorSearch = matchLines: 1, charsPerLine: 1000 }, + afterContext: contextLines, + beforeContext: contextLines, isSmartCase: configurationService.getValue('search').smartCase, expandPatterns: true }; @@ -220,7 +263,7 @@ export const refreshActiveEditorSearch = await searchModel.search(query); const labelFormatter = (uri: URI): string => labelService.getUriLabel(uri, { relative: true }); - const results = serializeSearchResultForEditor(searchModel.searchResult, '', '', labelFormatter); + const results = serializeSearchResultForEditor(searchModel.searchResult, contentPattern.includes, contentPattern.excludes, contextLines, labelFormatter); textModel.setValue(results.text.join(lineDelimiter)); textModel.deltaDecorations([], results.matchRanges.map(range => ({ range, options: { className: 'searchEditorFindMatch', stickiness: TrackedRangeStickiness.NeverGrowsWhenTypingAtEdges } }))); @@ -233,7 +276,7 @@ export const createEditorFromSearchResult = const labelFormatter = (uri: URI): string => labelService.getUriLabel(uri, { relative: true }); - const results = serializeSearchResultForEditor(searchResult, rawIncludePattern, rawExcludePattern, labelFormatter); + const results = serializeSearchResultForEditor(searchResult, rawIncludePattern, rawExcludePattern, 0, labelFormatter); let possible = { contents: results.text.join(lineDelimiter), @@ -255,7 +298,6 @@ export const createEditorFromSearchResult = model.deltaDecorations([], results.matchRanges.map(range => ({ range, options: { className: 'searchEditorFindMatch', stickiness: TrackedRangeStickiness.NeverGrowsWhenTypingAtEdges } }))); }; -// theming registerThemingParticipant((theme, collector) => { collector.addRule(`.monaco-editor .searchEditorFindMatch { background-color: ${theme.getColor(searchEditorFindMatch)}; }`); diff --git a/src/vs/workbench/contrib/search/common/constants.ts b/src/vs/workbench/contrib/search/common/constants.ts index 483190b6498..b7c72d5d5cd 100644 --- a/src/vs/workbench/contrib/search/common/constants.ts +++ b/src/vs/workbench/contrib/search/common/constants.ts @@ -17,6 +17,7 @@ export const CopyMatchCommandId = 'search.action.copyMatch'; export const CopyAllCommandId = 'search.action.copyAll'; export const OpenInEditorCommandId = 'search.action.openInEditor'; export const RerunEditorSearchCommandId = 'search.action.rerunEditorSearch'; +export const RerunEditorSearchWithContextCommandId = 'search.action.rerunEditorSearchWithContext'; export const ClearSearchHistoryCommandId = 'search.action.clearHistory'; export const FocusSearchListCommandID = 'search.action.focusSearchList'; export const ReplaceActionId = 'search.action.replace'; diff --git a/src/vs/workbench/contrib/search/common/searchModel.ts b/src/vs/workbench/contrib/search/common/searchModel.ts index 18ce0750f09..477b2de22e0 100644 --- a/src/vs/workbench/contrib/search/common/searchModel.ts +++ b/src/vs/workbench/contrib/search/common/searchModel.ts @@ -20,7 +20,7 @@ import { IModelService } from 'vs/editor/common/services/modelService'; import { createDecorator, IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; import { IProgress, IProgressStep } from 'vs/platform/progress/common/progress'; import { ReplacePattern } from 'vs/workbench/services/search/common/replace'; -import { IFileMatch, IPatternInfo, ISearchComplete, ISearchProgressItem, ISearchConfigurationProperties, ISearchService, ITextQuery, ITextSearchPreviewOptions, ITextSearchMatch, ITextSearchStats, resultIsMatch, ISearchRange, OneLineRange } from 'vs/workbench/services/search/common/search'; +import { IFileMatch, IPatternInfo, ISearchComplete, ISearchProgressItem, ISearchConfigurationProperties, ISearchService, ITextQuery, ITextSearchPreviewOptions, ITextSearchMatch, ITextSearchStats, resultIsMatch, ISearchRange, OneLineRange, ITextSearchContext, ITextSearchResult } from 'vs/workbench/services/search/common/search'; import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry'; import { overviewRulerFindMatchForeground, minimapFindMatch } from 'vs/platform/theme/common/colorRegistry'; import { themeColorFromId } from 'vs/platform/theme/common/themeService'; @@ -197,6 +197,11 @@ export class FileMatch extends Disposable implements IFileMatch { private _updateScheduler: RunOnceScheduler; private _modelDecorations: string[] = []; + private _context: Map = new Map(); + public get context(): Map { + return new Map(this._context); + } + constructor(private _query: IPatternInfo, private _previewOptions: ITextSearchPreviewOptions | undefined, private _maxResults: number | undefined, private _parent: FolderMatch, private rawMatch: IFileMatch, @IModelService private readonly modelService: IModelService, @IReplaceService private readonly replaceService: IReplaceService ) { @@ -221,6 +226,8 @@ export class FileMatch extends Disposable implements IFileMatch { textSearchResultToMatches(rawMatch, this) .forEach(m => this.add(m)); }); + + this.addContext(this.rawMatch.results); } } @@ -375,6 +382,14 @@ export class FileMatch extends Disposable implements IFileMatch { return getBaseLabel(this.resource); } + addContext(results: ITextSearchResult[] | undefined) { + if (!results) { return; } + + results + .filter((result => !resultIsMatch(result)) as ((a: any) => a is ITextSearchContext)) + .forEach(context => this._context.set(context.lineNumber, context.text)); + } + add(match: Match, trigger?: boolean) { this._matches.set(match.id(), match); if (trigger) { @@ -479,6 +494,8 @@ export class FolderMatch extends Disposable { .forEach(m => existingFileMatch.add(m)); }); updated.push(existingFileMatch); + + existingFileMatch.addContext(rawFileMatch.results); } else { const fileMatch = this.instantiationService.createInstance(FileMatch, this._query.contentPattern, this._query.previewOptions, this._query.maxResults, this, rawFileMatch); this.doAdd(fileMatch);