UNPKG

@theia/core

Version:

Theia is a cloud & desktop IDE framework implemented in TypeScript.

427 lines (375 loc) • 15.1 kB
// ***************************************************************************** // Copyright (C) 2017 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 WITH Classpath-exception-2.0 // ***************************************************************************** import { injectable, inject, named } from 'inversify'; import { TextDocumentContentChangeEvent } from 'vscode-languageserver-protocol'; import URI from '../common/uri'; import { ContributionProvider } from './contribution-provider'; import { Event, Emitter } from './event'; import { Disposable } from './disposable'; import { MaybePromise } from './types'; import { CancellationToken } from './cancellation'; import { ApplicationError } from './application-error'; import { ReadableStream, Readable } from './stream'; import { SyncReferenceCollection, Reference } from './reference'; export interface ResourceVersion { } export interface ResourceReadOptions { encoding?: string } export interface ResourceSaveOptions { encoding?: string overwriteEncoding?: boolean version?: ResourceVersion } export interface Resource extends Disposable { readonly uri: URI; /** * Latest read version of this resource. * * Optional if a resource does not support versioning, check with `in` operator`. * Undefined if a resource did not read content yet. */ readonly version?: ResourceVersion | undefined; /** * Latest read encoding of this resource. * * Optional if a resource does not support encoding, check with `in` operator`. * Undefined if a resource did not read content yet. */ readonly encoding?: string | undefined; readonly isReadonly?: boolean; /** * Reads latest content of this resource. * * If a resource supports versioning it updates version to latest. * If a resource supports encoding it updates encoding to latest. * * @throws `ResourceError.NotFound` if a resource not found */ readContents(options?: ResourceReadOptions): Promise<string>; /** * Stream latest content of this resource. * * If a resource supports versioning it updates version to latest. * If a resource supports encoding it updates encoding to latest. * * @throws `ResourceError.NotFound` if a resource not found */ readStream?(options?: ResourceReadOptions): Promise<ReadableStream<string>>; /** * Rewrites the complete content for this resource. * If a resource does not exist it will be created. * * If a resource supports versioning clients can pass some version * to check against it, if it is not provided latest version is used. * * It updates version and encoding to latest. * * @throws `ResourceError.OutOfSync` if latest resource version is out of sync with the given */ saveContents?(content: string, options?: ResourceSaveOptions): Promise<void>; /** * Rewrites the complete content for this resource. * If a resource does not exist it will be created. * * If a resource supports versioning clients can pass some version * to check against it, if it is not provided latest version is used. * * It updates version and encoding to latest. * * @throws `ResourceError.OutOfSync` if latest resource version is out of sync with the given */ saveStream?(content: Readable<string>, options?: ResourceSaveOptions): Promise<void>; /** * Applies incremental content changes to this resource. * * If a resource supports versioning clients can pass some version * to check against it, if it is not provided latest version is used. * It updates version to latest. * * @throws `ResourceError.NotFound` if a resource not found or was not read yet * @throws `ResourceError.OutOfSync` if latest resource version is out of sync with the given */ saveContentChanges?(changes: TextDocumentContentChangeEvent[], options?: ResourceSaveOptions): Promise<void>; readonly onDidChangeContents?: Event<void>; guessEncoding?(): Promise<string | undefined> } export namespace Resource { export interface SaveContext { contentLength: number content: string | Readable<string> changes?: TextDocumentContentChangeEvent[] options?: ResourceSaveOptions } export async function save(resource: Resource, context: SaveContext, token?: CancellationToken): Promise<void> { if (!resource.saveContents) { return; } if (await trySaveContentChanges(resource, context)) { return; } if (token && token.isCancellationRequested) { return; } if (typeof context.content !== 'string' && resource.saveStream) { await resource.saveStream(context.content, context.options); } else { const content = typeof context.content === 'string' ? context.content : Readable.toString(context.content); await resource.saveContents(content, context.options); } } export async function trySaveContentChanges(resource: Resource, context: SaveContext): Promise<boolean> { if (!context.changes || !resource.saveContentChanges || shouldSaveContent(resource, context)) { return false; } try { await resource.saveContentChanges(context.changes, context.options); return true; } catch (e) { if (!ResourceError.NotFound.is(e) && !ResourceError.OutOfSync.is(e)) { console.error(`Failed to apply incremental changes to '${resource.uri.toString()}':`, e); } return false; } } export function shouldSaveContent(resource: Resource, { contentLength, changes }: SaveContext): boolean { if (!changes || (resource.saveStream && contentLength > 32 * 1024 * 1024)) { return true; } let contentChangesLength = 0; for (const change of changes) { contentChangesLength += JSON.stringify(change).length; if (contentChangesLength > contentLength) { return true; } } return contentChangesLength > contentLength; } } export namespace ResourceError { export const NotFound = ApplicationError.declare(-40000, (raw: ApplicationError.Literal<{ uri: URI }>) => raw); export const OutOfSync = ApplicationError.declare(-40001, (raw: ApplicationError.Literal<{ uri: URI }>) => raw); } export const ResourceResolver = Symbol('ResourceResolver'); export interface ResourceResolver { /** * Reject if a resource cannot be provided. */ resolve(uri: URI): MaybePromise<Resource>; } export const ResourceProvider = Symbol('ResourceProvider'); export type ResourceProvider = (uri: URI) => Promise<Resource>; @injectable() export class DefaultResourceProvider { constructor( @inject(ContributionProvider) @named(ResourceResolver) protected readonly resolversProvider: ContributionProvider<ResourceResolver> ) { } /** * Reject if a resource cannot be provided. */ async get(uri: URI): Promise<Resource> { const resolvers = this.resolversProvider.getContributions(); for (const resolver of resolvers) { try { return await resolver.resolve(uri); } catch (err) { // no-op } } return Promise.reject(new Error(`A resource provider for '${uri.toString()}' is not registered.`)); } } export class MutableResource implements Resource { private contents: string = ''; constructor(readonly uri: URI) { } dispose(): void { } async readContents(): Promise<string> { return this.contents; } async saveContents(contents: string): Promise<void> { this.contents = contents; this.fireDidChangeContents(); } protected readonly onDidChangeContentsEmitter = new Emitter<void>(); readonly onDidChangeContents = this.onDidChangeContentsEmitter.event; protected fireDidChangeContents(): void { this.onDidChangeContentsEmitter.fire(undefined); } } export class ReferenceMutableResource implements Resource { constructor(protected reference: Reference<MutableResource>) { } get uri(): URI { return this.reference.object.uri; } get onDidChangeContents(): Event<void> { return this.reference.object.onDidChangeContents; } dispose(): void { this.reference.dispose(); } readContents(): Promise<string> { return this.reference.object.readContents(); } saveContents(contents: string): Promise<void> { return this.reference.object.saveContents(contents); } } @injectable() export class InMemoryResources implements ResourceResolver { protected readonly resources = new SyncReferenceCollection<string, MutableResource>(uri => new MutableResource(new URI(uri))); add(uri: URI, contents: string): Resource { const resourceUri = uri.toString(); if (this.resources.has(resourceUri)) { throw new Error(`Cannot add already existing in-memory resource '${resourceUri}'`); } const resource = this.acquire(resourceUri); resource.saveContents(contents); return resource; } update(uri: URI, contents: string): Resource { const resourceUri = uri.toString(); const resource = this.resources.get(resourceUri); if (!resource) { throw new Error(`Cannot update non-existed in-memory resource '${resourceUri}'`); } resource.saveContents(contents); return resource; } resolve(uri: URI): Resource { const uriString = uri.toString(); if (!this.resources.has(uriString)) { throw new Error(`In memory '${uriString}' resource does not exist.`); } return this.acquire(uriString); } protected acquire(uri: string): ReferenceMutableResource { const reference = this.resources.acquire(uri); return new ReferenceMutableResource(reference); } } export const MEMORY_TEXT = 'mem-txt'; /** * Resource implementation for 'mem-txt' URI scheme where content is saved in URI query. */ export class InMemoryTextResource implements Resource { constructor(readonly uri: URI) { } async readContents(options?: { encoding?: string | undefined; } | undefined): Promise<string> { return this.uri.query; } dispose(): void { } } /** * ResourceResolver implementation for 'mem-txt' URI scheme. */ @injectable() export class InMemoryTextResourceResolver implements ResourceResolver { resolve(uri: URI): MaybePromise<Resource> { if (uri.scheme !== MEMORY_TEXT) { throw new Error(`Expected a URI with ${MEMORY_TEXT} scheme. Was: ${uri}.`); } return new InMemoryTextResource(uri); } } export const UNTITLED_SCHEME = 'untitled'; let untitledResourceSequenceIndex = 0; @injectable() export class UntitledResourceResolver implements ResourceResolver { protected readonly resources = new Map<string, UntitledResource>(); has(uri: URI): boolean { if (uri.scheme !== UNTITLED_SCHEME) { throw new Error('The given uri is not untitled file uri: ' + uri); } else { return this.resources.has(uri.toString()); } } async resolve(uri: URI): Promise<UntitledResource> { if (uri.scheme !== UNTITLED_SCHEME) { throw new Error('The given uri is not untitled file uri: ' + uri); } else { const untitledResource = this.resources.get(uri.toString()); if (!untitledResource) { return this.createUntitledResource('', '', uri); } else { return untitledResource; } } } async createUntitledResource(content?: string, extension?: string, uri?: URI): Promise<UntitledResource> { if (!uri) { uri = this.createUntitledURI(extension); } return new UntitledResource(this.resources, uri, content); } createUntitledURI(extension?: string, parent?: URI): URI { let counter = 1; // vscode starts at 1 let untitledUri; do { const name = `Untitled-${counter}${extension ?? ''}`; if (parent) { untitledUri = parent.resolve(name).withScheme(UNTITLED_SCHEME); } untitledUri = new URI().resolve(name).withScheme(UNTITLED_SCHEME); counter++; } while (this.has(untitledUri)); return untitledUri; } } export class UntitledResource implements Resource { protected readonly onDidChangeContentsEmitter = new Emitter<void>(); get onDidChangeContents(): Event<void> { return this.onDidChangeContentsEmitter.event; } constructor(private resources: Map<string, UntitledResource>, public uri: URI, private content?: string) { this.resources.set(this.uri.toString(), this); } dispose(): void { this.resources.delete(this.uri.toString()); this.onDidChangeContentsEmitter.dispose(); } async readContents(options?: { encoding?: string | undefined; } | undefined): Promise<string> { if (this.content) { return this.content; } else { return ''; } } async saveContents(content: string, options?: { encoding?: string, overwriteEncoding?: boolean }): Promise<void> { // This function must exist to ensure readOnly is false for the Monaco editor. // However it should not be called because saving 'untitled' is always processed as 'Save As'. throw Error('Untitled resources cannot be saved.'); } protected fireDidChangeContents(): void { this.onDidChangeContentsEmitter.fire(undefined); } get version(): ResourceVersion | undefined { return undefined; } get encoding(): string | undefined { return undefined; } } /** * @deprecated Since 1.27.0. Please use `UntitledResourceResolver.createUntitledURI` instead. */ export function createUntitledURI(extension?: string, parent?: URI): URI { const name = `Untitled-${untitledResourceSequenceIndex++}${extension ?? ''}`; if (parent) { return parent.resolve(name).withScheme(UNTITLED_SCHEME); } return new URI().resolve(name).withScheme(UNTITLED_SCHEME); }