textFileService.ts 17.8 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 { isEqual, 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';
B
Benjamin Pasero 已提交
37

38 39 40 41
/**
 * 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 已提交
42

43 44
	_serviceBrand: undefined;

45
	//#region events
46

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

50
	//#endregion
51

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

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

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

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

		this.registerListeners();
	}

79
	protected registerListeners(): void {
80 81 82 83 84

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

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

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

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

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

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

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

161 162 163 164 165 166 167
		return stat;
	}

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

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

172 173
	//#endregion

174

B
Benjamin Pasero 已提交
175
	//#region save
176

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

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

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

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

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

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

210
		return undefined;
211 212
	}

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

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

220
		if (!target) {
221 222 223 224
			return; // user canceled
		}

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

229
		// Do it
230
		return this.doSaveAs(source, target, options);
231 232
	}

233 234
	private async doSaveAs(source: URI, target: URI, options?: ITextFileSaveOptions): Promise<URI> {
		let success = false;
235

236 237
		// If the source is an existing text file model, we can directly
		// use that model to copy the contents to the target destination
238
		const textFileModel = this.files.get(source);
239 240
		if (textFileModel && textFileModel.isResolved()) {
			success = await this.doSaveAsTextFile(textFileModel, source, target, options);
241 242
		}

243 244 245 246 247 248
		// 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;
249 250
		}

251 252
		// 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
253 254 255
		// contents and additional meta data (e.g. encoding).
		else if (this.textModelService.hasTextModelContentProvider(source.scheme)) {
			const modelReference = await this.textModelService.createModelReference(source);
256 257 258 259 260
			try {
				success = await this.doSaveAsTextFile(modelReference.object, source, target, options);
			} finally {
				modelReference.dispose(); // free up our use of the reference
			}
261 262
		}

263 264 265 266 267
		// 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) {
268
				success = await this.doSaveAsTextFile(textModel, source, target, options);
269 270 271
			}
		}

272 273 274
		// Revert the source if result is success
		if (success) {
			await this.revert(source);
275 276
		}

277 278
		return target;
	}
279

280
	private async doSaveAsTextFile(sourceModel: IResolvedTextEditorModel | ITextModel, source: URI, target: URI, options?: ITextFileSaveOptions): Promise<boolean> {
281 282 283 284 285 286 287

		// 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();
		}
288 289 290

		// Prefer an existing model if it is already loaded for the given target resource
		let targetExists: boolean = false;
291
		let targetModel = this.files.get(target);
B
Benjamin Pasero 已提交
292
		if (targetModel?.isResolved()) {
293 294 295 296 297 298 299
			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);

300
			// create target file adhoc if it does not exist yet
301 302
			if (!targetExists) {
				await this.create(target, '');
303
			}
304

305
			try {
306
				targetModel = await this.files.resolve(target, { encoding: sourceModelEncoding });
307 308 309 310 311 312 313 314 315 316 317 318 319 320
			} 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);
					}
321 322
				}

323
				throw error;
324
			}
325 326 327 328 329 330 331 332 333 334 335 336
		}

		// 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;
		}
337

338 339 340
		if (!write) {
			return false;
		}
341

342 343 344 345
		let sourceTextModel: ITextModel | undefined = undefined;
		if (sourceModel instanceof BaseTextEditorModel) {
			if (sourceModel.isResolved()) {
				sourceTextModel = sourceModel.textEditorModel;
346
			}
347 348 349
		} else {
			sourceTextModel = sourceModel as ITextModel;
		}
350

351 352 353 354
		let targetTextModel: ITextModel | undefined = undefined;
		if (targetModel.isResolved()) {
			targetTextModel = targetModel.textEditorModel;
		}
355

356 357
		// take over model value, encoding and mode (only if more specific) from source model
		if (sourceTextModel && targetTextModel) {
358 359 360 361 362

			// encoding
			targetModel.updatePreferredEncoding(sourceModelEncoding);

			// content
363
			this.modelService.updateModel(targetTextModel, createTextBufferFactoryFromSnapshot(sourceTextModel.createSnapshot()));
364

365
			// mode
366 367 368 369
			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
370
			}
371 372 373 374 375 376 377 378

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

		// save model
B
Benjamin Pasero 已提交
382
		return await targetModel.save(options);
383 384
	}

385 386 387
	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 已提交
388
			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))),
389 390 391 392 393 394 395
			primaryButton: nls.localize({ key: 'replaceButtonLabel', comment: ['&& denotes a mnemonic'] }, "&&Replace"),
			type: 'warning'
		};

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

396
	private async suggestSavePath(resource: URI): Promise<URI> {
397 398 399 400 401 402

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

403 404
		const remoteAuthority = this.environmentService.configuration.remoteAuthority;

405 406 407 408 409 410 411 412 413 414 415
		// 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);
				}

416 417 418 419 420 421 422 423
				// 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
424
				const mode = model.getMode();
425 426
				if (mode !== PLAINTEXT_MODE_ID) {
					suggestedFilename = suggestFilename(mode, untitledName);
427
				} else {
428
					suggestedFilename = untitledName;
429 430
				}
			}
431 432
		}

433 434 435 436
		// Fallback to basename of resource
		if (!suggestedFilename) {
			suggestedFilename = basename(resource);
		}
437 438

		// Try to place where last active file was if any
439
		// Otherwise fallback to user home
440
		return joinPath(this.fileDialogService.defaultFilePath() || (await this.pathService.userHome), suggestedFilename);
441 442
	}

B
Benjamin Pasero 已提交
443 444 445 446
	//#endregion

	//#region revert

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

449
		// Untitled
450
		if (resource.scheme === Schemas.untitled) {
451
			const model = this.untitled.get(resource);
452
			if (model) {
453
				return model.revert(options);
454
			}
455
		}
456

457
		// File
458 459 460 461 462
		else {
			const model = this.files.get(resource);
			if (model && (model.isDirty() || options?.force)) {
				return model.revert(options);
			}
463
		}
464 465
	}

B
Benjamin Pasero 已提交
466 467
	//#endregion

468 469
	//#region dirty

470
	isDirty(resource: URI): boolean {
B
Benjamin Pasero 已提交
471 472 473
		const model = resource.scheme === Schemas.untitled ? this.untitled.get(resource) : this.files.get(resource);
		if (model) {
			return model.isDirty();
474 475
		}

B
Benjamin Pasero 已提交
476
		return false;
477 478
	}

479
	//#endregion
480
}