textFileService.ts 18.4 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
import { AsyncEmitter } from 'vs/base/common/event';
9
import { ITextFileService, ITextFileStreamContent, 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, FileOperation } 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
import { TextFileEditorModelManager } from 'vs/workbench/services/textfile/common/textFileEditorModelManager';
import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation';
19
import { Schemas } from 'vs/base/common/network';
20 21
import { createTextBufferFactoryFromSnapshot, createTextBufferFactoryFromStream } from 'vs/editor/common/model/textModel';
import { IModelService } from 'vs/editor/common/services/modelService';
22
import { joinPath, dirname, basename, toLocalResource } from 'vs/base/common/resources';
23
import { IDialogService, IFileDialogService, IConfirmation } from 'vs/platform/dialogs/common/dialogs';
24
import { VSBuffer } from 'vs/base/common/buffer';
25
import { ITextSnapshot, ITextModel } from 'vs/editor/common/model';
S
rename  
Sandeep Somavarapu 已提交
26
import { ITextResourceConfigurationService } from 'vs/editor/common/services/textResourceConfigurationService';
27
import { PLAINTEXT_MODE_ID } from 'vs/editor/common/modes/modesRegistry';
28
import { IFilesConfigurationService } from 'vs/workbench/services/filesConfiguration/common/filesConfigurationService';
29
import { CancellationToken } from 'vs/base/common/cancellation';
30
import { ITextModelService, IResolvedTextEditorModel } from 'vs/editor/common/services/resolverService';
31
import { BaseTextEditorModel } from 'vs/workbench/common/editor/textEditorModel';
32
import { ICodeEditorService } from 'vs/editor/browser/services/codeEditorService';
33
import { suggestFilename } from 'vs/base/common/mime';
34
import { IPathService } from 'vs/workbench/services/path/common/pathService';
35
import { isValidBasename } from 'vs/base/common/extpath';
36
import { IWorkingCopyFileService } from 'vs/workbench/services/workingCopy/common/workingCopyFileService';
37
import { IUriIdentityService } from 'vs/workbench/services/uriIdentity/common/uriIdentity';
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
	declare readonly _serviceBrand: undefined;
45

46
	//#region events
47

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

51
	//#endregion
52

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

B
Benjamin Pasero 已提交
55
	readonly untitled: IUntitledTextEditorModelManager = this.untitledTextEditorService;
56

57 58 59 60
	abstract get encoding(): IResourceEncodings;

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

		this.registerListeners();
	}

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

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

87
	//#region text file read / write / create
88 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

	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 已提交
124
			value: await createTextBufferFactoryFromStream(stream.value, undefined, options?.acceptTextOnly ? throwOnBinary : undefined)
125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143
		};
	}

	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> {
144

145 146
		// file operation participation
		await this.workingCopyFileService.runFileOperationParticipants(resource, undefined, FileOperation.CREATE);
147

148
		// create file on disk
149 150 151 152 153 154
		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()
155
		const existingModel = this.files.get(resource);
156 157 158 159
		if (existingModel && !existingModel.isDisposed()) {
			await existingModel.revert();
		}

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

163 164 165 166 167 168 169
		return stat;
	}

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

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

174 175
	//#endregion

176

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

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

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

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

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

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

204 205
		// File
		else {
206
			const model = this.files.get(resource);
207
			if (model) {
B
Benjamin Pasero 已提交
208
				return await model.save(options) ? resource : undefined;
209 210 211
			}
		}

212
		return undefined;
213 214
	}

215
	async saveAs(source: URI, target?: URI, options?: ITextFileSaveOptions): Promise<URI | undefined> {
216 217

		// Get to target resource
218
		if (!target) {
219
			target = await this.fileDialogService.pickFileToSave(await this.suggestSavePath(source), options?.availableFileSystems);
220 221
		}

222
		if (!target) {
223 224 225 226
			return; // user canceled
		}

		// Just save if target is same as models own resource
227
		if (source.toString() === target.toString()) {
228
			return this.save(source, options);
B
Benjamin Pasero 已提交
229
		}
230

231 232 233
		// If the target is different but of same identity, we
		// move the source to the target, knowing that the
		// underlying file system cannot have both and then save.
B
Benjamin Pasero 已提交
234
		if (this.fileService.canHandleResource(source) && this.uriIdentityService.extUri.isEqual(source, target)) {
235 236 237 238 239
			await this.workingCopyFileService.move(source, target);

			return this.save(target, options);
		}

240
		// Do it
241
		return this.doSaveAs(source, target, options);
242 243
	}

244 245
	private async doSaveAs(source: URI, target: URI, options?: ITextFileSaveOptions): Promise<URI> {
		let success = false;
246

247 248
		// If the source is an existing text file model, we can directly
		// use that model to copy the contents to the target destination
249
		const textFileModel = this.files.get(source);
250 251
		if (textFileModel && textFileModel.isResolved()) {
			success = await this.doSaveAsTextFile(textFileModel, source, target, options);
252 253
		}

254 255 256 257 258 259
		// 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;
260 261
		}

262 263
		// 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
264
		// contents and additional meta data (e.g. encoding).
265
		else if (this.textModelService.canHandleResource(source)) {
266
			const modelReference = await this.textModelService.createModelReference(source);
267 268 269 270 271
			try {
				success = await this.doSaveAsTextFile(modelReference.object, source, target, options);
			} finally {
				modelReference.dispose(); // free up our use of the reference
			}
272 273
		}

274 275 276 277 278
		// 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) {
279
				success = await this.doSaveAsTextFile(textModel, source, target, options);
280 281 282
			}
		}

283 284 285
		// Revert the source if result is success
		if (success) {
			await this.revert(source);
286 287
		}

288 289
		return target;
	}
290

291
	private async doSaveAsTextFile(sourceModel: IResolvedTextEditorModel | ITextModel, source: URI, target: URI, options?: ITextFileSaveOptions): Promise<boolean> {
292 293 294 295 296 297 298

		// 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();
		}
299 300 301

		// Prefer an existing model if it is already loaded for the given target resource
		let targetExists: boolean = false;
302
		let targetModel = this.files.get(target);
B
Benjamin Pasero 已提交
303
		if (targetModel?.isResolved()) {
304 305 306 307 308 309 310
			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);

311
			// create target file adhoc if it does not exist yet
312 313
			if (!targetExists) {
				await this.create(target, '');
314
			}
315

316
			try {
317
				targetModel = await this.files.resolve(target, { encoding: sourceModelEncoding });
318 319 320 321 322 323 324 325 326 327 328 329 330 331
			} 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);
					}
332 333
				}

334
				throw error;
335
			}
336 337 338 339 340 341 342
		}

		// 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;
343
		if (sourceModel instanceof UntitledTextEditorModel && sourceModel.hasAssociatedFilePath && targetExists && this.uriIdentityService.extUri.isEqual(target, toLocalResource(sourceModel.resource, this.environmentService.configuration.remoteAuthority))) {
344 345 346 347
			write = await this.confirmOverwrite(target);
		} else {
			write = true;
		}
348

349 350 351
		if (!write) {
			return false;
		}
352

353 354 355 356
		let sourceTextModel: ITextModel | undefined = undefined;
		if (sourceModel instanceof BaseTextEditorModel) {
			if (sourceModel.isResolved()) {
				sourceTextModel = sourceModel.textEditorModel;
357
			}
358 359 360
		} else {
			sourceTextModel = sourceModel as ITextModel;
		}
361

362 363 364 365
		let targetTextModel: ITextModel | undefined = undefined;
		if (targetModel.isResolved()) {
			targetTextModel = targetModel.textEditorModel;
		}
366

367 368
		// take over model value, encoding and mode (only if more specific) from source model
		if (sourceTextModel && targetTextModel) {
369 370 371 372 373

			// encoding
			targetModel.updatePreferredEncoding(sourceModelEncoding);

			// content
374
			this.modelService.updateModel(targetTextModel, createTextBufferFactoryFromSnapshot(sourceTextModel.createSnapshot()));
375

376
			// mode
377 378 379 380
			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
381
			}
382 383 384 385 386 387 388 389

			// transient properties
			const sourceTransientProperties = this.codeEditorService.getTransientModelProperties(sourceTextModel);
			if (sourceTransientProperties) {
				for (const [key, value] of sourceTransientProperties) {
					this.codeEditorService.setTransientModelProperty(targetTextModel, key, value);
				}
			}
390
		}
391 392

		// save model
B
Benjamin Pasero 已提交
393
		return await targetModel.save(options);
394 395
	}

396 397 398
	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 已提交
399
			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))),
400 401 402 403 404 405 406
			primaryButton: nls.localize({ key: 'replaceButtonLabel', comment: ['&& denotes a mnemonic'] }, "&&Replace"),
			type: 'warning'
		};

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

407
	private async suggestSavePath(resource: URI): Promise<URI> {
408 409 410 411 412 413

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

414 415
		const remoteAuthority = this.environmentService.configuration.remoteAuthority;

416 417 418 419 420 421 422 423 424 425 426
		// 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);
				}

427 428 429 430 431 432 433 434
				// 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
435
				const mode = model.getMode();
436 437
				if (mode !== PLAINTEXT_MODE_ID) {
					suggestedFilename = suggestFilename(mode, untitledName);
438
				} else {
439
					suggestedFilename = untitledName;
440 441
				}
			}
442 443
		}

444 445 446 447
		// Fallback to basename of resource
		if (!suggestedFilename) {
			suggestedFilename = basename(resource);
		}
448 449

		// Try to place where last active file was if any
450
		// Otherwise fallback to user home
451
		return joinPath(this.fileDialogService.defaultFilePath() || (await this.pathService.userHome), suggestedFilename);
452 453
	}

B
Benjamin Pasero 已提交
454 455 456 457
	//#endregion

	//#region revert

458
	async revert(resource: URI, options?: IRevertOptions): Promise<void> {
459

460
		// Untitled
461
		if (resource.scheme === Schemas.untitled) {
462
			const model = this.untitled.get(resource);
463
			if (model) {
464
				return model.revert(options);
465
			}
466
		}
467

468
		// File
469 470 471 472 473
		else {
			const model = this.files.get(resource);
			if (model && (model.isDirty() || options?.force)) {
				return model.revert(options);
			}
474
		}
475 476
	}

B
Benjamin Pasero 已提交
477 478
	//#endregion

479 480
	//#region dirty

481
	isDirty(resource: URI): boolean {
B
Benjamin Pasero 已提交
482 483 484
		const model = resource.scheme === Schemas.untitled ? this.untitled.get(resource) : this.files.get(resource);
		if (model) {
			return model.isDirty();
485 486
		}

B
Benjamin Pasero 已提交
487
		return false;
488 489
	}

490
	//#endregion
491
}