// ***************************************************************************** // Copyright (C) 2018 TypeFox and others. // // This program and the accompanying materials are made available under the // terms of the Eclipse Public License v. 2.0 which is available at // http://www.eclipse.org/legal/epl-2.0. // // This Source Code may also be made available under the following Secondary // Licenses when the conditions for such availability set forth in the Eclipse // Public License v. 2.0 are satisfied: GNU General Public License, version 2 // with the GNU Classpath Exception which is available at // https://www.gnu.org/software/classpath/license.html. // // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 // ***************************************************************************** import { injectable, inject } from '@theia/core/shared/inversify'; import { Resource, ResourceVersion, ResourceResolver, ResourceError, ResourceSaveOptions } from '@theia/core/lib/common/resource'; import { DisposableCollection } from '@theia/core/lib/common/disposable'; import { Emitter, Event } from '@theia/core/lib/common/event'; import { Readable, ReadableStream } from '@theia/core/lib/common/stream'; import URI from '@theia/core/lib/common/uri'; import { FileOperation, FileOperationError, FileOperationResult, ETAG_DISABLED, FileSystemProviderCapabilities, FileReadStreamOptions, BinarySize } from '../common/files'; import { FileService, TextFileOperationError, TextFileOperationResult } from './file-service'; import { ConfirmDialog, Dialog } from '@theia/core/lib/browser/dialogs'; import { LabelProvider } from '@theia/core/lib/browser/label-provider'; import { GENERAL_MAX_FILE_SIZE_MB } from '../common/filesystem-preferences'; import { FrontendApplicationStateService } from '@theia/core/lib/browser/frontend-application-state'; import { nls } from '@theia/core'; import { MarkdownString } from '@theia/core/lib/common/markdown-rendering'; import { Mutex } from 'async-mutex'; export interface FileResourceVersion extends ResourceVersion { readonly encoding: string; readonly mtime: number; readonly etag: string; } export namespace FileResourceVersion { export function is(version: ResourceVersion | undefined): version is FileResourceVersion { return !!version && 'encoding' in version && 'mtime' in version && 'etag' in version; } } export interface FileResourceOptions { readOnly: boolean | MarkdownString shouldOverwrite: () => Promise shouldOpenAsText: (error: string) => Promise } export class FileResource implements Resource { protected acceptTextOnly = true; protected limits: FileReadStreamOptions['limits']; protected readonly toDispose = new DisposableCollection(); protected readonly onDidChangeContentsEmitter = new Emitter(); readonly onDidChangeContents: Event = this.onDidChangeContentsEmitter.event; protected readonly onDidChangeReadOnlyEmitter = new Emitter(); readonly onDidChangeReadOnly: Event = this.onDidChangeReadOnlyEmitter.event; protected _version: FileResourceVersion | undefined; get version(): FileResourceVersion | undefined { return this._version; } get encoding(): string | undefined { return this._version?.encoding; } get readOnly(): boolean | MarkdownString { return this.options.readOnly; } protected writingLock = new Mutex(); constructor( readonly uri: URI, protected readonly fileService: FileService, protected readonly options: FileResourceOptions ) { this.toDispose.push(this.onDidChangeContentsEmitter); this.toDispose.push(this.onDidChangeReadOnlyEmitter); this.toDispose.push(this.fileService.onDidFilesChange(event => { if (event.contains(this.uri)) { this.sync(); } })); this.toDispose.push(this.fileService.onDidRunOperation(e => { if ((e.isOperation(FileOperation.DELETE) || e.isOperation(FileOperation.MOVE)) && e.resource.isEqualOrParent(this.uri)) { this.sync(); } })); try { this.toDispose.push(this.fileService.watch(this.uri)); } catch (e) { console.error(e); } this.updateSavingContentChanges(); this.toDispose.push(this.fileService.onDidChangeFileSystemProviderCapabilities(async e => { if (e.scheme === this.uri.scheme) { this.updateReadOnly(); } })); this.toDispose.push(this.fileService.onDidChangeFileSystemProviderReadOnlyMessage(async e => { if (e.scheme === this.uri.scheme) { this.updateReadOnly(); } })); } protected async updateReadOnly(): Promise { const oldReadOnly = this.options.readOnly; const readOnlyMessage = this.fileService.getReadOnlyMessage(this.uri); if (readOnlyMessage) { this.options.readOnly = readOnlyMessage; } else { this.options.readOnly = this.fileService.hasCapability(this.uri, FileSystemProviderCapabilities.Readonly); } if (this.options.readOnly !== oldReadOnly) { this.updateSavingContentChanges(); this.onDidChangeReadOnlyEmitter.fire(this.options.readOnly); } } dispose(): void { this.toDispose.dispose(); } async readContents(options?: { encoding?: string }): Promise { try { const encoding = options?.encoding || this.version?.encoding; const stat = await this.fileService.read(this.uri, { encoding, etag: ETAG_DISABLED, acceptTextOnly: this.acceptTextOnly, limits: this.limits }); this._version = { encoding: stat.encoding, etag: stat.etag, mtime: stat.mtime }; return stat.value; } catch (e) { if (e instanceof TextFileOperationError && e.textFileOperationResult === TextFileOperationResult.FILE_IS_BINARY) { if (await this.shouldOpenAsText(nls.localize('theia/filesystem/fileResource/binaryTitle', 'The file is either binary or uses an unsupported text encoding.'))) { this.acceptTextOnly = false; return this.readContents(options); } } else if (e instanceof FileOperationError && e.fileOperationResult === FileOperationResult.FILE_TOO_LARGE) { const stat = await this.fileService.resolve(this.uri, { resolveMetadata: true }); const maxFileSize = GENERAL_MAX_FILE_SIZE_MB * 1024 * 1024; if (this.limits?.size !== maxFileSize && await this.shouldOpenAsText(nls.localize( 'theia/filesystem/fileResource/largeFileTitle', 'The file is too large ({0}).', BinarySize.formatSize(stat.size)))) { this.limits = { size: maxFileSize }; return this.readContents(options); } } else if (e instanceof FileOperationError && e.fileOperationResult === FileOperationResult.FILE_NOT_FOUND) { this._version = undefined; const { message, stack } = e; throw ResourceError.NotFound({ message, stack, data: { uri: this.uri } }); } throw e; } } async readStream(options?: { encoding?: string }): Promise> { try { const encoding = options?.encoding || this.version?.encoding; const stat = await this.fileService.readStream(this.uri, { encoding, etag: ETAG_DISABLED, acceptTextOnly: this.acceptTextOnly, limits: this.limits }); this._version = { encoding: stat.encoding, etag: stat.etag, mtime: stat.mtime }; return stat.value; } catch (e) { if (e instanceof TextFileOperationError && e.textFileOperationResult === TextFileOperationResult.FILE_IS_BINARY) { if (await this.shouldOpenAsText(nls.localize('theia/filesystem/fileResource/binaryTitle', 'The file is either binary or uses an unsupported text encoding.'))) { this.acceptTextOnly = false; return this.readStream(options); } } else if (e instanceof FileOperationError && e.fileOperationResult === FileOperationResult.FILE_TOO_LARGE) { const stat = await this.fileService.resolve(this.uri, { resolveMetadata: true }); const maxFileSize = GENERAL_MAX_FILE_SIZE_MB * 1024 * 1024; if (this.limits?.size !== maxFileSize && await this.shouldOpenAsText(nls.localize( 'theia/filesystem/fileResource/largeFileTitle', 'The file is too large ({0}).', BinarySize.formatSize(stat.size)))) { this.limits = { size: maxFileSize }; return this.readStream(options); } } else if (e instanceof FileOperationError && e.fileOperationResult === FileOperationResult.FILE_NOT_FOUND) { this._version = undefined; const { message, stack } = e; throw ResourceError.NotFound({ message, stack, data: { uri: this.uri } }); } throw e; } } protected doWrite = async (content: string | Readable, options?: ResourceSaveOptions): Promise => { const version = options?.version || this._version; const current = FileResourceVersion.is(version) ? version : undefined; const etag = current?.etag; const releaseLock = await this.writingLock.acquire(); try { const stat = await this.fileService.write(this.uri, content, { encoding: options?.encoding, overwriteEncoding: options?.overwriteEncoding, etag, mtime: current?.mtime }); this._version = { etag: stat.etag, mtime: stat.mtime, encoding: stat.encoding }; } catch (e) { if (e instanceof FileOperationError && e.fileOperationResult === FileOperationResult.FILE_MODIFIED_SINCE) { if (etag !== ETAG_DISABLED && await this.shouldOverwrite()) { return this.doWrite(content, { ...options, version: { stat: { ...current, etag: ETAG_DISABLED } } }); } const { message, stack } = e; throw ResourceError.OutOfSync({ message, stack, data: { uri: this.uri } }); } throw e; } finally { releaseLock(); } }; saveStream?: Resource['saveStream']; saveContents?: Resource['saveContents']; saveContentChanges?: Resource['saveContentChanges']; protected updateSavingContentChanges(): void { if (this.readOnly) { delete this.saveContentChanges; delete this.saveContents; delete this.saveStream; } else { this.saveContents = this.doWrite; this.saveStream = this.doWrite; if (this.fileService.hasCapability(this.uri, FileSystemProviderCapabilities.Update)) { this.saveContentChanges = this.doSaveContentChanges; } } } protected doSaveContentChanges: Resource['saveContentChanges'] = async (changes, options) => { const version = options?.version || this._version; const current = FileResourceVersion.is(version) ? version : undefined; if (!current) { throw ResourceError.NotFound({ message: 'has not been read yet', data: { uri: this.uri } }); } const etag = current?.etag; const releaseLock = await this.writingLock.acquire(); try { const stat = await this.fileService.update(this.uri, changes, { readEncoding: current.encoding, encoding: options?.encoding, overwriteEncoding: options?.overwriteEncoding, etag, mtime: current?.mtime }); this._version = { etag: stat.etag, mtime: stat.mtime, encoding: stat.encoding }; } catch (e) { if (e instanceof FileOperationError && e.fileOperationResult === FileOperationResult.FILE_NOT_FOUND) { const { message, stack } = e; throw ResourceError.NotFound({ message, stack, data: { uri: this.uri } }); } if (e instanceof FileOperationError && e.fileOperationResult === FileOperationResult.FILE_MODIFIED_SINCE) { const { message, stack } = e; throw ResourceError.OutOfSync({ message, stack, data: { uri: this.uri } }); } throw e; } finally { releaseLock(); } }; async guessEncoding(): Promise { // TODO limit size const content = await this.fileService.read(this.uri, { autoGuessEncoding: true }); return content.encoding; } protected async sync(): Promise { if (await this.isInSync()) { return; } this.onDidChangeContentsEmitter.fire(undefined); } protected async isInSync(): Promise { try { await this.writingLock.waitForUnlock(); const stat = await this.fileService.resolve(this.uri, { resolveMetadata: true }); return !!this.version && this.version.mtime >= stat.mtime; } catch { return !this.version; } } protected async shouldOverwrite(): Promise { return this.options.shouldOverwrite(); } protected async shouldOpenAsText(error: string): Promise { return this.options.shouldOpenAsText(error); } } @injectable() export class FileResourceResolver implements ResourceResolver { /** This resolver interacts with the VSCode plugin system in a way that can cause delays. Most other resource resolvers fail immediately, so this one should be tried late. */ readonly priority = -10; @inject(FileService) protected readonly fileService: FileService; @inject(LabelProvider) protected readonly labelProvider: LabelProvider; @inject(FrontendApplicationStateService) protected readonly applicationState: FrontendApplicationStateService; async resolve(uri: URI): Promise { let stat; try { stat = await this.fileService.resolve(uri); } catch (e) { if (!(e instanceof FileOperationError && e.fileOperationResult === FileOperationResult.FILE_NOT_FOUND)) { throw e; } } if (stat && stat.isDirectory) { throw new Error('The given uri is a directory: ' + this.labelProvider.getLongName(uri)); } const readOnlyMessage = this.fileService.getReadOnlyMessage(uri); const isFileSystemReadOnly = this.fileService.hasCapability(uri, FileSystemProviderCapabilities.Readonly); const readOnly = readOnlyMessage ?? (isFileSystemReadOnly ? isFileSystemReadOnly : (stat?.isReadonly ?? false)); return new FileResource(uri, this.fileService, { readOnly: readOnly, shouldOverwrite: () => this.shouldOverwrite(uri), shouldOpenAsText: error => this.shouldOpenAsText(uri, error) }); } protected async shouldOverwrite(uri: URI): Promise { const dialog = new ConfirmDialog({ title: nls.localize('theia/filesystem/fileResource/overwriteTitle', "The file '{0}' has been changed on the file system.", this.labelProvider.getName(uri)), msg: nls.localize('theia/fileSystem/fileResource/overWriteBody', "Do you want to overwrite the changes made to '{0}' on the file system?", this.labelProvider.getLongName(uri)), ok: Dialog.YES, cancel: Dialog.NO, }); return !!await dialog.open(); } protected async shouldOpenAsText(uri: URI, error: string): Promise { switch (this.applicationState.state) { case 'init': case 'started_contributions': case 'attached_shell': return true; // We're restoring state - assume that we should open files that were previously open. default: { const dialog = new ConfirmDialog({ title: error, msg: nls.localize('theia/filesystem/fileResource/binaryFileQuery', "Opening it might take some time and might make the IDE unresponsive. Do you want to open '{0}' anyway?", this.labelProvider.getLongName(uri) ), ok: Dialog.YES, cancel: Dialog.NO, }); return !!await dialog.open(); } } } }