/*--------------------------------------------------------------------------------------------- * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ import { Emitter, Event } from 'vs/base/common/event'; import * as network from 'vs/base/common/network'; import { basename } from 'vs/base/common/path'; import { extname, isEqual, joinPath } from 'vs/base/common/resources'; import { URI } from 'vs/base/common/uri'; import 'vs/css!./media/searchEditor'; import { Range } from 'vs/editor/common/core/range'; import { DefaultEndOfLine, ITextModel, TrackedRangeStickiness } from 'vs/editor/common/model'; import { IModelService } from 'vs/editor/common/services/modelService'; import { IModeService } from 'vs/editor/common/services/modeService'; import { localize } from 'vs/nls'; import { IFileDialogService } from 'vs/platform/dialogs/common/dialogs'; import { IInstantiationService, ServicesAccessor } from 'vs/platform/instantiation/common/instantiation'; import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry'; import { EditorInput, GroupIdentifier, IEditorInput, IMoveResult, IRevertOptions, ISaveOptions } from 'vs/workbench/common/editor'; import { FileEditorInput } from 'vs/workbench/contrib/files/common/editors/fileEditorInput'; import { SearchEditorFindMatchClass, SearchEditorScheme } from 'vs/workbench/contrib/searchEditor/browser/constants'; import { extractSearchQueryFromModel, parseSavedSearchEditor, serializeSearchConfiguration } from 'vs/workbench/contrib/searchEditor/browser/searchEditorSerialization'; import { IBackupFileService } from 'vs/workbench/services/backup/common/backup'; import { IWorkbenchEnvironmentService } from 'vs/workbench/services/environment/common/environmentService'; import { AutoSaveMode, IFilesConfigurationService } from 'vs/workbench/services/filesConfiguration/common/filesConfigurationService'; import { IRemotePathService } from 'vs/workbench/services/path/common/remotePathService'; import { ITextFileSaveOptions, ITextFileService, snapshotToString, stringToSnapshot } from 'vs/workbench/services/textfile/common/textfiles'; import { IWorkingCopy, IWorkingCopyBackup, IWorkingCopyService, WorkingCopyCapabilities } from 'vs/workbench/services/workingCopy/common/workingCopyService'; export type SearchConfiguration = { query: string, includes: string, excludes: string contextLines: number, wholeWord: boolean, caseSensitive: boolean, regexp: boolean, useIgnores: boolean, showIncludesExcludes: boolean, }; const SEARCH_EDITOR_EXT = '.code-search'; export class SearchEditorInput extends EditorInput { static readonly ID: string = 'workbench.editorinputs.searchEditorInput'; private dirty: boolean = false; private model: Promise; private _cachedModel: ITextModel | undefined; private readonly _onDidChangeContent = this._register(new Emitter()); readonly onDidChangeContent: Event = this._onDidChangeContent.event; private oldDecorationsIDs: string[] = []; private _config: Readonly; public get config(): Readonly { return this._config; } public set config(value: Readonly) { this._config = value; this._onDidChangeLabel.fire(); } get resource() { return this.backingUri || this.modelUri; } constructor( public readonly modelUri: URI, public readonly backingUri: URI | undefined, config: Readonly, getModel: () => Promise, @IModelService private readonly modelService: IModelService, @ITextFileService protected readonly textFileService: ITextFileService, @IWorkbenchEnvironmentService private readonly environmentService: IWorkbenchEnvironmentService, @IFileDialogService private readonly fileDialogService: IFileDialogService, @IInstantiationService private readonly instantiationService: IInstantiationService, @IWorkingCopyService private readonly workingCopyService: IWorkingCopyService, @IFilesConfigurationService private readonly filesConfigurationService: IFilesConfigurationService, @ITelemetryService private readonly telemetryService: ITelemetryService, @IRemotePathService private readonly remotePathService: IRemotePathService ) { super(); this._config = config; this.model = getModel() .then(model => { this._register(model.onDidChangeContent(() => this._onDidChangeContent.fire())); this._register(model); this._cachedModel = model; return model; }); if (this.modelUri.scheme !== SearchEditorScheme) { throw Error('SearchEditorInput must be invoked with a SearchEditorScheme uri'); } const input = this; const workingCopyAdapter = new class implements IWorkingCopy { readonly resource = input.modelUri; get name() { return input.getName(); } readonly capabilities = input.isUntitled() ? WorkingCopyCapabilities.Untitled : 0; readonly onDidChangeDirty = input.onDidChangeDirty; readonly onDidChangeContent = input.onDidChangeContent; isDirty(): boolean { return input.isDirty(); } backup(): Promise { return input.backup(); } save(options?: ISaveOptions): Promise { return input.save(0, options).then(editor => !!editor); } revert(options?: IRevertOptions): Promise { return input.revert(0, options); } }; this._register(this.workingCopyService.registerWorkingCopy(workingCopyAdapter)); } async save(group: GroupIdentifier, options?: ITextFileSaveOptions): Promise { if ((await this.model).isDisposed()) { return; } if (this.backingUri) { await this.textFileService.write(this.backingUri, await this.serializeForDisk(), options); this.setDirty(false); return this; } else { return this.saveAs(group, options); } } private async serializeForDisk() { return serializeSearchConfiguration(this.config) + '\n' + (await this.model).getValue(); } async getModels() { return { config: this.config, body: await this.model }; } async saveAs(group: GroupIdentifier, options?: ITextFileSaveOptions): Promise { const path = await this.fileDialogService.pickFileToSave(await this.suggestFileName(), options?.availableFileSystems); if (path) { this.telemetryService.publicLog2('searchEditor/saveSearchResults'); const toWrite = await this.serializeForDisk(); if (await this.textFileService.create(path, toWrite, { overwrite: true })) { this.setDirty(false); if (!isEqual(path, this.modelUri)) { const input = this.instantiationService.invokeFunction(getOrMakeSearchEditorInput, { config: this.config, backingUri: path }); input.setMatchRanges(this.getMatchRanges()); return input; } return this; } } return undefined; } getTypeId(): string { return SearchEditorInput.ID; } getName(maxLength = 12): string { const trimToMax = (label: string) => (label.length < maxLength ? label : `${label.slice(0, maxLength - 3)}...`); if (this.backingUri) { return localize('searchTitle.withQuery', "Search: {0}", basename(this.backingUri?.path, SEARCH_EDITOR_EXT)); } const query = this.config.query?.trim(); if (query) { return localize('searchTitle.withQuery', "Search: {0}", trimToMax(query)); } return localize('searchTitle', "Search"); } async resolve() { return null; } setDirty(dirty: boolean) { this.dirty = dirty; this._onDidChangeDirty.fire(); } isDirty() { return this.dirty; } isSaving(): boolean { if (!this.isDirty()) { return false; // the editor needs to be dirty for being saved } if (this.isUntitled()) { return false; // untitled are not saving automatically } if (this.filesConfigurationService.getAutoSaveMode() === AutoSaveMode.AFTER_SHORT_DELAY) { return true; // a short auto save is configured, treat this as being saved } return false; } isReadonly() { return false; } isUntitled() { return !this.backingUri; } move(group: GroupIdentifier, target: URI): IMoveResult | undefined { if (this._cachedModel && extname(target) === SEARCH_EDITOR_EXT) { return { editor: this.instantiationService.invokeFunction(getOrMakeSearchEditorInput, { config: this.config, text: this._cachedModel.getValue(), backingUri: target }) }; } // Ignore move if editor was renamed to a different file extension return undefined; } dispose() { this.modelService.destroyModel(this.modelUri); super.dispose(); } matches(other: unknown) { if (this === other) { return true; } if (other instanceof SearchEditorInput) { return !!(other.modelUri.fragment && other.modelUri.fragment === this.modelUri.fragment); } else if (other instanceof FileEditorInput) { return other.resource?.toString() === this.backingUri?.toString(); } return false; } getMatchRanges(): Range[] { return (this._cachedModel?.getAllDecorations() ?? []) .filter(decoration => decoration.options.className === SearchEditorFindMatchClass) .filter(({ range }) => !(range.startColumn === 1 && range.endColumn === 1)) .map(({ range }) => range); } async setMatchRanges(ranges: Range[]) { this.oldDecorationsIDs = (await this.model).deltaDecorations(this.oldDecorationsIDs, ranges.map(range => ({ range, options: { className: SearchEditorFindMatchClass, stickiness: TrackedRangeStickiness.NeverGrowsWhenTypingAtEdges } }))); } async revert(group: GroupIdentifier, options?: IRevertOptions) { // TODO: this should actually revert the contents. But it needs to set dirty false. if (this.backingUri) { const { config, text } = await this.instantiationService.invokeFunction(parseSavedSearchEditor, this.backingUri); (await this.model).setValue(text); this.config = config; } else { (await this.model).setValue(''); } super.revert(group, options); this.setDirty(false); } supportsSplitEditor() { return false; } private async backup(): Promise { const content = stringToSnapshot((await this.model).getValue()); return { content }; } private async suggestFileName(): Promise { const query = extractSearchQueryFromModel(await this.model).query; const searchFileName = (query.replace(/[^\w \-_]+/g, '_') || 'Search') + SEARCH_EDITOR_EXT; const remoteAuthority = this.environmentService.configuration.remoteAuthority; const schemeFilter = remoteAuthority ? network.Schemas.vscodeRemote : network.Schemas.file; return joinPath(this.fileDialogService.defaultFilePath(schemeFilter) || (await this.remotePathService.userHome), searchFileName); } } const inputs = new Map(); export const getOrMakeSearchEditorInput = ( accessor: ServicesAccessor, existingData: ({ config: Partial, backingUri?: URI } & ({ modelUri: URI, text?: never, } | { text: string, modelUri?: never, } | { backingUri: URI, text?: never, modelUri?: never })) ): SearchEditorInput => { const defaultConfig: SearchConfiguration = { caseSensitive: false, contextLines: 0, excludes: '', includes: '', query: '', regexp: false, showIncludesExcludes: false, useIgnores: true, wholeWord: false }; let config = { ...defaultConfig, ...existingData.config }; const modelUri = existingData.modelUri ?? URI.from({ scheme: SearchEditorScheme, fragment: `${Math.random()}` }); const instantiationService = accessor.get(IInstantiationService); const modelService = accessor.get(IModelService); const backupService = accessor.get(IBackupFileService); const modeService = accessor.get(IModeService); const existing = inputs.get(modelUri.toString() + existingData.backingUri?.toString()); if (existing) { return existing; } const getModel = async () => { let contents: string; const backup = await backupService.resolve(modelUri); if (backup) { // this way of stringifying a TextBufferFactory seems needlessly complicated... contents = snapshotToString(backup.value.create(DefaultEndOfLine.LF).createSnapshot(true)); } else if (existingData.text !== undefined) { contents = existingData.text; } else if (existingData.backingUri !== undefined) { const { text } = await instantiationService.invokeFunction(parseSavedSearchEditor, existingData.backingUri); contents = text; } else if (config !== undefined) { contents = ''; } else { throw new Error('no initial contents for search editor'); } backupService.discardBackup(modelUri); return modelService.getModel(modelUri) ?? modelService.createModel(contents, modeService.create('search-result'), modelUri); }; const input = instantiationService.createInstance(SearchEditorInput, modelUri, existingData.backingUri, config, getModel); inputs.set(modelUri.toString(), input); input.onDispose(() => inputs.delete(modelUri.toString())); return input; };