searchEditorInput.ts 13.5 KB
Newer Older
1 2 3 4 5
/*---------------------------------------------------------------------------------------------
 *  Copyright (c) Microsoft Corporation. All rights reserved.
 *  Licensed under the MIT License. See License.txt in the project root for license information.
 *--------------------------------------------------------------------------------------------*/

6
import { Emitter, Event } from 'vs/base/common/event';
7
import * as network from 'vs/base/common/network';
8
import { basename } from 'vs/base/common/path';
9
import { extname, isEqual, joinPath } from 'vs/base/common/resources';
10 11
import { URI } from 'vs/base/common/uri';
import 'vs/css!./media/searchEditor';
12 13
import { Range } from 'vs/editor/common/core/range';
import { DefaultEndOfLine, ITextModel, TrackedRangeStickiness } from 'vs/editor/common/model';
14 15
import { IModelService } from 'vs/editor/common/services/modelService';
import { IModeService } from 'vs/editor/common/services/modeService';
16
import { localize } from 'vs/nls';
17
import { IConfigurationService } from 'vs/platform/configuration/common/configuration';
18
import { IFileDialogService } from 'vs/platform/dialogs/common/dialogs';
19
import { IInstantiationService, ServicesAccessor } from 'vs/platform/instantiation/common/instantiation';
20
import { IStorageService, StorageScope } from 'vs/platform/storage/common/storage';
21
import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry';
22
import { EditorInput, GroupIdentifier, IEditorInput, IMoveResult, IRevertOptions, ISaveOptions } from 'vs/workbench/common/editor';
23
import { Memento } from 'vs/workbench/common/memento';
24 25
import { FileEditorInput } from 'vs/workbench/contrib/files/common/editors/fileEditorInput';
import { SearchEditorFindMatchClass, SearchEditorScheme } from 'vs/workbench/contrib/searchEditor/browser/constants';
26
import { defaultSearchConfig, extractSearchQueryFromModel, parseSavedSearchEditor, serializeSearchConfiguration } from 'vs/workbench/contrib/searchEditor/browser/searchEditorSerialization';
27 28 29
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';
30
import { IPathService } from 'vs/workbench/services/path/common/pathService';
31
import { ISearchConfigurationProperties } from 'vs/workbench/services/search/common/search';
32
import { ITextFileSaveOptions, ITextFileService, snapshotToString, stringToSnapshot } from 'vs/workbench/services/textfile/common/textfiles';
33
import { IWorkingCopy, IWorkingCopyBackup, IWorkingCopyService, WorkingCopyCapabilities } from 'vs/workbench/services/workingCopy/common/workingCopyService';
34

35

36 37 38 39 40 41 42 43 44 45 46 47
export type SearchConfiguration = {
	query: string,
	includes: string,
	excludes: string
	contextLines: number,
	wholeWord: boolean,
	caseSensitive: boolean,
	regexp: boolean,
	useIgnores: boolean,
	showIncludesExcludes: boolean,
};

48 49
const SEARCH_EDITOR_EXT = '.code-search';

50 51 52
export class SearchEditorInput extends EditorInput {
	static readonly ID: string = 'workbench.editorinputs.searchEditorInput';

53 54
	private memento: Memento;

55
	private dirty: boolean = false;
56 57
	private model: Promise<ITextModel>;
	private _cachedModel: ITextModel | undefined;
58

59
	private readonly _onDidChangeContent = this._register(new Emitter<void>());
60 61
	readonly onDidChangeContent: Event<void> = this._onDidChangeContent.event;

J
Jackson Kearl 已提交
62
	private oldDecorationsIDs: string[] = [];
63

64 65
	private _config: Readonly<SearchConfiguration>;
	public get config(): Readonly<SearchConfiguration> { return this._config; }
66 67 68 69 70
	public set config(value: Readonly<SearchConfiguration>) {
		this._config = value;
		this.memento.getMemento(StorageScope.WORKSPACE).searchConfig = value;
		this._onDidChangeLabel.fire();
	}
71 72 73 74 75

	get resource() {
		return this.backingUri || this.modelUri;
	}

76
	constructor(
77 78 79 80
		public readonly modelUri: URI,
		public readonly backingUri: URI | undefined,
		config: Readonly<SearchConfiguration>,
		getModel: () => Promise<ITextModel>,
81 82 83 84 85 86
		@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,
87 88
		@IFilesConfigurationService private readonly filesConfigurationService: IFilesConfigurationService,
		@ITelemetryService private readonly telemetryService: ITelemetryService,
89
		@IPathService private readonly pathService: IPathService,
90
		@IStorageService storageService: IStorageService,
91 92 93
	) {
		super();

94 95 96 97 98 99 100
		this._config = config;
		this.model = getModel()
			.then(model => {
				this._register(model.onDidChangeContent(() => this._onDidChangeContent.fire()));
				this._register(model);
				this._cachedModel = model;
				return model;
101
			});
102

103 104 105
		if (this.modelUri.scheme !== SearchEditorScheme) {
			throw Error('SearchEditorInput must be invoked with a SearchEditorScheme uri');
		}
106

107 108 109
		this.memento = new Memento(SearchEditorInput.ID, storageService);
		storageService.onWillSaveState(() => this.memento.saveMemento());

B
Benjamin Pasero 已提交
110 111
		const input = this;
		const workingCopyAdapter = new class implements IWorkingCopy {
112
			readonly resource = input.modelUri;
B
Benjamin Pasero 已提交
113 114 115
			get name() { return input.getName(); }
			readonly capabilities = input.isUntitled() ? WorkingCopyCapabilities.Untitled : 0;
			readonly onDidChangeDirty = input.onDidChangeDirty;
116
			readonly onDidChangeContent = input.onDidChangeContent;
B
Benjamin Pasero 已提交
117 118 119
			isDirty(): boolean { return input.isDirty(); }
			backup(): Promise<IWorkingCopyBackup> { return input.backup(); }
			save(options?: ISaveOptions): Promise<boolean> { return input.save(0, options).then(editor => !!editor); }
120
			revert(options?: IRevertOptions): Promise<void> { return input.revert(0, options); }
121 122
		};

J
Jackson Kearl 已提交
123
		this._register(this.workingCopyService.registerWorkingCopy(workingCopyAdapter));
124 125
	}

126
	async save(group: GroupIdentifier, options?: ITextFileSaveOptions): Promise<IEditorInput | undefined> {
127
		if ((await this.model).isDisposed()) { return; }
128

129 130
		if (this.backingUri) {
			await this.textFileService.write(this.backingUri, await this.serializeForDisk(), options);
131
			this.setDirty(false);
J
Jackson Kearl 已提交
132
			return this;
133 134
		} else {
			return this.saveAs(group, options);
135 136 137
		}
	}

138
	private async serializeForDisk() {
139
		return serializeSearchConfiguration(this.config) + '\n' + (await this.model).getValue();
140 141 142
	}

	async getModels() {
143
		return { config: this.config, body: await this.model };
144 145
	}

B
Benjamin Pasero 已提交
146 147 148
	async saveAs(group: GroupIdentifier, options?: ITextFileSaveOptions): Promise<IEditorInput | undefined> {
		const path = await this.fileDialogService.pickFileToSave(await this.suggestFileName(), options?.availableFileSystems);
		if (path) {
149
			this.telemetryService.publicLog2('searchEditor/saveSearchResults');
150 151
			const toWrite = await this.serializeForDisk();
			if (await this.textFileService.create(path, toWrite, { overwrite: true })) {
B
Benjamin Pasero 已提交
152
				this.setDirty(false);
153 154
				if (!isEqual(path, this.modelUri)) {
					const input = this.instantiationService.invokeFunction(getOrMakeSearchEditorInput, { config: this.config, backingUri: path });
155
					input.setMatchRanges(this.getMatchRanges());
156
					return input;
B
Benjamin Pasero 已提交
157 158 159 160 161 162 163
				}
				return this;
			}
		}
		return undefined;
	}

164 165 166 167
	getTypeId(): string {
		return SearchEditorInput.ID;
	}

168 169 170
	getName(maxLength = 12): string {
		const trimToMax = (label: string) => (label.length < maxLength ? label : `${label.slice(0, maxLength - 3)}...`);

171 172
		if (this.backingUri) {
			return localize('searchTitle.withQuery', "Search: {0}", basename(this.backingUri?.path, SEARCH_EDITOR_EXT));
173 174
		}

175 176 177 178 179
		const query = this.config.query?.trim();
		if (query) {
			return localize('searchTitle.withQuery', "Search: {0}", trimToMax(query));
		}
		return localize('searchTitle', "Search");
180 181 182 183 184 185
	}

	async resolve() {
		return null;
	}

186
	setDirty(dirty: boolean) {
187 188 189 190 191 192 193 194
		this.dirty = dirty;
		this._onDidChangeDirty.fire();
	}

	isDirty() {
		return this.dirty;
	}

B
Benjamin Pasero 已提交
195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215
	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() {
216
		return !this.backingUri;
B
Benjamin Pasero 已提交
217 218
	}

219
	move(group: GroupIdentifier, target: URI): IMoveResult | undefined {
220
		if (this._cachedModel && extname(target) === SEARCH_EDITOR_EXT) {
221
			return {
222
				editor: this.instantiationService.invokeFunction(getOrMakeSearchEditorInput, { config: this.config, text: this._cachedModel.getValue(), backingUri: target })
223 224 225 226 227 228
			};
		}
		// Ignore move if editor was renamed to a different file extension
		return undefined;
	}

229
	dispose() {
230
		this.modelService.destroyModel(this.modelUri);
231 232 233 234 235 236 237
		super.dispose();
	}

	matches(other: unknown) {
		if (this === other) { return true; }

		if (other instanceof SearchEditorInput) {
238 239 240
			return !!(other.modelUri.fragment && other.modelUri.fragment === this.modelUri.fragment);
		} else if (other instanceof FileEditorInput) {
			return other.resource?.toString() === this.backingUri?.toString();
241 242 243 244
		}
		return false;
	}

245 246
	getMatchRanges(): Range[] {
		return (this._cachedModel?.getAllDecorations() ?? [])
247
			.filter(decoration => decoration.options.className === SearchEditorFindMatchClass)
J
Jackson Kearl 已提交
248
			.filter(({ range }) => !(range.startColumn === 1 && range.endColumn === 1))
249
			.map(({ range }) => range);
250 251
	}

252 253
	async setMatchRanges(ranges: Range[]) {
		this.oldDecorationsIDs = (await this.model).deltaDecorations(this.oldDecorationsIDs, ranges.map(range =>
254
			({ range, options: { className: SearchEditorFindMatchClass, stickiness: TrackedRangeStickiness.NeverGrowsWhenTypingAtEdges } })));
255 256
	}

B
Benjamin Pasero 已提交
257
	async revert(group: GroupIdentifier, options?: IRevertOptions) {
258
		// TODO: this should actually revert the contents. But it needs to set dirty false.
259 260 261 262 263 264 265
		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('');
		}
B
Benjamin Pasero 已提交
266
		super.revert(group, options);
267 268 269
		this.setDirty(false);
	}

270 271 272 273
	supportsSplitEditor() {
		return false;
	}

274
	private async backup(): Promise<IWorkingCopyBackup> {
275
		const content = stringToSnapshot((await this.model).getValue());
276 277 278 279
		return { content };
	}

	private async suggestFileName(): Promise<URI> {
280
		const query = extractSearchQueryFromModel(await this.model).query;
281

282
		const searchFileName = (query.replace(/[^\w \-_]+/g, '_') || 'Search') + SEARCH_EDITOR_EXT;
283 284 285 286

		const remoteAuthority = this.environmentService.configuration.remoteAuthority;
		const schemeFilter = remoteAuthority ? network.Schemas.vscodeRemote : network.Schemas.file;

287
		return joinPath(this.fileDialogService.defaultFilePath(schemeFilter) || (await this.pathService.userHome), searchFileName);
288 289 290 291 292 293
	}
}

const inputs = new Map<string, SearchEditorInput>();
export const getOrMakeSearchEditorInput = (
	accessor: ServicesAccessor,
294 295 296 297
	existingData: ({ config: Partial<SearchConfiguration>, backingUri?: URI } &
		({ modelUri: URI, text?: never, } |
		{ text: string, modelUri?: never, } |
		{ backingUri: URI, text?: never, modelUri?: never }))
298 299 300 301 302 303
): SearchEditorInput => {

	const instantiationService = accessor.get(IInstantiationService);
	const modelService = accessor.get(IModelService);
	const backupService = accessor.get(IBackupFileService);
	const modeService = accessor.get(IModeService);
304 305 306 307 308 309 310 311 312 313
	const storageService = accessor.get(IStorageService);
	const configurationService = accessor.get(IConfigurationService);

	const reuseOldSettings = configurationService.getValue<ISearchConfigurationProperties>('search').searchEditor?.experimental?.reusePriorSearchConfiguration;
	const priorConfig: SearchConfiguration = reuseOldSettings ? new Memento(SearchEditorInput.ID, storageService).getMemento(StorageScope.WORKSPACE).searchConfig : {};
	const defaultConfig = defaultSearchConfig();
	let config = { ...defaultConfig, ...priorConfig, ...existingData.config };

	const modelUri = existingData.modelUri ?? URI.from({ scheme: SearchEditorScheme, fragment: `${Math.random()}` });

314 315
	const cacheKey = existingData.backingUri?.toString() ?? modelUri.toString();
	const existing = inputs.get(cacheKey);
316 317 318 319 320
	if (existing) {
		return existing;
	}

	const getModel = async () => {
321
		let contents: string;
322

323
		const backup = await backupService.resolve(modelUri);
324
		if (backup) {
325 326
			// this way of stringifying a TextBufferFactory seems needlessly complicated...
			contents = snapshotToString(backup.value.create(DefaultEndOfLine.LF).createSnapshot(true));
327
		} else if (existingData.text !== undefined) {
328
			contents = existingData.text;
329 330 331 332 333
		} else if (existingData.backingUri !== undefined) {
			const { text } = await instantiationService.invokeFunction(parseSavedSearchEditor, existingData.backingUri);
			contents = text;
		} else if (config !== undefined) {
			contents = '';
334
		} else {
335
			throw new Error('no initial contents for search editor');
336
		}
337
		backupService.discardBackup(modelUri);
338

339
		return modelService.getModel(modelUri) ?? modelService.createModel(contents, modeService.create('search-result'), modelUri);
340 341
	};

342
	const input = instantiationService.createInstance(SearchEditorInput, modelUri, existingData.backingUri, config, getModel);
343

344 345
	inputs.set(cacheKey, input);
	input.onDispose(() => inputs.delete(cacheKey));
346 347 348

	return input;
};