[Search Editor] Add option for context lines

This commit is contained in:
Jackson Kearl 2019-12-02 19:39:28 -08:00
parent 529351318e
commit eae6eca8cf
9 changed files with 133 additions and 22 deletions

View file

@ -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"
}
]

View file

@ -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)"
}

View file

@ -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 + ' ' }));
}

View file

@ -3,7 +3,7 @@
"scopeName": "text.searchResult",
"patterns": [
{
"match": "^# (Query|Flags|Including|Excluding): .*$",
"match": "^# (Query|Flags|Including|Excluding|ContextLines): .*$",
"name": "comment"
},
{

View file

@ -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<IQuickOpenRegistry>(QuickOpenExtensions.Quickopen).registerDefaultQuickOpenHandler(

View file

@ -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<ISearchConfigurationProperties>('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<ISearchConfigurationProperties>('search').enableSearchEditorPreview) {
await this.progressService.withProgress({ location: ProgressLocation.Window },
() => refreshActiveEditorSearch(+lines, this.editorService, this.instantiationService, this.contextService, this.labelService, this.configurationService));
}
}
}

View file

@ -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<string, number> = {};
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<string>();
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 = <T>(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<ISearchConfigurationProperties>('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)}; }`);

View file

@ -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';

View file

@ -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<number, string> = new Map();
public get context(): Map<number, string> {
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);