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 { isEqual, joinPath, extname } 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 { IFileDialogService } from 'vs/platform/dialogs/common/dialogs';
18
import { IInstantiationService, ServicesAccessor } from 'vs/platform/instantiation/common/instantiation';
19
import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry';
20
import { EditorInput, GroupIdentifier, IEditorInput, IRevertOptions, ISaveOptions, IMoveResult } from 'vs/workbench/common/editor';
J
Jackson Kearl 已提交
21
import { SearchEditorFindMatchClass, SearchEditorScheme, SearchEditorBodyScheme } from 'vs/workbench/contrib/searchEditor/browser/constants';
22 23 24 25 26 27
import { extractSearchQuery, serializeSearchConfiguration } from 'vs/workbench/contrib/searchEditor/browser/searchEditorSerialization';
import { IBackupFileService } from 'vs/workbench/services/backup/common/backup';
import { IEditorGroupsService } from 'vs/workbench/services/editor/common/editorGroupsService';
import { IEditorService } from 'vs/workbench/services/editor/common/editorService';
import { IWorkbenchEnvironmentService } from 'vs/workbench/services/environment/common/environmentService';
import { AutoSaveMode, IFilesConfigurationService } from 'vs/workbench/services/filesConfiguration/common/filesConfigurationService';
28
import { IRemotePathService } from 'vs/workbench/services/path/common/remotePathService';
29
import { ITextFileSaveOptions, ITextFileService, snapshotToString, stringToSnapshot } from 'vs/workbench/services/textfile/common/textfiles';
30
import { IWorkingCopy, IWorkingCopyBackup, IWorkingCopyService, WorkingCopyCapabilities } from 'vs/workbench/services/workingCopy/common/workingCopyService';
31

32

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

45 46
const SEARCH_EDITOR_EXT = '.code-search';

47 48 49 50
export class SearchEditorInput extends EditorInput {
	static readonly ID: string = 'workbench.editorinputs.searchEditorInput';

	private dirty: boolean = false;
51 52
	private readonly contentsModel: Promise<ITextModel>;
	private readonly headerModel: Promise<ITextModel>;
53
	private _cachedContentsModel: ITextModel | undefined;
54
	private _cachedConfig?: SearchConfiguration;
55

56
	private readonly _onDidChangeContent = this._register(new Emitter<void>());
57 58
	readonly onDidChangeContent: Event<void> = this._onDidChangeContent.event;

J
Jackson Kearl 已提交
59
	private oldDecorationsIDs: string[] = [];
60

61 62
	constructor(
		public readonly resource: URI,
63
		getModel: () => Promise<{ contentsModel: ITextModel, headerModel: ITextModel }>,
64 65 66 67 68 69 70 71
		@IModelService private readonly modelService: IModelService,
		@IEditorService protected readonly editorService: IEditorService,
		@IEditorGroupsService protected readonly editorGroupService: IEditorGroupsService,
		@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,
72 73
		@IFilesConfigurationService private readonly filesConfigurationService: IFilesConfigurationService,
		@ITelemetryService private readonly telemetryService: ITelemetryService,
74
		@IModeService readonly modeService: IModeService,
75
		@IRemotePathService private readonly remotePathService: IRemotePathService
76 77 78
	) {
		super();

79 80 81
		// Dummy model to set file icon
		this._register(modelService.createModel('', modeService.create('search-result'), this.resource));

82 83 84 85 86 87 88 89 90 91
		const modelLoader = getModel()
			.then(({ contentsModel, headerModel }) => {
				this._register(contentsModel.onDidChangeContent(() => this._onDidChangeContent.fire()));
				this._register(headerModel.onDidChangeContent(() => {
					this._cachedConfig = extractSearchQuery(headerModel);
					this._onDidChangeContent.fire();
					this._onDidChangeLabel.fire();
				}));

				this._cachedConfig = extractSearchQuery(headerModel);
92
				this._cachedContentsModel = contentsModel;
93 94 95

				this._register(contentsModel);
				this._register(headerModel);
96
				this._onDidChangeLabel.fire();
97 98

				return { contentsModel, headerModel };
99
			});
100

101 102 103
		this.contentsModel = modelLoader.then(({ contentsModel }) => contentsModel);
		this.headerModel = modelLoader.then(({ headerModel }) => headerModel);

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

J
Jackson Kearl 已提交
117
		this._register(this.workingCopyService.registerWorkingCopy(workingCopyAdapter));
118 119
	}

120
	async save(group: GroupIdentifier, options?: ITextFileSaveOptions): Promise<IEditorInput | undefined> {
J
Jackson Kearl 已提交
121
		if ((await this.headerModel).isDisposed() || (await this.contentsModel).isDisposed()) { return; }
122

B
Benjamin Pasero 已提交
123 124
		if (this.isUntitled()) {
			return this.saveAs(group, options);
125
		} else {
126
			await this.textFileService.write(this.resource, await this.serializeForDisk(), options);
127
			this.setDirty(false);
J
Jackson Kearl 已提交
128
			return this;
129 130 131
		}
	}

132 133 134 135 136 137 138 139 140 141
	private async serializeForDisk() {
		return (await this.headerModel).getValue() + '\n' + (await this.contentsModel).getValue();
	}

	async getModels() {
		const header = await this.headerModel;
		const body = await this.contentsModel;
		return { header, body };
	}

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

159 160 161 162
	getTypeId(): string {
		return SearchEditorInput.ID;
	}

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

B
Benjamin Pasero 已提交
166
		if (this.isUntitled()) {
167
			const query = this._cachedConfig?.query?.trim();
168 169 170 171
			if (query) {
				return localize('searchTitle.withQuery', "Search: {0}", trimToMax(query));
			}
			return localize('searchTitle', "Search");
172 173
		}

174
		return localize('searchTitle.withQuery', "Search: {0}", basename(this.resource.path, SEARCH_EDITOR_EXT));
175 176 177
	}

	getConfigSync() {
178
		return this._cachedConfig;
179 180 181 182 183 184
	}

	async resolve() {
		return null;
	}

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

	isDirty() {
		return this.dirty;
	}

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

218 219 220 221 222 223 224 225 226 227 228
	move(group: GroupIdentifier, target: URI): IMoveResult | undefined {
		if (extname(target) === SEARCH_EDITOR_EXT) {
			return {
				editor: this.instantiationService.invokeFunction(getOrMakeSearchEditorInput, { uri: target })
			};
		}

		// Ignore move if editor was renamed to a different file extension
		return undefined;
	}

229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247
	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;
	}

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

255 256 257
	public async setMatchRanges(ranges: Range[]) {
		this.oldDecorationsIDs = (await this.contentsModel).deltaDecorations(this.oldDecorationsIDs, ranges.map(range =>
			({ range, options: { className: SearchEditorFindMatchClass, stickiness: TrackedRangeStickiness.NeverGrowsWhenTypingAtEdges } })));
258 259
	}

B
Benjamin Pasero 已提交
260
	async revert(group: GroupIdentifier, options?: IRevertOptions) {
261
		// TODO: this should actually revert the contents. But it needs to set dirty false.
B
Benjamin Pasero 已提交
262
		super.revert(group, options);
263 264 265 266
		this.setDirty(false);
	}

	private async backup(): Promise<IWorkingCopyBackup> {
267
		const content = stringToSnapshot(await this.serializeForDisk());
268 269 270 271 272 273
		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<URI> {
274
		const query = extractSearchQuery(await this.headerModel).query;
275

276
		const searchFileName = (query.replace(/[^\w \-_]+/g, '_') || 'Search') + SEARCH_EDITOR_EXT;
277 278 279 280

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

281
		return joinPath(this.fileDialogService.defaultFilePath(schemeFilter) || (await this.remotePathService.userHome), searchFileName);
282 283 284 285 286 287
	}
}

const inputs = new Map<string, SearchEditorInput>();
export const getOrMakeSearchEditorInput = (
	accessor: ServicesAccessor,
288 289 290 291
	existingData:
		{ uri: URI, config?: Partial<SearchConfiguration>, text?: never } |
		{ text: string, uri?: never, config?: never } |
		{ config: Partial<SearchConfiguration>, text?: never, uri?: never }
292 293
): SearchEditorInput => {

294
	const uri = existingData.uri ?? URI.from({ scheme: SearchEditorScheme, fragment: `${Math.random()}` });
295 296 297 298 299 300 301 302 303 304 305 306 307

	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 () => {
308
		let contents: string;
309

310 311
		const backup = await backupService.resolve(uri);
		if (backup) {
312 313
			// this way of stringifying a TextBufferFactory seems needlessly complicated...
			contents = snapshotToString(backup.value.create(DefaultEndOfLine.LF).createSnapshot(true));
314
		} else if (uri.scheme !== SearchEditorScheme) {
315
			contents = (await textFileService.read(uri)).value;
316 317 318 319
		} else if (existingData.text) {
			contents = existingData.text;
		} else if (existingData.config) {
			contents = serializeSearchConfiguration(existingData.config);
320
		} else {
321
			throw new Error('no initial contents for search editor');
322
		}
323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340
		backupService.discardBackup(uri);

		const lines = contents.split(/\r?\n/);

		const headerlines = [];
		const bodylines = [];
		let inHeader = true;
		for (const line of lines) {
			if (inHeader) {
				headerlines.push(line);
				if (line === '') {
					inHeader = false;
				}
			} else {
				bodylines.push(line);
			}
		}

J
Jackson Kearl 已提交
341
		const contentsModelURI = uri.with({ scheme: SearchEditorBodyScheme });
342 343 344 345 346 347
		const headerModelURI = uri.with({ scheme: 'search-editor-header' });
		const contentsModel = modelService.getModel(contentsModelURI) ?? modelService.createModel('', modeService.create('search-result'), contentsModelURI);
		const headerModel = modelService.getModel(headerModelURI) ?? modelService.createModel('', modeService.create('search-result'), headerModelURI);

		contentsModel.setValue(bodylines.join('\n'));
		headerModel.setValue(headerlines.join('\n'));
348

349
		return { contentsModel, headerModel };
350 351
	};

J
Jackson Kearl 已提交
352
	const input = instantiationService.createInstance(SearchEditorInput, uri, getModel);
353 354 355 356 357 358

	inputs.set(uri.toString(), input);
	input.onDispose(() => inputs.delete(uri.toString()));

	return input;
};