diff --git a/src/vs/workbench/contrib/search/browser/search.contribution.ts b/src/vs/workbench/contrib/search/browser/search.contribution.ts index e54aa07912ad0c77c0433ddf5483814610126651..f83efde0992681abbe87e1450f0e4c499b8ac34c 100644 --- a/src/vs/workbench/contrib/search/browser/search.contribution.ts +++ b/src/vs/workbench/contrib/search/browser/search.contribution.ts @@ -56,7 +56,7 @@ import { ExplorerViewPaneContainer } from 'vs/workbench/contrib/files/browser/ex import { assertType } from 'vs/base/common/types'; import { SearchViewPaneContainer } from 'vs/workbench/contrib/search/browser/searchViewlet'; import { EditorDescriptor, Extensions as EditorExtensions, IEditorRegistry } from 'vs/workbench/browser/editor'; -import { SearchEditorInput, SearchEditorInputFactory, SearchEditorContribution } from 'vs/workbench/contrib/search/browser/searchEditorCommands'; +import { SearchEditorInput, SearchEditorInputFactory, SearchEditorContribution } from 'vs/workbench/contrib/search/browser/searchEditorInput'; import { SearchEditor } from 'vs/workbench/contrib/search/browser/searchEditor'; import { SyncDescriptor } from 'vs/platform/instantiation/common/descriptors'; import { Extensions as EditorInputExtensions, IEditorInputFactoryRegistry } from 'vs/workbench/common/editor'; diff --git a/src/vs/workbench/contrib/search/browser/searchActions.ts b/src/vs/workbench/contrib/search/browser/searchActions.ts index b8f13ef18326660f72131752c707f3d53408f787..e32774592a886203e36b6c9793899414a071aff2 100644 --- a/src/vs/workbench/contrib/search/browser/searchActions.ts +++ b/src/vs/workbench/contrib/search/browser/searchActions.ts @@ -29,10 +29,9 @@ import { IViewletService } from 'vs/workbench/services/viewlet/browser/viewlet'; import { SearchViewPaneContainer } 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, openNewSearchEditor, SearchEditorInput } from 'vs/workbench/contrib/search/browser/searchEditorCommands'; - +import { createEditorFromSearchResult, openNewSearchEditor } from 'vs/workbench/contrib/search/browser/searchEditorActions'; import type { SearchEditor } from 'vs/workbench/contrib/search/browser/searchEditor'; -import { ITextFileService } from 'vs/workbench/services/textfile/common/textfiles'; +import { SearchEditorInput } from 'vs/workbench/contrib/search/browser/searchEditorInput'; export function isSearchViewFocused(viewletService: IViewletService, panelService: IPanelService): boolean { const searchView = getSearchView(viewletService, panelService); @@ -587,7 +586,6 @@ export class OpenResultsInEditorAction extends Action { @IEditorService private editorService: IEditorService, @IConfigurationService private configurationService: IConfigurationService, @IInstantiationService private readonly instantiationService: IInstantiationService, - @ITextFileService private readonly textFileService: ITextFileService ) { super(id, label, 'codicon-go-to-file'); } @@ -604,7 +602,7 @@ export class OpenResultsInEditorAction extends Action { async run() { const searchView = getSearchView(this.viewletService, this.panelService); if (searchView && this.configurationService.getValue('search').enableSearchEditorPreview) { - await createEditorFromSearchResult(searchView.searchResult, searchView.searchIncludePattern.getValue(), searchView.searchExcludePattern.getValue(), this.labelService, this.editorService, this.textFileService, this.instantiationService); + await createEditorFromSearchResult(searchView.searchResult, searchView.searchIncludePattern.getValue(), searchView.searchExcludePattern.getValue(), this.labelService, this.editorService, this.instantiationService); } } } diff --git a/src/vs/workbench/contrib/search/browser/searchEditor.ts b/src/vs/workbench/contrib/search/browser/searchEditor.ts index 5a6064f11b30d6917c27f2307f92873fad0048ec..64e83b59e52765fa8de190226da4ab3d83e38eea 100644 --- a/src/vs/workbench/contrib/search/browser/searchEditor.ts +++ b/src/vs/workbench/contrib/search/browser/searchEditor.ts @@ -23,10 +23,10 @@ import { IInstantiationService } from 'vs/platform/instantiation/common/instanti import { ILabelService } from 'vs/platform/label/common/label'; import { IStorageService } from 'vs/platform/storage/common/storage'; import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry'; -import { IThemeService } from 'vs/platform/theme/common/themeService'; +import { IThemeService, registerThemingParticipant } from 'vs/platform/theme/common/themeService'; import { IWorkspaceContextService } from 'vs/platform/workspace/common/workspace'; import { BaseEditor } from 'vs/workbench/browser/parts/editor/baseEditor'; -import { EditorInput, EditorOptions } from 'vs/workbench/common/editor'; +import { EditorOptions } from 'vs/workbench/common/editor'; import { ExcludePatternInputWidget, PatternInputWidget } from 'vs/workbench/contrib/search/browser/patternInputWidget'; import { SearchWidget } from 'vs/workbench/contrib/search/browser/searchWidget'; import { ITextQueryBuilderOptions, QueryBuilder } from 'vs/workbench/contrib/search/common/queryBuilder'; @@ -34,19 +34,15 @@ import { getOutOfWorkspaceEditorResources } from 'vs/workbench/contrib/search/co import { SearchModel } from 'vs/workbench/contrib/search/common/searchModel'; import { IPatternInfo, ISearchConfigurationProperties, ITextQuery } from 'vs/workbench/services/search/common/search'; import { Delayer } from 'vs/base/common/async'; -import { serializeSearchResultForEditor, SearchConfiguration, SearchEditorInput } from 'vs/workbench/contrib/search/browser/searchEditorCommands'; -import { ITextFileService } from 'vs/workbench/services/textfile/common/textfiles'; +import { serializeSearchResultForEditor } from 'vs/workbench/contrib/search/browser/searchEditorSerialization'; import { IContextKeyService, IContextKey } from 'vs/platform/contextkey/common/contextkey'; import { InSearchEditor, InputBoxFocusedKey } from 'vs/workbench/contrib/search/common/constants'; import { IEditorProgressService, LongRunningOperation } from 'vs/platform/progress/common/progress'; +import type { SearchEditorInput, SearchConfiguration } from 'vs/workbench/contrib/search/browser/searchEditorInput'; +import { searchEditorFindMatchBorder, searchEditorFindMatch } from 'vs/platform/theme/common/colorRegistry'; const RESULT_LINE_REGEX = /^(\s+)(\d+)(:| )(\s+)(.*)$/; -// Using \r\n on Windows inserts an extra newline between results. -const lineDelimiter = '\n'; - - - export class SearchEditor extends BaseEditor { static readonly ID: string = 'workbench.editor.searchEditor'; @@ -78,7 +74,6 @@ export class SearchEditor extends BaseEditor { @IInstantiationService private readonly instantiationService: IInstantiationService, @IContextViewService private readonly contextViewService: IContextViewService, @ICommandService private readonly commandService: ICommandService, - @ITextFileService private readonly textFileService: ITextFileService, @IContextKeyService readonly contextKeyService: IContextKeyService, @IEditorProgressService readonly progressService: IEditorProgressService, ) { @@ -304,16 +299,17 @@ export class SearchEditor extends BaseEditor { return; } - (assertIsDefined(this._input) as SearchEditorInput).setConfig(config); const labelFormatter = (uri: URI): string => this.labelService.getUriLabel(uri, { relative: true }); const results = serializeSearchResultForEditor(searchModel.searchResult, config.includes, config.excludes, config.contextLines, labelFormatter, true); const textModel = assertIsDefined(this.searchResultEditor.getModel()); - this.modelService.updateModel(textModel, results.text.join(lineDelimiter)); + this.modelService.updateModel(textModel, results.text); this.getInput()?.setDirty(this.getInput()?.resource.scheme !== 'search-editor'); this.hideHeader(); textModel.deltaDecorations([], results.matchRanges.map(range => ({ range, options: { className: 'searchEditorFindMatch', stickiness: TrackedRangeStickiness.NeverGrowsWhenTypingAtEdges } }))); + (assertIsDefined(this._input) as SearchEditorInput).reloadModel(); + searchModel.dispose(); } @@ -327,7 +323,8 @@ export class SearchEditor extends BaseEditor { .length ?? 0; - this.searchResultEditor.setHiddenAreas([new Range(1, 1, headerLines + 1, 1)]); + // const length = this.searchResultEditor.getModel()?.getLineLength(headerLines); + this.searchResultEditor.setHiddenAreas([new Range(1, 1, headerLines, 1)]); } layout(dimension: DOM.Dimension) { @@ -352,38 +349,26 @@ export class SearchEditor extends BaseEditor { return this._input as SearchEditorInput; } - async setInput(newInput: EditorInput, options: EditorOptions | undefined, token: CancellationToken): Promise { + async setInput(newInput: SearchEditorInput, options: EditorOptions | undefined, token: CancellationToken): Promise { await super.setInput(newInput, options, token); this.inSearchEditorContextKey.set(true); - if (!(newInput instanceof SearchEditorInput)) { return; } - - const model = assertIsDefined(this.modelService.getModel(newInput.resource)); + const { model } = await newInput.reloadModel(); this.searchResultEditor.setModel(model); - - const backup = await newInput.resolveBackup(); - if (backup) { - model.setValueFromTextBuffer(backup); - } else { - if (newInput.resource.scheme !== 'search-editor') { - if (model.getValue() === '') { - model.setValue((await this.textFileService.read(newInput.resource)).value); - } - } - } - this.hideHeader(); this.pauseSearching = true; - this.queryEditorWidget.setValue(newInput.config.query, true); - this.queryEditorWidget.searchInput.setCaseSensitive(newInput.config.caseSensitive); - this.queryEditorWidget.searchInput.setRegex(newInput.config.regexp); - this.queryEditorWidget.searchInput.setWholeWords(newInput.config.wholeWord); - this.queryEditorWidget.setContextLines(newInput.config.contextLines); - this.inputPatternExcludes.setValue(newInput.config.excludes); - this.inputPatternIncludes.setValue(newInput.config.includes); - this.inputPatternExcludes.setUseExcludesAndIgnoreFiles(newInput.config.useIgnores); - this.toggleIncludesExcludes(newInput.config.showIncludesExcludes); + const { query } = await newInput.reloadModel(); + + this.queryEditorWidget.setValue(query.query, true); + this.queryEditorWidget.searchInput.setCaseSensitive(query.caseSensitive); + this.queryEditorWidget.searchInput.setRegex(query.regexp); + this.queryEditorWidget.searchInput.setWholeWords(query.wholeWord); + this.queryEditorWidget.setContextLines(query.contextLines); + this.inputPatternExcludes.setValue(query.excludes); + this.inputPatternIncludes.setValue(query.includes); + this.inputPatternExcludes.setUseExcludesAndIgnoreFiles(query.useIgnores); + this.toggleIncludesExcludes(query.showIncludesExcludes); this.focusInput(); this.pauseSearching = false; @@ -415,3 +400,12 @@ export class SearchEditor extends BaseEditor { this.inSearchEditorContextKey.set(false); } } + +registerThemingParticipant((theme, collector) => { + collector.addRule(`.monaco-editor .searchEditorFindMatch { background-color: ${theme.getColor(searchEditorFindMatch)}; }`); + + const findMatchHighlightBorder = theme.getColor(searchEditorFindMatchBorder); + if (findMatchHighlightBorder) { + collector.addRule(`.monaco-editor .searchEditorFindMatch { border: 1px ${theme.type === 'hc' ? 'dotted' : 'solid'} ${findMatchHighlightBorder}; box-sizing: border-box; }`); + } +}); diff --git a/src/vs/workbench/contrib/search/browser/searchEditorActions.ts b/src/vs/workbench/contrib/search/browser/searchEditorActions.ts new file mode 100644 index 0000000000000000000000000000000000000000..af442b1234d4a58feb5ac0bedd865477c5d51bda --- /dev/null +++ b/src/vs/workbench/contrib/search/browser/searchEditorActions.ts @@ -0,0 +1,55 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { assertIsDefined } from 'vs/base/common/types'; +import { URI } from 'vs/base/common/uri'; +import 'vs/css!./media/searchEditor'; +import { isDiffEditor, ICodeEditor } from 'vs/editor/browser/editorBrowser'; +import { TrackedRangeStickiness } from 'vs/editor/common/model'; +import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; +import { ILabelService } from 'vs/platform/label/common/label'; +import { SearchResult } from 'vs/workbench/contrib/search/common/searchModel'; +import { IEditorService } from 'vs/workbench/services/editor/common/editorService'; +import { SearchEditor } from 'vs/workbench/contrib/search/browser/searchEditor'; +import { getOrMakeSearchEditorInput } from 'vs/workbench/contrib/search/browser/searchEditorInput'; +import { serializeSearchResultForEditor, serializeSearchConfiguration } from 'vs/workbench/contrib/search/browser/searchEditorSerialization'; + +export const openNewSearchEditor = + async (editorService: IEditorService, instantiationService: IInstantiationService) => { + const activeEditor = editorService.activeTextEditorWidget; + let activeModel: ICodeEditor | undefined; + if (isDiffEditor(activeEditor)) { + if (activeEditor.getOriginalEditor().hasTextFocus()) { + activeModel = activeEditor.getOriginalEditor(); + } else { + activeModel = activeEditor.getModifiedEditor(); + } + } else { + activeModel = activeEditor as ICodeEditor | undefined; + } + const selection = activeModel?.getSelection(); + let selected = (selection && activeModel?.getModel()?.getValueInRange(selection)) ?? ''; + + + const input = instantiationService.invokeFunction(getOrMakeSearchEditorInput, { text: serializeSearchConfiguration({ query: selected }) }); + await editorService.openEditor(input, { pinned: true }); + }; + +export const createEditorFromSearchResult = + async (searchResult: SearchResult, rawIncludePattern: string, rawExcludePattern: string, labelService: ILabelService, editorService: IEditorService, instantiationService: IInstantiationService) => { + if (!searchResult.query) { + console.error('Expected searchResult.query to be defined. Got', searchResult); + return; + } + + const labelFormatter = (uri: URI): string => labelService.getUriLabel(uri, { relative: true }); + + const { text, matchRanges } = serializeSearchResultForEditor(searchResult, rawIncludePattern, rawExcludePattern, 0, labelFormatter, true); + + const input = instantiationService.invokeFunction(getOrMakeSearchEditorInput, { text }); + const editor = await editorService.openEditor(input, { pinned: true }) as SearchEditor; + const model = assertIsDefined(editor.getModel()); + model.deltaDecorations([], matchRanges.map(range => ({ range, options: { className: 'searchEditorFindMatch', stickiness: TrackedRangeStickiness.NeverGrowsWhenTypingAtEdges } }))); + }; diff --git a/src/vs/workbench/contrib/search/browser/searchEditorCommands.ts b/src/vs/workbench/contrib/search/browser/searchEditorCommands.ts deleted file mode 100644 index 554c806632bdd1b6fd53f613a6ae871002e3c368..0000000000000000000000000000000000000000 --- a/src/vs/workbench/contrib/search/browser/searchEditorCommands.ts +++ /dev/null @@ -1,586 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { coalesce, flatten } from 'vs/base/common/arrays'; -import * as network from 'vs/base/common/network'; -import { repeat, endsWith } from 'vs/base/common/strings'; -import { assertIsDefined } from 'vs/base/common/types'; -import { URI } from 'vs/base/common/uri'; -import 'vs/css!./media/searchEditor'; -import { isDiffEditor, ICodeEditor } from 'vs/editor/browser/editorBrowser'; -import { Range } from 'vs/editor/common/core/range'; -import { EndOfLinePreference, TrackedRangeStickiness, ITextModel, ITextBuffer, DefaultEndOfLine } from 'vs/editor/common/model'; -import { localize } from 'vs/nls'; -import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; -import { ILabelService } from 'vs/platform/label/common/label'; -import { searchEditorFindMatch, searchEditorFindMatchBorder } from 'vs/platform/theme/common/colorRegistry'; -import { registerThemingParticipant } from 'vs/platform/theme/common/themeService'; -import { UntitledTextEditorInput } from 'vs/workbench/common/editor/untitledTextEditorInput'; -import { FileMatch, Match, searchMatchComparer, SearchResult } from 'vs/workbench/contrib/search/common/searchModel'; -import { IEditorService } from 'vs/workbench/services/editor/common/editorService'; -import { ITextQuery } from 'vs/workbench/services/search/common/search'; -import { IEditorInputFactory, GroupIdentifier, EditorInput, SaveContext, IRevertOptions } from 'vs/workbench/common/editor'; -import { IModelService } from 'vs/editor/common/services/modelService'; -import { IModeService } from 'vs/editor/common/services/modeService'; -import { SearchEditor } from 'vs/workbench/contrib/search/browser/searchEditor'; -import { IEditorGroupsService } from 'vs/workbench/services/editor/common/editorGroupsService'; -import { ITextFileSaveOptions, ITextFileService } from 'vs/workbench/services/textfile/common/textfiles'; -import type { IWorkbenchContribution } from 'vs/workbench/common/contributions'; -import { FileEditorInput } from 'vs/workbench/contrib/files/common/editors/fileEditorInput'; -import { dirname, joinPath, isEqual } from 'vs/base/common/resources'; -import { IHistoryService } from 'vs/workbench/services/history/common/history'; -import { IWorkbenchEnvironmentService } from 'vs/workbench/services/environment/common/environmentService'; -import { IFileDialogService } from 'vs/platform/dialogs/common/dialogs'; -import { basename } from 'vs/base/common/path'; -import { IWorkingCopyService, WorkingCopyCapabilities, IWorkingCopy, IWorkingCopyBackup } from 'vs/workbench/services/workingCopy/common/workingCopyService'; -import { IBackupFileService } from 'vs/workbench/services/backup/common/backup'; - - - -export type SearchConfiguration = { - query: string, - includes: string, - excludes: string - contextLines: number, - wholeWord: boolean, - caseSensitive: boolean, - regexp: boolean, - useIgnores: boolean, - showIncludesExcludes: boolean, -}; - -export class SearchEditorContribution implements IWorkbenchContribution { - constructor( - @IEditorService private readonly editorService: IEditorService, - @ITextFileService protected readonly textFileService: ITextFileService, - @IInstantiationService protected readonly instantiationService: IInstantiationService, - @IModelService protected readonly modelService: IModelService, - ) { - - this.editorService.overrideOpenEditor((editor, options, group) => { - const resource = editor.getResource(); - if (!resource || - !(endsWith(resource.path, '.code-search') || resource.scheme === 'search-editor') || - !(editor instanceof FileEditorInput || (resource.scheme === 'search-editor'))) { - return undefined; - } - - if (group.isOpened(editor)) { - return undefined; - } - - return { - override: (async () => { - const contents = resource.scheme === 'search-editor' ? this.modelService.getModel(resource)?.getValue() ?? '' : (await this.textFileService.read(resource)).value; - const header = searchHeaderToContentPattern(contents.split('\n').slice(0, 5)); - - const input = instantiationService.createInstance( - SearchEditorInput, - { - query: header.pattern, - regexp: header.flags.regex, - caseSensitive: header.flags.caseSensitive, - wholeWord: header.flags.wholeWord, - includes: header.includes, - excludes: header.excludes, - contextLines: header.context ?? 0, - useIgnores: !header.flags.ignoreExcludes, - showIncludesExcludes: !!(header.includes || header.excludes || header.flags.ignoreExcludes) - }, contents, resource); - - return editorService.openEditor(input, { ...options, pinned: resource.scheme === 'search-editor', ignoreOverrides: true }, group); - })() - }; - }); - } -} - -export class SearchEditorInputFactory implements IEditorInputFactory { - - canSerialize() { return true; } - - serialize(input: SearchEditorInput) { - let resource = undefined; - if (input.resource.path || input.resource.fragment) { - resource = input.resource.toString(); - } - - return JSON.stringify({ ...input.config, resource, dirty: input.isDirty() }); - } - - deserialize(instantiationService: IInstantiationService, serializedEditorInput: string): SearchEditorInput | undefined { - const { resource, dirty, ...config } = JSON.parse(serializedEditorInput); - const input = instantiationService.createInstance(SearchEditorInput, config, undefined, resource && URI.parse(resource)); - input.setDirty(dirty); - return input; - } -} - -export class SearchEditorInput extends EditorInput { - static readonly ID: string = 'workbench.editorinputs.searchEditorInput'; - - private _config: SearchConfiguration; - public get config(): Readonly { - return this._config; - } - - private model: ITextModel; - public readonly resource: URI; - - private dirty: boolean = false; - private hasRestoredFromBackup = false; - - constructor( - config: Partial | undefined, - initialContents: string | undefined, - resource: URI | undefined, - @IModelService private readonly modelService: IModelService, - @IModeService private readonly modeService: IModeService, - @IEditorService protected readonly editorService: IEditorService, - @IEditorGroupsService protected readonly editorGroupService: IEditorGroupsService, - @ITextFileService protected readonly textFileService: ITextFileService, - @IHistoryService private readonly historyService: IHistoryService, - @IWorkbenchEnvironmentService private readonly environmentService: IWorkbenchEnvironmentService, - @IFileDialogService private readonly fileDialogService: IFileDialogService, - @IInstantiationService private readonly instantiationService: IInstantiationService, - @IWorkingCopyService private readonly workingCopyService: IWorkingCopyService, - @IBackupFileService private readonly backupService: IBackupFileService, - ) { - super(); - this.resource = resource ?? URI.from({ scheme: 'search-editor', fragment: `${Math.random()}` }); - this._config = { ...{ query: '', includes: '', excludes: '', contextLines: 0, wholeWord: false, caseSensitive: false, regexp: false, useIgnores: true, showIncludesExcludes: false }, ...config }; - - const searchResultMode = this.modeService.create('search-result'); - - this.model = this.modelService.getModel(this.resource) ?? this.modelService.createModel(initialContents ?? '', searchResultMode, this.resource); - - const workingCopyAdapter: IWorkingCopy = { - resource: this.resource, - capabilities: this.resource.scheme === 'search-editor' ? WorkingCopyCapabilities.Untitled : 0, - onDidChangeDirty: this.onDidChangeDirty, - onDidChangeContent: this.onDidChangeDirty, - isDirty: () => this.isDirty(), - backup: () => this.backup(), - save: (options) => this.save(0, options), - revert: () => this.revert(), - }; - - - this.workingCopyService.registerWorkingCopy(workingCopyAdapter); - } - - async save(group: GroupIdentifier, options?: ITextFileSaveOptions): Promise { - if (this.resource.scheme === 'search-editor') { - const path = await this.promptForPath(this.resource, this.suggestFileName(), options?.availableFileSystems); - if (path) { - if (await this.textFileService.saveAs(this.resource, path, options)) { - this.setDirty(false); - if (options?.context !== SaveContext.EDITOR_CLOSE && !isEqual(path, this.resource)) { - const replacement = this.instantiationService.createInstance(SearchEditorInput, this.config, undefined, path); - await this.editorService.replaceEditors([{ editor: this, replacement, options: { pinned: true } }], group); - return true; - } else if (options?.context === SaveContext.EDITOR_CLOSE) { - return true; - } - } - } - return false; - } else { - this.setDirty(false); - return !!this.textFileService.write(this.resource, this.model.getValue(), options); - } - } - - // Brining this over from textFileService because it only suggests for untitled scheme. - // In the future I may just use the untitled scheme. I dont get particular benefit from using search-editor... - private async promptForPath(resource: URI, defaultUri: URI, availableFileSystems?: string[]): Promise { - // Help user to find a name for the file by opening it first - await this.editorService.openEditor({ resource, options: { revealIfOpened: true, preserveFocus: true } }); - return this.fileDialogService.pickFileToSave(defaultUri, availableFileSystems); - } - - getTypeId(): string { - return SearchEditorInput.ID; - } - - getName(): string { - if (this.resource.scheme === 'search-editor') { - return this.config.query ? localize('searchTitle.withQuery', "Search: {0}", this.config.query) : localize('searchTitle', "Search"); - } - return localize('searchTitle.withQuery', "Search: {0}", basename(this.resource.path, '.code-search')); - } - - setConfig(config: SearchConfiguration) { - this._config = config; - this._onDidChangeLabel.fire(); - } - - async resolve() { - return null; - } - - async resolveBackup(): Promise { - if (this.hasRestoredFromBackup === true) { return undefined; } - this.hasRestoredFromBackup = true; - return (await this.backupService.resolve(this.resource))?.value.create(DefaultEndOfLine.LF); - } - - setDirty(dirty: boolean) { - this.dirty = dirty; - this._onDidChangeDirty.fire(); - } - - isDirty() { - return this.dirty; - } - - dispose() { - this.modelService.destroyModel(this.resource); - super.dispose(); - } - - matches(other: unknown) { - if (this === other) { return true; } - - if (other instanceof SearchEditorInput) { - if ( - (other.resource.path && other.resource.path === this.resource.path) || - (other.resource.fragment && other.resource.fragment === this.resource.fragment) - ) { - return true; - } - } - return false; - } - - async revert(options?: IRevertOptions) { - // TODO: this should actually revert the contents. But it needs to set dirty false. - super.revert(options); - this.setDirty(false); - return true; - } - - private async backup(): Promise { - if (this.model.isDisposed() || this.model.getValueLength() === 0) { - // FIXME: this is clearly not good, but `backup` is sometimes getting called after the - // model disposes, so we cant reliably grab the snapshot from the model. Instead fall back to the existing snapshot, if one exists. - // Ideally we'd return undefined and thus signal we dont want to overwrite any existing backup. - return { content: (await this.backupService.resolve(this.resource))?.value.create(DefaultEndOfLine.LF).createSnapshot(true) }; - } - - return { content: this.model.createSnapshot() }; - } - - // Bringing this over from textFileService because it only suggests for untitled scheme. - // In the future I may just use the untitled scheme. I dont get particular benefit from using search-editor... - private suggestFileName(): URI { - const searchFileName = (this.config.query.replace(/[^\w \-_]+/g, '_') || 'Search') + '.code-search'; - - const remoteAuthority = this.environmentService.configuration.remoteAuthority; - const schemeFilter = remoteAuthority ? network.Schemas.vscodeRemote : network.Schemas.file; - - const lastActiveFile = this.historyService.getLastActiveFile(schemeFilter); - if (lastActiveFile) { - const lastDir = dirname(lastActiveFile); - return joinPath(lastDir, searchFileName); - } - - const lastActiveFolder = this.historyService.getLastActiveWorkspaceRoot(schemeFilter); - if (lastActiveFolder) { - return joinPath(lastActiveFolder, searchFileName); - } - - return URI.from({ scheme: schemeFilter, path: searchFileName }); - } -} - -// 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 matchToSearchResultFormat = (match: Match): { line: string, ranges: Range[], lineNumber: string }[] => { - const getLinePrefix = (i: number) => `${match.range().startLineNumber + i}`; - - const fullMatchLines = match.fullPreviewLines(); - const largestPrefixSize = fullMatchLines.reduce((largest, _, i) => Math.max(getLinePrefix(i).length, largest), 0); - - - const results: { line: string, ranges: Range[], lineNumber: string }[] = []; - - fullMatchLines - .forEach((sourceLine, i) => { - const lineNumber = getLinePrefix(i); - const paddingStr = repeat(' ', largestPrefixSize - lineNumber.length); - const prefix = ` ${lineNumber}: ${paddingStr}`; - const prefixOffset = prefix.length; - - const line = (prefix + sourceLine).replace(/\r?\n?$/, ''); - - const rangeOnThisLine = ({ start, end }: { start?: number; end?: number; }) => new Range(1, (start ?? 1) + prefixOffset, 1, (end ?? sourceLine.length + 1) + prefixOffset); - - const matchRange = match.range(); - const matchIsSingleLine = matchRange.startLineNumber === matchRange.endLineNumber; - - let lineRange; - if (matchIsSingleLine) { lineRange = (rangeOnThisLine({ start: matchRange.startColumn, end: matchRange.endColumn })); } - else if (i === 0) { lineRange = (rangeOnThisLine({ start: matchRange.startColumn })); } - else if (i === fullMatchLines.length - 1) { lineRange = (rangeOnThisLine({ end: matchRange.endColumn })); } - else { lineRange = (rangeOnThisLine({})); } - - results.push({ lineNumber: lineNumber, line, ranges: [lineRange] }); - }); - - return results; -}; - -type SearchResultSerialization = { text: string[], matchRanges: Range[] }; - -function fileMatchToSearchResultFormat(fileMatch: FileMatch, labelFormatter: (x: URI) => string): SearchResultSerialization { - const serializedMatches = flatten(fileMatch.matches() - .sort(searchMatchComparer) - .map(match => matchToSearchResultFormat(match))); - - const uriString = labelFormatter(fileMatch.resource); - let text: string[] = [`${uriString}:`]; - let matchRanges: Range[] = []; - - 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 }; -} - -const flattenSearchResultSerializations = (serializations: SearchResultSerialization[]): SearchResultSerialization => { - let text: string[] = []; - let matchRanges: Range[] = []; - - serializations.forEach(serialized => { - serialized.matchRanges.map(translateRangeLines(text.length)).forEach(range => matchRanges.push(range)); - serialized.text.forEach(line => text.push(line)); - text.push(''); // new line - }); - - return { text, matchRanges }; -}; - -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[]; - - const escapeNewlines = (str: string) => str.replace(/\\/g, '\\\\').replace(/\n/g, '\\n'); - - return removeNullFalseAndUndefined([ - `# Query: ${escapeNewlines(pattern.contentPattern.pattern)}`, - - (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.userDisabledExcludesAndIgnoreFiles && 'IgnoreExcludeSettings' - ]).join(' ')}`, - includes ? `# Including: ${includes}` : undefined, - excludes ? `# Excluding: ${excludes}` : undefined, - contextLines ? `# ContextLines: ${contextLines}` : undefined, - '' - ]); -}; - - -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: '', - context: undefined - }; - - const unescapeNewlines = (str: string) => { - let out = ''; - for (let i = 0; i < str.length; i++) { - if (str[i] === '\\') { - i++; - const escaped = str[i]; - - if (escaped === 'n') { - out += '\n'; - } - else if (escaped === '\\') { - out += '\\'; - } - else { - throw Error(localize('invalidQueryStringError', "All backslashes in Query string must be escaped (\\\\)")); - } - } else { - out += str[i]; - } - } - return out; - }; - 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 'ContextLines': query.context = +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; -}; - -export const serializeSearchResultForEditor = (searchResult: SearchResult, rawIncludePattern: string, rawExcludePattern: string, contextLines: number, labelFormatter: (x: URI) => string, includeHeader: boolean): SearchResultSerialization => { - const header = includeHeader ? 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))))); - - return { matchRanges: allResults.matchRanges.map(translateRangeLines(header.length)), text: header.concat(allResults.text.length ? allResults.text : ['No Results']) }; -}; - -export const openNewSearchEditor = - async (editorService: IEditorService, instantiationService: IInstantiationService) => { - const activeEditor = editorService.activeTextEditorWidget; - let activeModel: ICodeEditor | undefined; - if (isDiffEditor(activeEditor)) { - if (activeEditor.getOriginalEditor().hasTextFocus()) { - activeModel = activeEditor.getOriginalEditor(); - } else { - activeModel = activeEditor.getModifiedEditor(); - } - } else { - activeModel = activeEditor as ICodeEditor | undefined; - } - const selection = activeModel?.getSelection(); - let selected = (selection && activeModel?.getModel()?.getValueInRange(selection)) ?? ''; - await editorService.openEditor(instantiationService.createInstance(SearchEditorInput, { query: selected }, undefined, undefined), { pinned: true }); - }; - -export const createEditorFromSearchResult = - async (searchResult: SearchResult, rawIncludePattern: string, rawExcludePattern: string, labelService: ILabelService, editorService: IEditorService, textFileService: ITextFileService, instantiationService: IInstantiationService) => { - if (!searchResult.query) { - console.error('Expected searchResult.query to be defined. Got', searchResult); - return; - } - - const searchTerm = searchResult.query.contentPattern.pattern.replace(/[^\w-_. ]/g, '') || 'Search'; - - const labelFormatter = (uri: URI): string => labelService.getUriLabel(uri, { relative: true }); - - const results = serializeSearchResultForEditor(searchResult, rawIncludePattern, rawExcludePattern, 0, labelFormatter, true); - const contents = results.text.join(lineDelimiter); - let possible = { - contents, - mode: 'search-result', - resource: URI.from({ scheme: network.Schemas.untitled, path: searchTerm }) - }; - - let id = 0; - - let existing = textFileService.untitled.get(possible.resource); - while (existing) { - if (existing instanceof UntitledTextEditorInput) { - const model = await existing.resolve(); - const existingContents = model.textEditorModel.getValue(EndOfLinePreference.LF); - if (existingContents === contents) { - break; - } - } - possible.resource = possible.resource.with({ path: searchTerm + '-' + ++id }); - existing = textFileService.untitled.get(possible.resource); - } - - const input = instantiationService.createInstance( - SearchEditorInput, - { - query: searchResult.query.contentPattern.pattern, - regexp: !!searchResult.query.contentPattern.isRegExp, - caseSensitive: !!searchResult.query.contentPattern.isCaseSensitive, - wholeWord: !!searchResult.query.contentPattern.isWordMatch, - includes: rawIncludePattern, - excludes: rawExcludePattern, - contextLines: 0, - useIgnores: !searchResult.query.userDisabledExcludesAndIgnoreFiles, - showIncludesExcludes: !!(rawExcludePattern || rawExcludePattern || searchResult.query.userDisabledExcludesAndIgnoreFiles) - }, contents, undefined); - - const editor = await editorService.openEditor(input, { pinned: true }) as SearchEditor; - const model = assertIsDefined(editor.getModel()); - model.deltaDecorations([], results.matchRanges.map(range => ({ range, options: { className: 'searchEditorFindMatch', stickiness: TrackedRangeStickiness.NeverGrowsWhenTypingAtEdges } }))); - }; - -registerThemingParticipant((theme, collector) => { - collector.addRule(`.monaco-editor .searchEditorFindMatch { background-color: ${theme.getColor(searchEditorFindMatch)}; }`); - - const findMatchHighlightBorder = theme.getColor(searchEditorFindMatchBorder); - if (findMatchHighlightBorder) { - collector.addRule(`.monaco-editor .searchEditorFindMatch { border: 1px ${theme.type === 'hc' ? 'dotted' : 'solid'} ${findMatchHighlightBorder}; box-sizing: border-box; }`); - } -}); diff --git a/src/vs/workbench/contrib/search/browser/searchEditorInput.ts b/src/vs/workbench/contrib/search/browser/searchEditorInput.ts new file mode 100644 index 0000000000000000000000000000000000000000..59fab059cafb25652f38848c8c17382a67301bfe --- /dev/null +++ b/src/vs/workbench/contrib/search/browser/searchEditorInput.ts @@ -0,0 +1,309 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as network from 'vs/base/common/network'; +import { endsWith } from 'vs/base/common/strings'; +import { URI } from 'vs/base/common/uri'; +import 'vs/css!./media/searchEditor'; +import { ITextModel, ITextBufferFactory } from 'vs/editor/common/model'; +import { localize } from 'vs/nls'; +import { IInstantiationService, ServicesAccessor } from 'vs/platform/instantiation/common/instantiation'; +import { IEditorService } from 'vs/workbench/services/editor/common/editorService'; +import { IEditorInputFactory, GroupIdentifier, EditorInput, SaveContext, IRevertOptions } from 'vs/workbench/common/editor'; +import { IModelService } from 'vs/editor/common/services/modelService'; +import { IModeService } from 'vs/editor/common/services/modeService'; +import { IEditorGroupsService } from 'vs/workbench/services/editor/common/editorGroupsService'; +import { ITextFileSaveOptions, ITextFileService } from 'vs/workbench/services/textfile/common/textfiles'; +import type { IWorkbenchContribution } from 'vs/workbench/common/contributions'; +import { FileEditorInput } from 'vs/workbench/contrib/files/common/editors/fileEditorInput'; +import { dirname, joinPath, isEqual } from 'vs/base/common/resources'; +import { IHistoryService } from 'vs/workbench/services/history/common/history'; +import { IWorkbenchEnvironmentService } from 'vs/workbench/services/environment/common/environmentService'; +import { IFileDialogService } from 'vs/platform/dialogs/common/dialogs'; +import { basename } from 'vs/base/common/path'; +import { IWorkingCopyService, WorkingCopyCapabilities, IWorkingCopy, IWorkingCopyBackup } from 'vs/workbench/services/workingCopy/common/workingCopyService'; +import { IBackupFileService } from 'vs/workbench/services/backup/common/backup'; +import { assertIsDefined } from 'vs/base/common/types'; +import { extractSearchQuery, serializeSearchConfiguration } from 'vs/workbench/contrib/search/browser/searchEditorSerialization'; + +export type SearchConfiguration = { + query: string, + includes: string, + excludes: string + contextLines: number, + wholeWord: boolean, + caseSensitive: boolean, + regexp: boolean, + useIgnores: boolean, + showIncludesExcludes: boolean, +}; + +export class SearchEditorInput extends EditorInput { + static readonly ID: string = 'workbench.editorinputs.searchEditorInput'; + + private dirty: boolean = false; + private readonly model: Promise; + private resolvedModel?: { model: ITextModel, query: SearchConfiguration }; + + constructor( + public readonly resource: URI, + getModel: () => Promise, + @IModelService private readonly modelService: IModelService, + @IEditorService protected readonly editorService: IEditorService, + @IEditorGroupsService protected readonly editorGroupService: IEditorGroupsService, + @ITextFileService protected readonly textFileService: ITextFileService, + @IHistoryService private readonly historyService: IHistoryService, + @IWorkbenchEnvironmentService private readonly environmentService: IWorkbenchEnvironmentService, + @IFileDialogService private readonly fileDialogService: IFileDialogService, + @IInstantiationService private readonly instantiationService: IInstantiationService, + @IWorkingCopyService private readonly workingCopyService: IWorkingCopyService, + ) { + super(); + + this.model = getModel(); + + const workingCopyAdapter: IWorkingCopy = { + resource: this.resource, + capabilities: this.resource.scheme === 'search-editor' ? WorkingCopyCapabilities.Untitled : 0, + onDidChangeDirty: this.onDidChangeDirty, + onDidChangeContent: this.onDidChangeDirty, + isDirty: () => this.isDirty(), + backup: () => this.backup(), + save: (options) => this.save(0, options), + revert: () => this.revert(), + }; + + this.workingCopyService.registerWorkingCopy(workingCopyAdapter); + } + + async save(group: GroupIdentifier, options?: ITextFileSaveOptions): Promise { + if (this.resource.scheme === 'search-editor') { + const path = await this.promptForPath(this.resource, await this.suggestFileName(), options?.availableFileSystems); + if (path) { + if (await this.textFileService.saveAs(this.resource, path, options)) { + this.setDirty(false); + if (options?.context !== SaveContext.EDITOR_CLOSE && !isEqual(path, this.resource)) { + const replacement = this.instantiationService.invokeFunction(getOrMakeSearchEditorInput, { uri: path }); + await this.editorService.replaceEditors([{ editor: this, replacement, options: { pinned: true } }], group); + return true; + } else if (options?.context === SaveContext.EDITOR_CLOSE) { + return true; + } + } + } + return false; + } else { + this.setDirty(false); + return !!this.textFileService.write(this.resource, (await this.model).getValue(), options); + } + } + + // Brining this over from textFileService because it only suggests for untitled scheme. + // In the future I may just use the untitled scheme. I dont get particular benefit from using search-editor... + private async promptForPath(resource: URI, defaultUri: URI, availableFileSystems?: string[]): Promise { + // Help user to find a name for the file by opening it first + await this.editorService.openEditor({ resource, options: { revealIfOpened: true, preserveFocus: true } }); + return this.fileDialogService.pickFileToSave(defaultUri, availableFileSystems); + } + + getTypeId(): string { + return SearchEditorInput.ID; + } + + getName(): string { + if (this.resource.scheme === 'search-editor') { + return this.resolvedModel?.query.query + ? localize('searchTitle.withQuery', "Search: {0}", this.resolvedModel?.query.query) + : localize('searchTitle', "Search"); + } + + return localize('searchTitle.withQuery', "Search: {0}", basename(this.resource.path, '.code-search')); + } + + async reloadModel() { + const model = await this.model; + const query = extractSearchQuery(model); + this.resolvedModel = { model, query }; + this._onDidChangeLabel.fire(); + return { model, query }; + } + + getConfigSync() { + if (!this.resolvedModel) { + console.error('Requested config for Search Editor before initalization'); + } + + return this.resolvedModel?.query; + } + + async resolve() { + return null; + } + + setDirty(dirty: boolean) { + this.dirty = dirty; + this._onDidChangeDirty.fire(); + } + + isDirty() { + return this.dirty; + } + + dispose() { + this.modelService.destroyModel(this.resource); + super.dispose(); + } + + matches(other: unknown) { + if (this === other) { return true; } + + if (other instanceof SearchEditorInput) { + if ( + (other.resource.path && other.resource.path === this.resource.path) || + (other.resource.fragment && other.resource.fragment === this.resource.fragment) + ) { + return true; + } + } + return false; + } + + async revert(options?: IRevertOptions) { + // TODO: this should actually revert the contents. But it needs to set dirty false. + super.revert(options); + this.setDirty(false); + return true; + } + + private async backup(): Promise { + const content = (await this.model).createSnapshot(); + return { content }; + } + + // Bringing this over from textFileService because it only suggests for untitled scheme. + // In the future I may just use the untitled scheme. I dont get particular benefit from using search-editor... + private async suggestFileName(): Promise { + const query = (await this.reloadModel()).query.query; + + const searchFileName = (query.replace(/[^\w \-_]+/g, '_') || 'Search') + '.code-search'; + + const remoteAuthority = this.environmentService.configuration.remoteAuthority; + const schemeFilter = remoteAuthority ? network.Schemas.vscodeRemote : network.Schemas.file; + + const lastActiveFile = this.historyService.getLastActiveFile(schemeFilter); + if (lastActiveFile) { + const lastDir = dirname(lastActiveFile); + return joinPath(lastDir, searchFileName); + } + + const lastActiveFolder = this.historyService.getLastActiveWorkspaceRoot(schemeFilter); + if (lastActiveFolder) { + return joinPath(lastActiveFolder, searchFileName); + } + + return URI.from({ scheme: schemeFilter, path: searchFileName }); + } +} + + + +export class SearchEditorContribution implements IWorkbenchContribution { + constructor( + @IEditorService private readonly editorService: IEditorService, + @ITextFileService protected readonly textFileService: ITextFileService, + @IInstantiationService protected readonly instantiationService: IInstantiationService, + @IModelService protected readonly modelService: IModelService, + ) { + + this.editorService.overrideOpenEditor((editor, options, group) => { + const resource = editor.getResource(); + if (!resource || + !(endsWith(resource.path, '.code-search') || resource.scheme === 'search-editor') || + !(editor instanceof FileEditorInput || (resource.scheme === 'search-editor'))) { + return undefined; + } + + if (group.isOpened(editor)) { + return undefined; + } + + const input = instantiationService.invokeFunction(getOrMakeSearchEditorInput, { uri: resource }); + const opened = editorService.openEditor(input, { ...options, pinned: resource.scheme === 'search-editor', ignoreOverrides: true }, group); + return { override: Promise.resolve(opened) }; + }); + } +} + +export class SearchEditorInputFactory implements IEditorInputFactory { + + canSerialize() { return true; } + + serialize(input: SearchEditorInput) { + let resource = undefined; + if (input.resource.path || input.resource.fragment) { + resource = input.resource.toString(); + } + + const config = input.getConfigSync(); + + return JSON.stringify({ resource, dirty: input.isDirty(), config }); + } + + deserialize(instantiationService: IInstantiationService, serializedEditorInput: string): SearchEditorInput | undefined { + const { resource, dirty, config } = JSON.parse(serializedEditorInput); + + const input = instantiationService.invokeFunction(getOrMakeSearchEditorInput, { text: serializeSearchConfiguration(config), uri: URI.parse(resource) }); + input.setDirty(dirty); + return input; + } +} + + +const inputs = new Map(); +export const getOrMakeSearchEditorInput = ( + accessor: ServicesAccessor, + existingData: { uri: URI, text?: string } | { text: string, uri?: URI } +): SearchEditorInput => { + + const uri = existingData.uri ?? URI.from({ scheme: 'search-editor', fragment: `${Math.random()}` }); + + const instantiationService = accessor.get(IInstantiationService); + const modelService = accessor.get(IModelService); + const textFileService = accessor.get(ITextFileService); + const backupService = accessor.get(IBackupFileService); + const modeService = accessor.get(IModeService); + + const existing = inputs.get(uri.toString()); + if (existing) { + return existing; + } + + + const getModel = async () => { + const existing = modelService.getModel(uri); + if (existing) { return existing; } + + // must be called before `hasBackupSync` to ensure the backup service is initalized. + await backupService.getBackups(); + + let contents: string | ITextBufferFactory; + if (backupService.hasBackupSync(uri)) { + contents = assertIsDefined((await backupService.resolve(uri))?.value); + // backupService.discardBackup(uri); + } else if (uri.scheme !== 'search-editor') { + contents = (await textFileService.read(uri)).value; + } else { + contents = existingData.text ?? ''; + } + return modelService.createModel(contents, modeService.create('search-result'), uri); + }; + + const input = instantiationService.createInstance(SearchEditorInput, uri, getModel); + + inputs.set(uri.toString(), input); + input.onDispose(() => inputs.delete(uri.toString())); + + return input; +}; diff --git a/src/vs/workbench/contrib/search/browser/searchEditorSerialization.ts b/src/vs/workbench/contrib/search/browser/searchEditorSerialization.ts new file mode 100644 index 0000000000000000000000000000000000000000..e813406532e10afc1fd387355d1a351b1758e34d --- /dev/null +++ b/src/vs/workbench/contrib/search/browser/searchEditorSerialization.ts @@ -0,0 +1,248 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import 'vs/css!./media/searchEditor'; +import { coalesce, flatten } from 'vs/base/common/arrays'; +import { repeat } from 'vs/base/common/strings'; +import { URI } from 'vs/base/common/uri'; +import { Range } from 'vs/editor/common/core/range'; +import { FileMatch, Match, searchMatchComparer, SearchResult } from 'vs/workbench/contrib/search/common/searchModel'; +import { ITextQuery } from 'vs/workbench/services/search/common/search'; +import { localize } from 'vs/nls'; +import type { ITextModel } from 'vs/editor/common/model'; +import type { SearchConfiguration } from 'vs/workbench/contrib/search/browser/searchEditorInput'; + +// 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 matchToSearchResultFormat = (match: Match): { line: string, ranges: Range[], lineNumber: string }[] => { + const getLinePrefix = (i: number) => `${match.range().startLineNumber + i}`; + + const fullMatchLines = match.fullPreviewLines(); + const largestPrefixSize = fullMatchLines.reduce((largest, _, i) => Math.max(getLinePrefix(i).length, largest), 0); + + + const results: { line: string, ranges: Range[], lineNumber: string }[] = []; + + fullMatchLines + .forEach((sourceLine, i) => { + const lineNumber = getLinePrefix(i); + const paddingStr = repeat(' ', largestPrefixSize - lineNumber.length); + const prefix = ` ${lineNumber}: ${paddingStr}`; + const prefixOffset = prefix.length; + + const line = (prefix + sourceLine).replace(/\r?\n?$/, ''); + + const rangeOnThisLine = ({ start, end }: { start?: number; end?: number; }) => new Range(1, (start ?? 1) + prefixOffset, 1, (end ?? sourceLine.length + 1) + prefixOffset); + + const matchRange = match.range(); + const matchIsSingleLine = matchRange.startLineNumber === matchRange.endLineNumber; + + let lineRange; + if (matchIsSingleLine) { lineRange = (rangeOnThisLine({ start: matchRange.startColumn, end: matchRange.endColumn })); } + else if (i === 0) { lineRange = (rangeOnThisLine({ start: matchRange.startColumn })); } + else if (i === fullMatchLines.length - 1) { lineRange = (rangeOnThisLine({ end: matchRange.endColumn })); } + else { lineRange = (rangeOnThisLine({})); } + + results.push({ lineNumber: lineNumber, line, ranges: [lineRange] }); + }); + + return results; +}; + +type SearchResultSerialization = { text: string[], matchRanges: Range[] }; + +function fileMatchToSearchResultFormat(fileMatch: FileMatch, labelFormatter: (x: URI) => string): SearchResultSerialization { + const serializedMatches = flatten(fileMatch.matches() + .sort(searchMatchComparer) + .map(match => matchToSearchResultFormat(match))); + + const uriString = labelFormatter(fileMatch.resource); + let text: string[] = [`${uriString}:`]; + let matchRanges: Range[] = []; + + 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 }; +} + +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[]; + + const escapeNewlines = (str: string) => str.replace(/\\/g, '\\\\').replace(/\n/g, '\\n'); + + return removeNullFalseAndUndefined([ + `# Query: ${escapeNewlines(pattern.contentPattern.pattern)}`, + + (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.userDisabledExcludesAndIgnoreFiles && 'IgnoreExcludeSettings' + ]).join(' ')}`, + includes ? `# Including: ${includes}` : undefined, + excludes ? `# Excluding: ${excludes}` : undefined, + contextLines ? `# ContextLines: ${contextLines}` : undefined + ]); +}; + +export const serializeSearchConfiguration = (config: Partial): string => { + const removeNullFalseAndUndefined = (a: (T | null | false | undefined)[]) => a.filter(a => a !== false && a !== null && a !== undefined) as T[]; + + const escapeNewlines = (str: string) => str.replace(/\\/g, '\\\\').replace(/\n/g, '\\n'); + + return removeNullFalseAndUndefined([ + `# Query: ${escapeNewlines(config.query ?? '')}`, + + (config.caseSensitive || config.wholeWord || config.regexp || config.useIgnores === false) + && `# Flags: ${coalesce([ + config.caseSensitive && 'CaseSensitive', + config.wholeWord && 'WordMatch', + config.regexp && 'RegExp', + (config.useIgnores === false) && 'IgnoreExcludeSettings' + ]).join(' ')}`, + config.includes ? `# Including: ${config.includes}` : undefined, + config.excludes ? `# Excluding: ${config.excludes}` : undefined, + config.contextLines ? `# ContextLines: ${config.contextLines}` : undefined, + '' + ]).join(lineDelimiter); +}; + + +export const extractSearchQuery = (model: ITextModel): SearchConfiguration => { + const header = model.getValueInRange(new Range(1, 1, 6, 1)).split(lineDelimiter); + + const query: SearchConfiguration = { + query: '', + includes: '', + excludes: '', + regexp: false, + caseSensitive: false, + useIgnores: true, + wholeWord: false, + contextLines: 0, + showIncludesExcludes: false, + }; + + const unescapeNewlines = (str: string) => { + let out = ''; + for (let i = 0; i < str.length; i++) { + if (str[i] === '\\') { + i++; + const escaped = str[i]; + + if (escaped === 'n') { + out += '\n'; + } + else if (escaped === '\\') { + out += '\\'; + } + else { + throw Error(localize('invalidQueryStringError', "All backslashes in Query string must be escaped (\\\\)")); + } + } else { + out += str[i]; + } + } + return out; + }; + + const parseYML = /^# ([^:]*): (.*)$/; + for (const line of header) { + const parsed = parseYML.exec(line); + if (!parsed) { continue; } + const [, key, value] = parsed; + switch (key) { + case 'Query': query.query = unescapeNewlines(value); break; + case 'Including': query.includes = value; break; + case 'Excluding': query.excludes = value; break; + case 'ContextLines': query.contextLines = +value; break; + case 'Flags': { + query.regexp = value.indexOf('RegExp') !== -1; + query.caseSensitive = value.indexOf('CaseSensitive') !== -1; + query.useIgnores = value.indexOf('IgnoreExcludeSettings') === -1; + query.wholeWord = value.indexOf('WordMatch') !== -1; + } + } + } + + query.showIncludesExcludes = !!(query.includes || query.excludes || !query.useIgnores); + + return query; +}; + +export const serializeSearchResultForEditor = + (searchResult: SearchResult, rawIncludePattern: string, rawExcludePattern: string, contextLines: number, labelFormatter: (x: URI) => string, includeHeader: boolean): { matchRanges: Range[], text: string } => { + const header = includeHeader + ? 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))))); + + return { + matchRanges: allResults.matchRanges.map(translateRangeLines(header.length)), + text: header + .concat(allResults.text.length ? allResults.text : ['No Results']) + .join(lineDelimiter) + }; + }; + +const flattenSearchResultSerializations = (serializations: SearchResultSerialization[]): SearchResultSerialization => { + let text: string[] = []; + let matchRanges: Range[] = []; + + serializations.forEach(serialized => { + serialized.matchRanges.map(translateRangeLines(text.length)).forEach(range => matchRanges.push(range)); + serialized.text.forEach(line => text.push(line)); + text.push(''); // new line + }); + + return { text, matchRanges }; +}; diff --git a/src/vs/workbench/contrib/search/browser/searchView.ts b/src/vs/workbench/contrib/search/browser/searchView.ts index 4cd26d8bd5fabe2c7dc868238b1fdf466a98751e..7015b1cc6fb44046e446c3514202fa3c92c8fb82 100644 --- a/src/vs/workbench/contrib/search/browser/searchView.ts +++ b/src/vs/workbench/contrib/search/browser/searchView.ts @@ -64,7 +64,7 @@ import { IOpenerService } from 'vs/platform/opener/common/opener'; import { MultiCursorSelectionController } from 'vs/editor/contrib/multicursor/multicursor'; import { Selection } from 'vs/editor/common/core/selection'; import { SIDE_BAR_BACKGROUND, PANEL_BACKGROUND } from 'vs/workbench/common/theme'; -import { createEditorFromSearchResult } from 'vs/workbench/contrib/search/browser/searchEditorCommands'; +import { createEditorFromSearchResult } from 'vs/workbench/contrib/search/browser/searchEditorActions'; import { ILabelService } from 'vs/platform/label/common/label'; import { Color, RGBA } from 'vs/base/common/color'; @@ -1559,7 +1559,7 @@ export class SearchView extends ViewPane { this.messageDisposables.push(dom.addDisposableListener(openInEditorLink, dom.EventType.CLICK, (e: MouseEvent) => { dom.EventHelper.stop(e, false); - createEditorFromSearchResult(this.searchResult, this.searchIncludePattern.getValue(), this.searchExcludePattern.getValue(), this.labelService, this.editorService, this.textFileService, this.instantiationService); + createEditorFromSearchResult(this.searchResult, this.searchIncludePattern.getValue(), this.searchExcludePattern.getValue(), this.labelService, this.editorService, this.instantiationService); })); } else {