textFileService.ts 19.0 KB
Newer Older
B
Benjamin Pasero 已提交
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 7
import * as nls from 'vs/nls';
import { URI } from 'vs/base/common/uri';
8 9
import { AsyncEmitter } from 'vs/base/common/event';
import { IResult, ITextFileOperationResult, ITextFileService, ITextFileStreamContent, ITextFileEditorModel, ITextFileContent, IResourceEncodings, IReadTextFileOptions, IWriteTextFileOptions, toBufferOrReadable, TextFileOperationError, TextFileOperationResult, ITextFileSaveOptions, ITextFileEditorModelManager, TextFileCreateEvent } from 'vs/workbench/services/textfile/common/textfiles';
10
import { IRevertOptions, IEncodingSupport } from 'vs/workbench/common/editor';
11
import { ILifecycleService } from 'vs/platform/lifecycle/common/lifecycle';
12
import { IFileService, FileOperationError, FileOperationResult, IFileStatWithMetadata, ICreateFileOptions } from 'vs/platform/files/common/files';
13 14
import { Disposable } from 'vs/base/common/lifecycle';
import { IWorkbenchEnvironmentService } from 'vs/workbench/services/environment/common/environmentService';
15
import { IUntitledTextEditorService, IUntitledTextEditorModelManager } from 'vs/workbench/services/untitled/common/untitledTextEditorService';
16
import { UntitledTextEditorModel } from 'vs/workbench/services/untitled/common/untitledTextEditorModel';
17 18 19
import { TextFileEditorModelManager } from 'vs/workbench/services/textfile/common/textFileEditorModelManager';
import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation';
import { ResourceMap } from 'vs/base/common/map';
20
import { Schemas } from 'vs/base/common/network';
21 22
import { createTextBufferFactoryFromSnapshot, createTextBufferFactoryFromStream } from 'vs/editor/common/model/textModel';
import { IModelService } from 'vs/editor/common/services/modelService';
23
import { isEqual, joinPath, dirname, basename, toLocalResource } from 'vs/base/common/resources';
24
import { IDialogService, IFileDialogService, IConfirmation } from 'vs/platform/dialogs/common/dialogs';
25
import { VSBuffer } from 'vs/base/common/buffer';
26
import { ITextSnapshot, ITextModel } from 'vs/editor/common/model';
S
rename  
Sandeep Somavarapu 已提交
27
import { ITextResourceConfigurationService } from 'vs/editor/common/services/textResourceConfigurationService';
28
import { PLAINTEXT_MODE_ID } from 'vs/editor/common/modes/modesRegistry';
29
import { IFilesConfigurationService } from 'vs/workbench/services/filesConfiguration/common/filesConfigurationService';
30
import { CancellationToken } from 'vs/base/common/cancellation';
31
import { ITextModelService, IResolvedTextEditorModel } from 'vs/editor/common/services/resolverService';
32
import { BaseTextEditorModel } from 'vs/workbench/common/editor/textEditorModel';
33
import { ICodeEditorService } from 'vs/editor/browser/services/codeEditorService';
B
Benjamin Pasero 已提交
34
import { coalesce } from 'vs/base/common/arrays';
35
import { suggestFilename } from 'vs/base/common/mime';
36
import { IRemotePathService } from 'vs/workbench/services/path/common/remotePathService';
37
import { isValidBasename } from 'vs/base/common/extpath';
B
Benjamin Pasero 已提交
38

39 40 41 42
/**
 * The workbench file service implementation implements the raw file service spec and adds additional methods on top.
 */
export abstract class AbstractTextFileService extends Disposable implements ITextFileService {
B
Benjamin Pasero 已提交
43

44 45
	_serviceBrand: undefined;

46
	//#region events
47

48 49
	private _onWillCreateTextFile = this._register(new AsyncEmitter<TextFileCreateEvent>());
	readonly onWillCreateTextFile = this._onWillCreateTextFile.event;
50

51 52
	private _onDidCreateTextFile = this._register(new AsyncEmitter<TextFileCreateEvent>());
	readonly onDidCreateTextFile = this._onDidCreateTextFile.event;
53

54
	//#endregion
55

B
Benjamin Pasero 已提交
56
	readonly files: ITextFileEditorModelManager = this._register(this.instantiationService.createInstance(TextFileEditorModelManager));
57

B
Benjamin Pasero 已提交
58
	readonly untitled: IUntitledTextEditorModelManager = this.untitledTextEditorService;
59

60 61 62 63
	abstract get encoding(): IResourceEncodings;

	constructor(
		@IFileService protected readonly fileService: IFileService,
B
Benjamin Pasero 已提交
64
		@IUntitledTextEditorService private untitledTextEditorService: IUntitledTextEditorService,
65
		@ILifecycleService protected readonly lifecycleService: ILifecycleService,
66
		@IInstantiationService protected readonly instantiationService: IInstantiationService,
67 68 69 70
		@IModelService private readonly modelService: IModelService,
		@IWorkbenchEnvironmentService protected readonly environmentService: IWorkbenchEnvironmentService,
		@IDialogService private readonly dialogService: IDialogService,
		@IFileDialogService private readonly fileDialogService: IFileDialogService,
S
rename  
Sandeep Somavarapu 已提交
71
		@ITextResourceConfigurationService protected readonly textResourceConfigurationService: ITextResourceConfigurationService,
72
		@IFilesConfigurationService protected readonly filesConfigurationService: IFilesConfigurationService,
73
		@ITextModelService private readonly textModelService: ITextModelService,
74
		@ICodeEditorService private readonly codeEditorService: ICodeEditorService,
75
		@IRemotePathService private readonly remotePathService: IRemotePathService
76 77 78 79 80 81
	) {
		super();

		this.registerListeners();
	}

82
	protected registerListeners(): void {
83 84 85 86 87

		// Lifecycle
		this.lifecycleService.onShutdown(this.dispose, this);
	}

88
	//#region text file read / write / create
89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124

	async read(resource: URI, options?: IReadTextFileOptions): Promise<ITextFileContent> {
		const content = await this.fileService.readFile(resource, options);

		// in case of acceptTextOnly: true, we check the first
		// chunk for possibly being binary by looking for 0-bytes
		// we limit this check to the first 512 bytes
		this.validateBinary(content.value, options);

		return {
			...content,
			encoding: 'utf8',
			value: content.value.toString()
		};
	}

	async readStream(resource: URI, options?: IReadTextFileOptions): Promise<ITextFileStreamContent> {
		const stream = await this.fileService.readFileStream(resource, options);

		// in case of acceptTextOnly: true, we check the first
		// chunk for possibly being binary by looking for 0-bytes
		// we limit this check to the first 512 bytes
		let checkedForBinary = false;
		const throwOnBinary = (data: VSBuffer): Error | undefined => {
			if (!checkedForBinary) {
				checkedForBinary = true;

				this.validateBinary(data, options);
			}

			return undefined;
		};

		return {
			...stream,
			encoding: 'utf8',
B
Benjamin Pasero 已提交
125
			value: await createTextBufferFactoryFromStream(stream.value, undefined, options?.acceptTextOnly ? throwOnBinary : undefined)
126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144
		};
	}

	private validateBinary(buffer: VSBuffer, options?: IReadTextFileOptions): void {
		if (!options || !options.acceptTextOnly) {
			return; // no validation needed
		}

		// in case of acceptTextOnly: true, we check the first
		// chunk for possibly being binary by looking for 0-bytes
		// we limit this check to the first 512 bytes
		for (let i = 0; i < buffer.byteLength && i < 512; i++) {
			if (buffer.readUInt8(i) === 0) {
				throw new TextFileOperationError(nls.localize('fileBinaryError', "File seems to be binary and cannot be opened as text"), TextFileOperationResult.FILE_IS_BINARY, options);
			}
		}
	}

	async create(resource: URI, value?: string | ITextSnapshot, options?: ICreateFileOptions): Promise<IFileStatWithMetadata> {
145 146

		// before event
147
		await this._onWillCreateTextFile.fireAsync({ resource }, CancellationToken.None);
148

149
		// create file on disk
150 151 152 153 154 155
		const stat = await this.doCreate(resource, value, options);

		// If we had an existing model for the given resource, load
		// it again to make sure it is up to date with the contents
		// we just wrote into the underlying resource by calling
		// revert()
156
		const existingModel = this.files.get(resource);
157 158 159 160
		if (existingModel && !existingModel.isDisposed()) {
			await existingModel.revert();
		}

161
		// after event
162
		await this._onDidCreateTextFile.fireAsync({ resource }, CancellationToken.None);
163

164 165 166 167 168 169 170
		return stat;
	}

	protected doCreate(resource: URI, value?: string | ITextSnapshot, options?: ICreateFileOptions): Promise<IFileStatWithMetadata> {
		return this.fileService.createFile(resource, toBufferOrReadable(value), options);
	}

171 172
	async write(resource: URI, value: string | ITextSnapshot, options?: IWriteTextFileOptions): Promise<IFileStatWithMetadata> {
		return this.fileService.writeFile(resource, toBufferOrReadable(value), options);
B
Benjamin Pasero 已提交
173 174
	}

175 176
	//#endregion

177

B
Benjamin Pasero 已提交
178
	//#region save
179

180
	async save(resource: URI, options?: ITextFileSaveOptions): Promise<URI | undefined> {
181

182 183
		// Untitled
		if (resource.scheme === Schemas.untitled) {
184 185
			const model = this.untitled.get(resource);
			if (model) {
186
				let targetUri: URI | undefined;
187 188

				// Untitled with associated file path don't need to prompt
189
				if (model.hasAssociatedFilePath) {
190
					targetUri = await this.suggestSavePath(resource);
191 192 193 194
				}

				// Otherwise ask user
				else {
195
					targetUri = await this.fileDialogService.pickFileToSave(await this.suggestSavePath(resource), options?.availableFileSystems);
196 197
				}

198 199
				// Save as if target provided
				if (targetUri) {
200
					return this.saveAs(resource, targetUri, options);
201
				}
202 203 204
			}
		}

205 206
		// File
		else {
207
			const model = this.files.get(resource);
208
			if (model) {
209

210 211
				// Save with options
				await model.save(options);
212

213
				return !model.isDirty() ? resource : undefined;
214 215 216
			}
		}

217
		return undefined;
218 219
	}

B
Benjamin Pasero 已提交
220
	private getFileModels(resources?: URI[]): ITextFileEditorModel[] {
B
Benjamin Pasero 已提交
221
		if (Array.isArray(resources)) {
B
Benjamin Pasero 已提交
222
			return coalesce(resources.map(resource => this.files.get(resource)));
223 224
		}

B
Benjamin Pasero 已提交
225
		return this.files.getAll();
226 227
	}

B
Benjamin Pasero 已提交
228
	private getDirtyFileModels(resources?: URI[]): ITextFileEditorModel[] {
229 230 231
		return this.getFileModels(resources).filter(model => model.isDirty());
	}

232
	async saveAs(source: URI, target?: URI, options?: ITextFileSaveOptions): Promise<URI | undefined> {
233 234

		// Get to target resource
235
		if (!target) {
236
			target = await this.fileDialogService.pickFileToSave(await this.suggestSavePath(source), options?.availableFileSystems);
237 238
		}

239
		if (!target) {
240 241 242 243
			return; // user canceled
		}

		// Just save if target is same as models own resource
244
		if (source.toString() === target.toString()) {
245
			return this.save(source, options);
B
Benjamin Pasero 已提交
246
		}
247

248
		// Do it
249
		return this.doSaveAs(source, target, options);
250 251
	}

252 253
	private async doSaveAs(source: URI, target: URI, options?: ITextFileSaveOptions): Promise<URI> {
		let success = false;
254

255 256
		// If the source is an existing text file model, we can directly
		// use that model to copy the contents to the target destination
257
		const textFileModel = this.files.get(source);
258 259
		if (textFileModel && textFileModel.isResolved()) {
			success = await this.doSaveAsTextFile(textFileModel, source, target, options);
260 261
		}

262 263 264 265 266 267
		// Otherwise if the source can be handled by the file service
		// we can simply invoke the copy() function to save as
		else if (this.fileService.canHandleResource(source)) {
			await this.fileService.copy(source, target);

			success = true;
268 269
		}

270 271
		// Next, if the source does not seem to be a file, we try to
		// resolve a text model from the resource to get at the
272 273 274 275
		// contents and additional meta data (e.g. encoding).
		else if (this.textModelService.hasTextModelContentProvider(source.scheme)) {
			const modelReference = await this.textModelService.createModelReference(source);
			success = await this.doSaveAsTextFile(modelReference.object, source, target, options);
276

277
			modelReference.dispose(); // free up our use of the reference
278 279
		}

280 281 282 283 284
		// Finally we simply check if we can find a editor model that
		// would give us access to the contents.
		else {
			const textModel = this.modelService.getModel(source);
			if (textModel) {
285
				success = await this.doSaveAsTextFile(textModel, source, target, options);
286 287 288
			}
		}

289 290 291
		// Revert the source if result is success
		if (success) {
			await this.revert(source);
292 293
		}

294 295
		return target;
	}
296

297
	private async doSaveAsTextFile(sourceModel: IResolvedTextEditorModel | ITextModel, source: URI, target: URI, options?: ITextFileSaveOptions): Promise<boolean> {
298 299 300 301 302 303 304

		// Find source encoding if any
		let sourceModelEncoding: string | undefined = undefined;
		const sourceModelWithEncodingSupport = (sourceModel as unknown as IEncodingSupport);
		if (typeof sourceModelWithEncodingSupport.getEncoding === 'function') {
			sourceModelEncoding = sourceModelWithEncodingSupport.getEncoding();
		}
305 306 307

		// Prefer an existing model if it is already loaded for the given target resource
		let targetExists: boolean = false;
308
		let targetModel = this.files.get(target);
B
Benjamin Pasero 已提交
309
		if (targetModel?.isResolved()) {
310 311 312 313 314 315 316
			targetExists = true;
		}

		// Otherwise create the target file empty if it does not exist already and resolve it from there
		else {
			targetExists = await this.fileService.exists(target);

317
			// create target file adhoc if it does not exist yet
318 319
			if (!targetExists) {
				await this.create(target, '');
320
			}
321

322
			try {
323
				targetModel = await this.files.resolve(target, { encoding: sourceModelEncoding });
324 325 326 327 328 329 330 331 332 333 334 335 336 337
			} catch (error) {
				// if the target already exists and was not created by us, it is possible
				// that we cannot load the target as text model if it is binary or too
				// large. in that case we have to delete the target file first and then
				// re-run the operation.
				if (targetExists) {
					if (
						(<TextFileOperationError>error).textFileOperationResult === TextFileOperationResult.FILE_IS_BINARY ||
						(<FileOperationError>error).fileOperationResult === FileOperationResult.FILE_TOO_LARGE
					) {
						await this.fileService.del(target);

						return this.doSaveAsTextFile(sourceModel, source, target, options);
					}
338 339
				}

340
				throw error;
341
			}
342 343 344 345 346 347 348 349 350 351 352 353
		}

		// Confirm to overwrite if we have an untitled file with associated file where
		// the file actually exists on disk and we are instructed to save to that file
		// path. This can happen if the file was created after the untitled file was opened.
		// See https://github.com/Microsoft/vscode/issues/67946
		let write: boolean;
		if (sourceModel instanceof UntitledTextEditorModel && sourceModel.hasAssociatedFilePath && targetExists && isEqual(target, toLocalResource(sourceModel.resource, this.environmentService.configuration.remoteAuthority))) {
			write = await this.confirmOverwrite(target);
		} else {
			write = true;
		}
354

355 356 357
		if (!write) {
			return false;
		}
358

359 360 361 362
		let sourceTextModel: ITextModel | undefined = undefined;
		if (sourceModel instanceof BaseTextEditorModel) {
			if (sourceModel.isResolved()) {
				sourceTextModel = sourceModel.textEditorModel;
363
			}
364 365 366
		} else {
			sourceTextModel = sourceModel as ITextModel;
		}
367

368 369 370 371
		let targetTextModel: ITextModel | undefined = undefined;
		if (targetModel.isResolved()) {
			targetTextModel = targetModel.textEditorModel;
		}
372

373 374
		// take over model value, encoding and mode (only if more specific) from source model
		if (sourceTextModel && targetTextModel) {
375 376 377 378 379

			// encoding
			targetModel.updatePreferredEncoding(sourceModelEncoding);

			// content
380
			this.modelService.updateModel(targetTextModel, createTextBufferFactoryFromSnapshot(sourceTextModel.createSnapshot()));
381

382
			// mode
383 384 385 386
			const sourceMode = sourceTextModel.getLanguageIdentifier();
			const targetMode = targetTextModel.getLanguageIdentifier();
			if (sourceMode.language !== PLAINTEXT_MODE_ID && targetMode.language === PLAINTEXT_MODE_ID) {
				targetTextModel.setMode(sourceMode); // only use if more specific than plain/text
387
			}
388 389 390 391 392 393 394 395

			// transient properties
			const sourceTransientProperties = this.codeEditorService.getTransientModelProperties(sourceTextModel);
			if (sourceTransientProperties) {
				for (const [key, value] of sourceTransientProperties) {
					this.codeEditorService.setTransientModelProperty(targetTextModel, key, value);
				}
			}
396
		}
397 398 399 400 401

		// save model
		await targetModel.save(options);

		return true;
402 403
	}

404 405 406
	private async confirmOverwrite(resource: URI): Promise<boolean> {
		const confirm: IConfirmation = {
			message: nls.localize('confirmOverwrite', "'{0}' already exists. Do you want to replace it?", basename(resource)),
I
isidor 已提交
407
			detail: nls.localize('irreversible', "A file or folder with the name '{0}' already exists in the folder '{1}'. Replacing it will overwrite its current contents.", basename(resource), basename(dirname(resource))),
408 409 410 411 412 413 414
			primaryButton: nls.localize({ key: 'replaceButtonLabel', comment: ['&& denotes a mnemonic'] }, "&&Replace"),
			type: 'warning'
		};

		return (await this.dialogService.confirm(confirm)).confirmed;
	}

415
	private async suggestSavePath(resource: URI): Promise<URI> {
416 417 418 419 420 421

		// Just take the resource as is if the file service can handle it
		if (this.fileService.canHandleResource(resource)) {
			return resource;
		}

422 423
		const remoteAuthority = this.environmentService.configuration.remoteAuthority;

424 425 426 427 428 429 430 431 432 433 434
		// Otherwise try to suggest a path that can be saved
		let suggestedFilename: string | undefined = undefined;
		if (resource.scheme === Schemas.untitled) {
			const model = this.untitledTextEditorService.get(resource);
			if (model) {

				// Untitled with associated file path
				if (model.hasAssociatedFilePath) {
					return toLocalResource(resource, remoteAuthority);
				}

435 436 437 438 439 440 441 442
				// Untitled without associated file path: use name
				// of untitled model if it is a valid path name
				let untitledName = model.name;
				if (!isValidBasename(untitledName)) {
					untitledName = basename(resource);
				}

				// Add mode file extension if specified
443
				const mode = model.getMode();
444 445
				if (mode !== PLAINTEXT_MODE_ID) {
					suggestedFilename = suggestFilename(mode, untitledName);
446
				} else {
447
					suggestedFilename = untitledName;
448 449
				}
			}
450 451
		}

452 453 454 455
		// Fallback to basename of resource
		if (!suggestedFilename) {
			suggestedFilename = basename(resource);
		}
456 457

		// Try to place where last active file was if any
458 459
		// Otherwise fallback to user home
		return joinPath(this.fileDialogService.defaultFilePath() || (await this.remotePathService.userHome), suggestedFilename);
460 461
	}

B
Benjamin Pasero 已提交
462 463 464 465
	//#endregion

	//#region revert

466 467
	async revert(resource: URI, options?: IRevertOptions): Promise<boolean> {

468
		// Untitled
469
		if (resource.scheme === Schemas.untitled) {
470
			const model = this.untitled.get(resource);
471
			if (model) {
472
				return model.revert(options);
473 474 475
			}

			return false;
476
		}
477

478
		// File
B
Benjamin Pasero 已提交
479
		return !(await this.doRevertFiles([resource], options)).results.some(result => result.error);
480 481
	}

B
Benjamin Pasero 已提交
482
	private async doRevertFiles(resources: URI[], options?: IRevertOptions): Promise<ITextFileOperationResult> {
B
Benjamin Pasero 已提交
483
		const fileModels = options?.force ? this.getFileModels(resources) : this.getDirtyFileModels(resources);
484 485

		const mapResourceToResult = new ResourceMap<IResult>();
486 487 488
		fileModels.forEach(fileModel => {
			mapResourceToResult.set(fileModel.resource, {
				source: fileModel.resource
489 490 491 492
			});
		});

		await Promise.all(fileModels.map(async model => {
493 494

			// Revert through model
495
			await model.revert(options);
496

497 498 499 500 501
			// If model is still dirty, mark the resulting operation as error
			if (model.isDirty()) {
				const result = mapResourceToResult.get(model.resource);
				if (result) {
					result.error = true;
502
				}
503
			}
504 505 506 507 508
		}));

		return { results: mapResourceToResult.values() };
	}

B
Benjamin Pasero 已提交
509 510
	//#endregion

511 512
	//#region dirty

513
	isDirty(resource: URI): boolean {
B
Benjamin Pasero 已提交
514 515 516
		const model = resource.scheme === Schemas.untitled ? this.untitled.get(resource) : this.files.get(resource);
		if (model) {
			return model.isDirty();
517 518
		}

B
Benjamin Pasero 已提交
519
		return false;
520 521
	}

522
	//#endregion
523
}