diff --git a/extensions/search-result/package.json b/extensions/search-result/package.json index 3639f83366d..a625733aa8a 100644 --- a/extensions/search-result/package.json +++ b/extensions/search-result/package.json @@ -16,6 +16,26 @@ "*" ], "contributes": { + "commands": [ + { + "command": "searchResult.rerunSearch", + "title": "%searchResult.rerunSearch.title%", + "category": "Search Result", + "icon": { + "light": "./src/media/refresh-light.svg", + "dark": "./src/media/refresh-dark.svg" + } + } + ], + "menus": { + "editor/title": [ + { + "command": "searchResult.rerunSearch", + "when": "editorLangId == search-result", + "group": "navigation" + } + ] + }, "languages": [ { "id": "search-result", diff --git a/extensions/search-result/package.nls.json b/extensions/search-result/package.nls.json index 324fd97bcd2..694f6b61d80 100644 --- a/extensions/search-result/package.nls.json +++ b/extensions/search-result/package.nls.json @@ -1,4 +1,5 @@ { "displayName": "Search Result", - "description": "Provides syntax highlighting and language features for tabbed search results." + "description": "Provides syntax highlighting and language features for tabbed search results.", + "searchResult.rerunSearch.title": "Search Again" } diff --git a/extensions/search-result/src/extension.ts b/extensions/search-result/src/extension.ts index 866645fee86..aa4090cc50e 100644 --- a/extensions/search-result/src/extension.ts +++ b/extensions/search-result/src/extension.ts @@ -8,12 +8,26 @@ import * as pathUtils from 'path'; const FILE_LINE_REGEX = /^(\S.*):$/; const RESULT_LINE_REGEX = /^(\s+)(\d+):(\s+)(.*)$/; +const LANGUAGE_SELECTOR = { language: 'search-result' }; let cachedLastParse: { version: number, parse: ParsedSearchResults } | undefined; export function activate() { - vscode.languages.registerDefinitionProvider({ language: 'search-result' }, { + vscode.commands.registerCommand('searchResult.rerunSearch', () => vscode.commands.executeCommand('search.action.rerunEditorSearch')); + + vscode.languages.registerCompletionItemProvider(LANGUAGE_SELECTOR, { + provideCompletionItems(document: vscode.TextDocument, position: vscode.Position): vscode.CompletionItem[] { + const line = document.lineAt(position.line); + if (line.text.indexOf('# Flags:') === -1) { return []; } + + return ['RegExp', 'CaseSensitive', 'IgnoreExcludeSettings', 'WordMatch'] + .filter(flag => line.text.indexOf(flag) === -1) + .map(flag => ({ label: flag, insertText: flag + ' ' })); + } + }); + + vscode.languages.registerDefinitionProvider(LANGUAGE_SELECTOR, { provideDefinition(document: vscode.TextDocument, position: vscode.Position, token: vscode.CancellationToken): vscode.DefinitionLink[] { const lineResult = parseSearchResults(document, token)[position.line]; if (!lineResult) { return []; } @@ -27,7 +41,7 @@ export function activate() { } }); - vscode.languages.registerDocumentLinkProvider({ language: 'search-result' }, { + vscode.languages.registerDocumentLinkProvider(LANGUAGE_SELECTOR, { async provideDocumentLinks(document: vscode.TextDocument, token: vscode.CancellationToken): Promise { return parseSearchResults(document, token) .filter(({ type }) => type === 'file') diff --git a/extensions/search-result/src/media/refresh-dark.svg b/extensions/search-result/src/media/refresh-dark.svg new file mode 100644 index 00000000000..e1f05aadeeb --- /dev/null +++ b/extensions/search-result/src/media/refresh-dark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/extensions/search-result/src/media/refresh-light.svg b/extensions/search-result/src/media/refresh-light.svg new file mode 100644 index 00000000000..9b1d9108409 --- /dev/null +++ b/extensions/search-result/src/media/refresh-light.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/vs/workbench/contrib/search/browser/search.contribution.ts b/src/vs/workbench/contrib/search/browser/search.contribution.ts index 94180385cbb..55fe302365c 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 } 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 } 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'; @@ -56,6 +56,7 @@ import { ISearchConfiguration, ISearchConfigurationProperties, PANEL_ID, VIEWLET import { IViewletService } from 'vs/workbench/services/viewlet/browser/viewlet'; import { IWorkspaceContextService } from 'vs/platform/workspace/common/workspace'; import { ExplorerViewlet } from 'vs/workbench/contrib/files/browser/explorerViewlet'; +import { EditorContextKeys } from 'vs/editor/common/editorContextKeys'; registerSingleton(ISearchWorkbenchService, SearchWorkbenchService, true); registerSingleton(ISearchHistoryService, SearchHistoryService, true); @@ -630,6 +631,13 @@ registry.registerWorkbenchAction( 'Search: Open Results in Editor', category, ContextKeyExpr.and(Constants.EnableSearchEditorPreview)); +registry.registerWorkbenchAction( + SyncActionDescriptor.create(RerunEditorSearchAction, RerunEditorSearchAction.ID, RerunEditorSearchAction.LABEL, + { primary: KeyMod.Shift | KeyMod.CtrlCmd | KeyCode.KEY_R }, + ContextKeyExpr.and(EditorContextKeys.languageId.isEqualTo('search-result'))), + 'Search Editor: Search Again', 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 b4dbbec31c1..c228d3a1218 100644 --- a/src/vs/workbench/contrib/search/browser/searchActions.ts +++ b/src/vs/workbench/contrib/search/browser/searchActions.ts @@ -13,7 +13,7 @@ import { IClipboardService } from 'vs/platform/clipboard/common/clipboardService import { ILabelService } from 'vs/platform/label/common/label'; import { ICommandHandler } from 'vs/platform/commands/common/commands'; import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; -import { ServicesAccessor } from 'vs/platform/instantiation/common/instantiation'; +import { ServicesAccessor, IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding'; import { getSelectionKeyboardEvent, WorkbenchObjectTree } from 'vs/platform/list/browser/listService'; import { SearchView } from 'vs/workbench/contrib/search/browser/searchView'; @@ -29,7 +29,9 @@ import { IViewletService } from 'vs/workbench/services/viewlet/browser/viewlet'; import { SearchViewlet } from 'vs/workbench/contrib/search/browser/searchViewlet'; import { SearchPanel } from 'vs/workbench/contrib/search/browser/searchPanel'; import { ITreeNavigator } from 'vs/base/browser/ui/tree/tree'; -import { createEditorFromSearchResult } from 'vs/workbench/contrib/search/browser/searchEditor'; +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'; export function isSearchViewFocused(viewletService: IViewletService, panelService: IPanelService): boolean { const searchView = getSearchView(viewletService, panelService); @@ -451,6 +453,30 @@ export class OpenResultsInEditorAction extends Action { } } +export class RerunEditorSearchAction extends Action { + + static readonly ID: string = Constants.RerunEditorSearchCommandId; + static readonly LABEL = nls.localize('search.rerunEditorSearch', "Search Again"); + + 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 + ) { + super(id, label); + } + + 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)); + } + } +} + export class FocusNextSearchResultAction extends Action { static readonly ID = 'search.action.focusNextSearchResult'; diff --git a/src/vs/workbench/contrib/search/browser/searchEditor.ts b/src/vs/workbench/contrib/search/browser/searchEditor.ts index 461040b475f..5c78887001c 100644 --- a/src/vs/workbench/contrib/search/browser/searchEditor.ts +++ b/src/vs/workbench/contrib/search/browser/searchEditor.ts @@ -3,25 +3,31 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Match, searchMatchComparer, FileMatch, SearchResult } from 'vs/workbench/contrib/search/common/searchModel'; +import { Match, searchMatchComparer, FileMatch, SearchResult, SearchModel } from 'vs/workbench/contrib/search/common/searchModel'; import { repeat } from 'vs/base/common/strings'; import { ILabelService } from 'vs/platform/label/common/label'; import { coalesce, flatten } from 'vs/base/common/arrays'; import { IEditorService } from 'vs/workbench/services/editor/common/editorService'; import { URI } from 'vs/base/common/uri'; -import { ITextQuery } from 'vs/workbench/services/search/common/search'; +import { ITextQuery, IPatternInfo, ISearchConfigurationProperties } from 'vs/workbench/services/search/common/search'; import * as network from 'vs/base/common/network'; import { Range } from 'vs/editor/common/core/range'; import { ITextModel, TrackedRangeStickiness } from 'vs/editor/common/model'; +import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; +import { ITextQueryBuilderOptions, QueryBuilder } from 'vs/workbench/contrib/search/common/queryBuilder'; +import { getOutOfWorkspaceEditorResources } from 'vs/workbench/contrib/search/common/search'; +import { IWorkspaceContextService } from 'vs/platform/workspace/common/workspace'; +import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; // Using \r\n on Windows inserts an extra newline between results. const lineDelimiter = '\n'; -const translateRangeLines = (n: number) => (range: Range) => new Range(range.startLineNumber + n, range.startColumn, range.endLineNumber + n, range.endColumn); +const translateRangeLines = + (n: number) => + (range: Range) => + new Range(range.startLineNumber + n, range.startColumn, range.endLineNumber + n, range.endColumn); -type SearchResultSerialization = { text: string[], matchRanges: Range[] }; - -function matchToSearchResultFormat(match: Match): { line: string, ranges: Range[], lineNumber: string }[] { +const matchToSearchResultFormat = (match: Match): { line: string, ranges: Range[], lineNumber: string }[] => { const getLinePrefix = (i: number) => `${match.range().startLineNumber + i}`; const fullMatchLines = match.fullPreviewLines(); @@ -54,8 +60,9 @@ function matchToSearchResultFormat(match: Match): { line: string, ranges: Range[ }); return results; -} +}; +type SearchResultSerialization = { text: string[], matchRanges: Range[] }; function fileMatchToSearchResultFormat(fileMatch: FileMatch, labelFormatter: (x: URI) => string): SearchResultSerialization { const serializedMatches = flatten(fileMatch.matches() .sort(searchMatchComparer) @@ -95,7 +102,7 @@ const flattenSearchResultSerializations = (serializations: SearchResultSerializa return { text, matchRanges }; }; -function contentPatternToSearchResultHeader(pattern: ITextQuery | null, includes: string, excludes: string): string[] { +const contentPatternToSearchResultHeader = (pattern: ITextQuery | null, includes: string, excludes: string): string[] => { if (!pattern) { return []; } const removeNullFalseAndUndefined = (a: (T | null | false | undefined)[]) => a.filter(a => a !== false && a !== null && a !== undefined) as T[]; @@ -105,29 +112,119 @@ function contentPatternToSearchResultHeader(pattern: ITextQuery | null, includes return removeNullFalseAndUndefined([ `# Query: ${escapeNewlines(pattern.contentPattern.pattern)}`, - (pattern.contentPattern.isCaseSensitive || pattern.contentPattern.isWordMatch || pattern.contentPattern.isRegExp) + (pattern.contentPattern.isCaseSensitive || pattern.contentPattern.isWordMatch || pattern.contentPattern.isRegExp || pattern.userDisabledExcludesAndIgnoreFiles) && `# Flags: ${coalesce([ pattern.contentPattern.isCaseSensitive && 'CaseSensitive', pattern.contentPattern.isWordMatch && 'WordMatch', - pattern.contentPattern.isRegExp && 'RegExp' + pattern.contentPattern.isRegExp && 'RegExp', + pattern.userDisabledExcludesAndIgnoreFiles && 'IgnoreExcludeSettings' ]).join(' ')}`, includes ? `# Including: ${includes}` : undefined, excludes ? `# Excluding: ${excludes}` : undefined, '' ]); -} +}; + +const searchHeaderToContentPattern = (header: string[]): { pattern: string, flags: { regex: boolean, wholeWord: boolean, caseSensitive: boolean, ignoreExcludes: boolean }, includes: string, excludes: string } => { + const query = { + pattern: '', + flags: { regex: false, caseSensitive: false, ignoreExcludes: false, wholeWord: false }, + includes: '', + excludes: '' + }; + + const unescapeNewlines = (str: string) => str.replace(/\\\\/g, '\\').replace(/\\n/g, '\n'); + const parseYML = /^# ([^:]*): (.*)$/; + for (const line of header) { + const parsed = parseYML.exec(line); + if (!parsed) { continue; } + const [, key, value] = parsed; + switch (key) { + case 'Query': query.pattern = unescapeNewlines(value); break; + case 'Including': query.includes = value; break; + case 'Excluding': query.excludes = value; break; + case 'Flags': { + query.flags = { + regex: value.indexOf('RegExp') !== -1, + caseSensitive: value.indexOf('CaseSensitive') !== -1, + ignoreExcludes: value.indexOf('IgnoreExcludeSettings') !== -1, + wholeWord: value.indexOf('WordMatch') !== -1 + }; + } + } + } + + return query; +}; const serializeSearchResultForEditor = (searchResult: SearchResult, rawIncludePattern: string, rawExcludePattern: string, labelFormatter: (x: URI) => string): SearchResultSerialization => { const header = contentPatternToSearchResultHeader(searchResult.query, rawIncludePattern, rawExcludePattern); const allResults = flattenSearchResultSerializations( - flatten(searchResult.folderMatches() - .map(folderMatch => folderMatch.matches() + 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) => { + const model = editorService.activeTextEditorWidget?.getModel(); + if (!model) { return; } + + const textModel = model as ITextModel; + + const header = textModel.getValueInRange(new Range(1, 1, 5, 1)) + .split(lineDelimiter) + .filter(line => line.indexOf('# ') === 0); + + const contentPattern = searchHeaderToContentPattern(header); + + const content: IPatternInfo = { + pattern: contentPattern.pattern, + isRegExp: contentPattern.flags.regex, + isCaseSensitive: contentPattern.flags.caseSensitive, + isWordMatch: contentPattern.flags.wholeWord + }; + + const options: ITextQueryBuilderOptions = { + _reason: 'searchEditor', + extraFileResources: instantiationService.invokeFunction(getOutOfWorkspaceEditorResources), + maxResults: 10000, + disregardIgnoreFiles: contentPattern.flags.ignoreExcludes, + disregardExcludeSettings: contentPattern.flags.ignoreExcludes, + excludePattern: contentPattern.excludes, + includePattern: contentPattern.includes, + previewOptions: { + matchLines: 1, + charsPerLine: 1000 + }, + isSmartCase: configurationService.getValue('search').smartCase, + expandPatterns: true + }; + + const folderResources = contextService.getWorkspace().folders; + + let query: ITextQuery; + try { + const queryBuilder = instantiationService.createInstance(QueryBuilder); + query = queryBuilder.text(content, folderResources.map(folder => folder.uri), options); + } catch (err) { + return; + } + + const searchModel = instantiationService.createInstance(SearchModel); + await searchModel.search(query); + + const labelFormatter = (uri: URI): string => labelService.getUriLabel(uri, { relative: true }); + const results = serializeSearchResultForEditor(searchModel.searchResult, '', '', labelFormatter); + + textModel.setValue(results.text.join(lineDelimiter)); + textModel.deltaDecorations([], results.matchRanges.map(range => ({ range, options: { className: 'findMatch', stickiness: TrackedRangeStickiness.NeverGrowsWhenTypingAtEdges } }))); + }; + + export const createEditorFromSearchResult = async (searchResult: SearchResult, rawIncludePattern: string, rawExcludePattern: string, labelService: ILabelService, editorService: IEditorService) => { const searchTerm = searchResult.query?.contentPattern.pattern.replace(/[^\w-_.]/g, '') || 'Search'; @@ -154,5 +251,4 @@ export const createEditorFromSearchResult = const model = control.getModel() as ITextModel; model.deltaDecorations([], results.matchRanges.map(range => ({ range, options: { className: 'findMatch', stickiness: TrackedRangeStickiness.NeverGrowsWhenTypingAtEdges } }))); - }; diff --git a/src/vs/workbench/contrib/search/common/constants.ts b/src/vs/workbench/contrib/search/common/constants.ts index 2b5ff708245..589eeafc837 100644 --- a/src/vs/workbench/contrib/search/common/constants.ts +++ b/src/vs/workbench/contrib/search/common/constants.ts @@ -16,6 +16,7 @@ export const CopyPathCommandId = 'search.action.copyPath'; 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 ClearSearchHistoryCommandId = 'search.action.clearHistory'; export const FocusSearchListCommandID = 'search.action.focusSearchList'; export const ReplaceActionId = 'search.action.replace';