diff --git a/packages/core/src/browser/opener-service.spec.ts b/packages/core/src/browser/opener-service.spec.ts index 64c7641e73994..57f17abf4f47d 100644 --- a/packages/core/src/browser/opener-service.spec.ts +++ b/packages/core/src/browser/opener-service.spec.ts @@ -17,6 +17,8 @@ import { DefaultOpenerService, OpenHandler } from './opener-service'; import * as assert from 'assert'; import { MaybePromise } from '../common/types'; +import * as chai from 'chai'; +const expect = chai.expect; const id = 'my-opener'; const openHandler: OpenHandler = { @@ -34,9 +36,14 @@ const openerService = new DefaultOpenerService({ }); describe('opener-service', () => { - it('getOpeners', () => openerService.getOpeners().then(openers => { assert.deepStrictEqual([openHandler], openers); })); + it('addHandler', () => { + openerService.addHandler(openHandler); + openerService.getOpeners().then(openers => { + expect(openers.length).is.equal(2); + }); + }); }); diff --git a/packages/core/src/browser/opener-service.ts b/packages/core/src/browser/opener-service.ts index 5739b83870093..3472bbc7769b3 100644 --- a/packages/core/src/browser/opener-service.ts +++ b/packages/core/src/browser/opener-service.ts @@ -16,7 +16,7 @@ import { named, injectable, inject } from 'inversify'; import URI from '../common/uri'; -import { ContributionProvider, Prioritizeable, MaybePromise } from '../common'; +import { ContributionProvider, Prioritizeable, MaybePromise, Emitter, Event, Disposable } from '../common'; export interface OpenerOptions { } @@ -75,6 +75,14 @@ export interface OpenerService { * Reject if such does not exist. */ getOpener(uri: URI, options?: OpenerOptions): Promise; + /** + * Add open handler i.e. for custom editors + */ + addHandler?(openHandler: OpenHandler): Disposable; + /** + * Event that fires when a new opener is added or removed. + */ + onDidChangeOpeners?: Event; } export async function open(openerService: OpenerService, uri: URI, options?: OpenerOptions): Promise { @@ -84,12 +92,27 @@ export async function open(openerService: OpenerService, uri: URI, options?: Ope @injectable() export class DefaultOpenerService implements OpenerService { + // Collection of open-handlers for custom-editor contributions. + protected readonly customEditorOpenHandlers: OpenHandler[] = []; + + protected readonly onDidChangeOpenersEmitter = new Emitter(); + readonly onDidChangeOpeners = this.onDidChangeOpenersEmitter.event; constructor( @inject(ContributionProvider) @named(OpenHandler) protected readonly handlersProvider: ContributionProvider ) { } + addHandler(openHandler: OpenHandler): Disposable { + this.customEditorOpenHandlers.push(openHandler); + this.onDidChangeOpenersEmitter.fire(); + + return Disposable.create(() => { + this.customEditorOpenHandlers.splice(this.customEditorOpenHandlers.indexOf(openHandler), 1); + this.onDidChangeOpenersEmitter.fire(); + }); + } + async getOpener(uri: URI, options?: OpenerOptions): Promise { const handlers = await this.prioritize(uri, options); if (handlers.length >= 1) { @@ -114,7 +137,10 @@ export class DefaultOpenerService implements OpenerService { } protected getHandlers(): OpenHandler[] { - return this.handlersProvider.getContributions(); + return [ + ...this.handlersProvider.getContributions(), + ...this.customEditorOpenHandlers + ]; } } diff --git a/packages/core/src/browser/saveable.ts b/packages/core/src/browser/saveable.ts index 959cabc9ebe1b..ad4ca17f276f9 100644 --- a/packages/core/src/browser/saveable.ts +++ b/packages/core/src/browser/saveable.ts @@ -58,7 +58,7 @@ export namespace Saveable { } // eslint-disable-next-line @typescript-eslint/no-explicit-any export function isSource(arg: any): arg is SaveableSource { - return !!arg && ('saveable' in arg); + return !!arg && ('saveable' in arg) && is(arg.saveable); } // eslint-disable-next-line @typescript-eslint/no-explicit-any export function is(arg: any): arg is Saveable { diff --git a/packages/editor/src/browser/editor.ts b/packages/editor/src/browser/editor.ts index 6c6d963d53016..922ecd1ec552d 100644 --- a/packages/editor/src/browser/editor.ts +++ b/packages/editor/src/browser/editor.ts @@ -18,8 +18,9 @@ import { Position, Range, Location } from 'vscode-languageserver-types'; import * as lsp from 'vscode-languageserver-types'; import URI from '@theia/core/lib/common/uri'; import { Event, Disposable, TextDocumentContentChangeDelta } from '@theia/core/lib/common'; -import { Saveable, Navigatable } from '@theia/core/lib/browser'; +import { Saveable, Navigatable, Widget } from '@theia/core/lib/browser'; import { EditorDecoration } from './decorations'; +import { Reference } from '@theia/core/lib/common'; export { Position, Range, Location @@ -336,3 +337,14 @@ export namespace TextEditorSelection { return e && e['uri'] instanceof URI; } } + +export namespace CustomEditorWidget { + export function is(arg: Widget | undefined): arg is CustomEditorWidget { + return !!arg && 'modelRef' in arg; + } +} + +export interface CustomEditorWidget extends Widget { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + readonly modelRef: Reference; +} diff --git a/packages/monaco/src/browser/monaco-command.ts b/packages/monaco/src/browser/monaco-command.ts index 9c7499f1857d0..944e56119a943 100644 --- a/packages/monaco/src/browser/monaco-command.ts +++ b/packages/monaco/src/browser/monaco-command.ts @@ -161,6 +161,9 @@ export class MonacoEditorCommandHandlers implements CommandContribution { }, isEnabled: () => { const editor = codeEditorService.getFocusedCodeEditor() || codeEditorService.getActiveCodeEditor(); + if (!editor) { + return false; + } if (editorActions.has(id)) { const action = editor && editor.getAction(id); return !!action && action.isSupported(); diff --git a/packages/monaco/src/browser/monaco-editor-model.ts b/packages/monaco/src/browser/monaco-editor-model.ts index 0117eea654043..89a8193048a58 100644 --- a/packages/monaco/src/browser/monaco-editor-model.ts +++ b/packages/monaco/src/browser/monaco-editor-model.ts @@ -49,6 +49,7 @@ export class MonacoEditorModel implements ITextEditorModel, TextEditorDocument { autoSave: 'on' | 'off' = 'on'; autoSaveDelay: number = 500; + suppressOpenEditorWhenDirty = false; /* @deprecated there is no general save timeout, each participant should introduce a sensible timeout */ readonly onWillSaveLoopTimeOut = 1500; protected bufferSavedVersionId: number; diff --git a/packages/monaco/src/browser/monaco-editor-service.ts b/packages/monaco/src/browser/monaco-editor-service.ts index 634b5afab4a42..f515cdccbc39a 100644 --- a/packages/monaco/src/browser/monaco-editor-service.ts +++ b/packages/monaco/src/browser/monaco-editor-service.ts @@ -17,9 +17,10 @@ import { injectable, inject, decorate } from 'inversify'; import URI from '@theia/core/lib/common/uri'; import { OpenerService, open, WidgetOpenMode, ApplicationShell, PreferenceService } from '@theia/core/lib/browser'; -import { EditorWidget, EditorOpenerOptions, EditorManager } from '@theia/editor/lib/browser'; +import { EditorWidget, EditorOpenerOptions, EditorManager, CustomEditorWidget } from '@theia/editor/lib/browser'; import { MonacoEditor } from './monaco-editor'; import { MonacoToProtocolConverter } from './monaco-to-protocol-converter'; +import { MonacoEditorModel } from './monaco-editor-model'; import ICodeEditor = monaco.editor.ICodeEditor; import CommonCodeEditor = monaco.editor.CommonCodeEditor; @@ -55,7 +56,13 @@ export class MonacoEditorService extends monaco.services.CodeEditorServiceImpl { * Monaco active editor is either focused or last focused editor. */ getActiveCodeEditor(): monaco.editor.IStandaloneCodeEditor | undefined { - const editor = MonacoEditor.getCurrent(this.editors); + let editor = MonacoEditor.getCurrent(this.editors); + if (!editor && CustomEditorWidget.is(this.shell.activeWidget)) { + const model = this.shell.activeWidget.modelRef.object; + if (model.editorTextModel instanceof MonacoEditorModel) { + editor = MonacoEditor.findByDocument(this.editors, model.editorTextModel)[0]; + } + } return editor && editor.getControl(); } diff --git a/packages/monaco/src/browser/monaco-workspace.ts b/packages/monaco/src/browser/monaco-workspace.ts index 909148bfd5049..027f10efb7ab4 100644 --- a/packages/monaco/src/browser/monaco-workspace.ts +++ b/packages/monaco/src/browser/monaco-workspace.ts @@ -159,7 +159,7 @@ export class MonacoWorkspace { protected readonly suppressedOpenIfDirty: MonacoEditorModel[] = []; protected openEditorIfDirty(model: MonacoEditorModel): void { - if (this.suppressedOpenIfDirty.indexOf(model) !== -1) { + if (model.suppressOpenEditorWhenDirty || this.suppressedOpenIfDirty.indexOf(model) !== -1) { return; } if (model.dirty && MonacoEditor.findByDocument(this.editorManager, model).length === 0) { diff --git a/packages/plugin-ext/src/common/plugin-api-rpc.ts b/packages/plugin-ext/src/common/plugin-api-rpc.ts index 26364ce6d3c3f..d43ecf53e7373 100644 --- a/packages/plugin-ext/src/common/plugin-api-rpc.ts +++ b/packages/plugin-ext/src/common/plugin-api-rpc.ts @@ -1442,6 +1442,40 @@ export interface WebviewsMain { $unregisterSerializer(viewType: string): void; } +export interface CustomEditorsExt { + $resolveWebviewEditor( + resource: UriComponents, + newWebviewHandle: string, + viewType: string, + title: string, + position: number, + options: theia.WebviewPanelOptions, + cancellation: CancellationToken): Promise; + $createCustomDocument(resource: UriComponents, viewType: string, backupId: string | undefined, cancellation: CancellationToken): Promise<{ editable: boolean }>; + $disposeCustomDocument(resource: UriComponents, viewType: string): Promise; + $undo(resource: UriComponents, viewType: string, editId: number, isDirty: boolean): Promise; + $redo(resource: UriComponents, viewType: string, editId: number, isDirty: boolean): Promise; + $revert(resource: UriComponents, viewType: string, cancellation: CancellationToken): Promise; + $disposeEdits(resourceComponents: UriComponents, viewType: string, editIds: number[]): void; + $onSave(resource: UriComponents, viewType: string, cancellation: CancellationToken): Promise; + $onSaveAs(resource: UriComponents, viewType: string, targetResource: UriComponents, cancellation: CancellationToken): Promise; + // $backup(resource: UriComponents, viewType: string, cancellation: CancellationToken): Promise; + $onMoveCustomEditor(handle: string, newResource: UriComponents, viewType: string): Promise; +} + +export interface CustomTextEditorCapabilities { + readonly supportsMove?: boolean; +} + +export interface CustomEditorsMain { + $registerTextEditorProvider(viewType: string, options: theia.WebviewPanelOptions, capabilities: CustomTextEditorCapabilities): void; + $registerCustomEditorProvider(viewType: string, options: theia.WebviewPanelOptions, supportsMultipleEditorsPerDocument: boolean): void; + $unregisterEditorProvider(viewType: string): void; + $createCustomEditorPanel(handle: string, title: string, viewColumn: theia.ViewColumn | undefined, options: theia.WebviewPanelOptions & theia.WebviewOptions): Promise; + $onDidEdit(resource: UriComponents, viewType: string, editId: number, label: string | undefined): void; + $onContentChange(resource: UriComponents, viewType: string): void; +} + export interface StorageMain { $set(key: string, value: KeysToAnyValues, isGlobal: boolean): Promise; $get(key: string, isGlobal: boolean): Promise; @@ -1580,6 +1614,7 @@ export const PLUGIN_RPC_CONTEXT = { LANGUAGES_MAIN: createProxyIdentifier('LanguagesMain'), CONNECTION_MAIN: createProxyIdentifier('ConnectionMain'), WEBVIEWS_MAIN: createProxyIdentifier('WebviewsMain'), + CUSTOM_EDITORS_MAIN: createProxyIdentifier('CustomEditorsMain'), STORAGE_MAIN: createProxyIdentifier('StorageMain'), TASKS_MAIN: createProxyIdentifier('TasksMain'), DEBUG_MAIN: createProxyIdentifier('DebugMain'), @@ -1612,6 +1647,7 @@ export const MAIN_RPC_CONTEXT = { LANGUAGES_EXT: createProxyIdentifier('LanguagesExt'), CONNECTION_EXT: createProxyIdentifier('ConnectionExt'), WEBVIEWS_EXT: createProxyIdentifier('WebviewsExt'), + CUSTOM_EDITORS_EXT: createProxyIdentifier('CustomEditorsExt'), STORAGE_EXT: createProxyIdentifier('StorageExt'), TASKS_EXT: createProxyIdentifier('TasksExt'), DEBUG_EXT: createProxyIdentifier('DebugExt'), @@ -1622,7 +1658,8 @@ export const MAIN_RPC_CONTEXT = { LABEL_SERVICE_EXT: createProxyIdentifier('LabelServiceExt'), TIMELINE_EXT: createProxyIdentifier('TimeLineExt'), THEMING_EXT: createProxyIdentifier('ThemingExt'), - COMMENTS_EXT: createProxyIdentifier('CommentsExt')}; + COMMENTS_EXT: createProxyIdentifier('CommentsExt') +}; export interface TasksExt { $provideTasks(handle: number, token?: CancellationToken): Promise; diff --git a/packages/plugin-ext/src/common/plugin-protocol.ts b/packages/plugin-ext/src/common/plugin-protocol.ts index 7eedadd6d16a1..2c1b7f7f179bb 100644 --- a/packages/plugin-ext/src/common/plugin-protocol.ts +++ b/packages/plugin-ext/src/common/plugin-protocol.ts @@ -71,6 +71,7 @@ export interface PluginPackageContribution { configurationDefaults?: RecursivePartial; languages?: PluginPackageLanguageContribution[]; grammars?: PluginPackageGrammarsContribution[]; + customEditors?: PluginPackageCustomEditor[]; viewsContainers?: { [location: string]: PluginPackageViewContainer[] }; views?: { [location: string]: PluginPackageView[] }; viewsWelcome?: PluginPackageViewWelcome[]; @@ -90,6 +91,23 @@ export interface PluginPackageContribution { resourceLabelFormatters?: ResourceLabelFormatter[]; } +export interface PluginPackageCustomEditor { + viewType: string; + displayName: string; + selector?: CustomEditorSelector[]; + priority?: CustomEditorPriority; +} + +export interface CustomEditorSelector { + readonly filenamePattern?: string; +} + +export enum CustomEditorPriority { + default = 'default', + builtin = 'builtin', + option = 'option', +} + export interface PluginPackageViewContainer { id: string; title: string; @@ -489,6 +507,7 @@ export interface PluginContribution { configurationDefaults?: PreferenceSchemaProperties; languages?: LanguageContribution[]; grammars?: GrammarsContribution[]; + customEditors?: CustomEditor[]; viewsContainers?: { [location: string]: ViewContainer[] }; views?: { [location: string]: View[] }; viewsWelcome?: ViewWelcome[]; @@ -612,6 +631,16 @@ export interface FoldingRules { markers?: FoldingMarkers; } +/** + * Custom Editors contribution + */ +export interface CustomEditor { + viewType: string; + displayName: string; + selector: CustomEditorSelector[]; + priority: CustomEditorPriority; +} + /** * Views Containers contribution */ diff --git a/packages/plugin-ext/src/hosted/browser/hosted-plugin.ts b/packages/plugin-ext/src/hosted/browser/hosted-plugin.ts index 20153fb49aa1f..028030050d4d2 100644 --- a/packages/plugin-ext/src/hosted/browser/hosted-plugin.ts +++ b/packages/plugin-ext/src/hosted/browser/hosted-plugin.ts @@ -62,6 +62,8 @@ import { FrontendApplicationConfigProvider } from '@theia/core/lib/browser/front import { environment } from '@theia/application-package/lib/environment'; import { JsonSchemaStore } from '@theia/core/lib/browser/json-schema-store'; import { FileService, FileSystemProviderActivationEvent } from '@theia/filesystem/lib/browser/file-service'; +import { PluginCustomEditorRegistry } from '../../main/browser/custom-editors/plugin-custom-editor-registry'; +import { CustomEditorWidget } from '../../main/browser/custom-editors/custom-editor-widget'; export type PluginHost = 'frontend' | string; export type DebugActivationEvent = 'onDebugResolve' | 'onDebugInitialConfigurations' | 'onDebugAdapterProtocolTracker'; @@ -151,6 +153,9 @@ export class HostedPluginSupport { @inject(JsonSchemaStore) protected readonly jsonSchemaStore: JsonSchemaStore; + @inject(PluginCustomEditorRegistry) + protected readonly customEditorRegistry: PluginCustomEditorRegistry; + private theiaReadyPromise: Promise; protected readonly managers = new Map(); @@ -197,9 +202,10 @@ export class HostedPluginSupport { this.taskProviderRegistry.onWillProvideTaskProvider(event => this.ensureTaskActivation(event)); this.taskResolverRegistry.onWillProvideTaskResolver(event => this.ensureTaskActivation(event)); this.fileService.onWillActivateFileSystemProvider(event => this.ensureFileSystemActivation(event)); + this.customEditorRegistry.onWillOpenCustomEditor(event => this.activateByCustomEditor(event)); this.widgets.onDidCreateWidget(({ factoryId, widget }) => { - if (factoryId === WebviewWidget.FACTORY_ID && widget instanceof WebviewWidget) { + if ((factoryId === WebviewWidget.FACTORY_ID || factoryId === CustomEditorWidget.FACTORY_ID) && widget instanceof WebviewWidget) { const storeState = widget.storeState.bind(widget); const restoreState = widget.restoreState.bind(widget); @@ -556,6 +562,10 @@ export class HostedPluginSupport { await this.activateByEvent(`onCommand:${commandId}`); } + async activateByCustomEditor(viewType: string): Promise { + await this.activateByEvent(`onCustomEditor:${viewType}`); + } + activateByFileSystem(event: FileSystemProviderActivationEvent): Promise { return this.activateByEvent(`onFileSystem:${event.scheme}`); } @@ -713,10 +723,17 @@ export class HostedPluginSupport { this.webviewRevivers.delete(viewType); } - protected preserveWebviews(): void { + protected async preserveWebviews(): Promise { for (const webview of this.widgets.getWidgets(WebviewWidget.FACTORY_ID)) { this.preserveWebview(webview as WebviewWidget); } + for (const webview of this.widgets.getWidgets(CustomEditorWidget.FACTORY_ID)) { + (webview as CustomEditorWidget).modelRef.dispose(); + if ((webview as any)['closeWithoutSaving']) { + delete (webview as any)['closeWithoutSaving']; + } + this.customEditorRegistry.resolveWidget(webview as CustomEditorWidget); + } } protected preserveWebview(webview: WebviewWidget): void { diff --git a/packages/plugin-ext/src/hosted/node/scanners/scanner-theia.ts b/packages/plugin-ext/src/hosted/node/scanners/scanner-theia.ts index 91058e85cf085..6a0d196f1bdff 100644 --- a/packages/plugin-ext/src/hosted/node/scanners/scanner-theia.ts +++ b/packages/plugin-ext/src/hosted/node/scanners/scanner-theia.ts @@ -49,7 +49,10 @@ import { ThemeContribution, View, ViewContainer, - ViewWelcome + ViewWelcome, + PluginPackageCustomEditor, + CustomEditor, + CustomEditorPriority } from '../../../common/plugin-protocol'; import * as fs from 'fs'; import * as path from 'path'; @@ -194,6 +197,15 @@ export class TheiaPluginScanner implements PluginScanner { console.error(`Could not read '${rawPlugin.name}' contribution 'grammars'.`, rawPlugin.contributes!.grammars, err); } + try { + if (rawPlugin.contributes?.customEditors) { + const customEditors = this.readCustomEditors(rawPlugin.contributes.customEditors!); + contributions.customEditors = customEditors; + } + } catch (err) { + console.error(`Could not read '${rawPlugin.name}' contribution 'customEditors'.`, rawPlugin.contributes!.customEditors, err); + } + try { if (rawPlugin.contributes && rawPlugin.contributes.viewsContainers) { const viewsContainers = rawPlugin.contributes.viewsContainers; @@ -484,6 +496,19 @@ export class TheiaPluginScanner implements PluginScanner { }; } + private readCustomEditors(rawCustomEditors: PluginPackageCustomEditor[]): CustomEditor[] { + return rawCustomEditors.map(rawCustomEditor => this.readCustomEditor(rawCustomEditor)); + } + + private readCustomEditor(rawCustomEditor: PluginPackageCustomEditor): CustomEditor { + return { + viewType: rawCustomEditor.viewType, + displayName: rawCustomEditor.displayName, + selector: rawCustomEditor.selector || [], + priority: rawCustomEditor.priority || CustomEditorPriority.default + }; + } + private readViewsContainers(rawViewsContainers: PluginPackageViewContainer[], pck: PluginPackage): ViewContainer[] { return rawViewsContainers.map(rawViewContainer => this.readViewContainer(rawViewContainer, pck)); } diff --git a/packages/plugin-ext/src/main/browser/custom-editors/custom-editor-contribution.ts b/packages/plugin-ext/src/main/browser/custom-editors/custom-editor-contribution.ts new file mode 100644 index 0000000000000..2b1e613dfbb81 --- /dev/null +++ b/packages/plugin-ext/src/main/browser/custom-editors/custom-editor-contribution.ts @@ -0,0 +1,38 @@ +/******************************************************************************** + * Copyright (c) 2021 SAP SE or an SAP affiliate company 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 } from 'inversify'; +import { CommandRegistry, CommandContribution } from '@theia/core/lib/common'; +import { ApplicationShell, CommonCommands } from '@theia/core/lib/browser'; +import { CustomEditorWidget } from './custom-editor-widget'; + +@injectable() +export class CustomEditorContribution implements CommandContribution { + + @inject(ApplicationShell) + protected readonly shell: ApplicationShell; + + registerCommands(commands: CommandRegistry): void { + commands.registerHandler(CommonCommands.UNDO.id, { + isEnabled: () => this.shell.activeWidget instanceof CustomEditorWidget, + execute: () => (this.shell.activeWidget as CustomEditorWidget).undo() + }); + commands.registerHandler(CommonCommands.REDO.id, { + isEnabled: () => this.shell.activeWidget instanceof CustomEditorWidget, + execute: () => (this.shell.activeWidget as CustomEditorWidget).redo() + }); + } +} diff --git a/packages/plugin-ext/src/main/browser/custom-editors/custom-editor-opener.tsx b/packages/plugin-ext/src/main/browser/custom-editors/custom-editor-opener.tsx new file mode 100644 index 0000000000000..c8e2ccbc4f40d --- /dev/null +++ b/packages/plugin-ext/src/main/browser/custom-editors/custom-editor-opener.tsx @@ -0,0 +1,94 @@ +/******************************************************************************** + * Copyright (c) 2021 SAP SE or an SAP affiliate company 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 { inject } from 'inversify'; +import { PreviewEditorOpenerOptions } from '@theia/editor-preview/lib/browser'; +import URI from '@theia/core/lib/common/uri'; +import { ApplicationShell, OpenerOptions, OpenHandler, Widget, WidgetManager } from '@theia/core/lib/browser'; +import { CustomEditorPriority, CustomEditorSelector } from '../../../common'; +import * as glob from './glob'; +import { CustomEditor } from '../../../common'; +import { CustomEditorWidget } from './custom-editor-widget'; +import { v4 } from 'uuid'; +import { Emitter } from '@theia/core'; + +export class CustomEditorOpener implements OpenHandler { + + readonly id: string; + readonly label: string; + + private readonly onDidOpenCustomEditorEmitter = new Emitter(); + readonly onDidOpenCustomEditor = this.onDidOpenCustomEditorEmitter.event; + + constructor( + private readonly editor: CustomEditor, + @inject(ApplicationShell) protected readonly shell: ApplicationShell, + @inject(WidgetManager) protected readonly widgetManager: WidgetManager + ) { + this.id = `custom-editor-${this.editor.viewType}`; + this.label = this.editor.displayName; + } + + canHandle(uri: URI, options?: PreviewEditorOpenerOptions): number { + if (this.matches(this.editor.selector, uri)) { + return this.getPriority(); + } + return 0; + } + + getPriority(): number { + switch (this.editor.priority) { + case CustomEditorPriority.default: return 500; + case CustomEditorPriority.builtin: return 400; + case CustomEditorPriority.option: return 300; + default: return 200; + } + } + + async open(uri: URI, options?: OpenerOptions): Promise { + let widget: CustomEditorWidget | undefined; + const widgets = this.widgetManager.getWidgets(CustomEditorWidget.FACTORY_ID) as CustomEditorWidget[]; + widget = widgets.find(w => w.viewType === this.editor.viewType && w.resource.toString() === uri.toString()); + + if (widget?.isVisible) { + return this.shell.revealWidget(widget.id); + } + if (widget?.isAttached) { + return this.shell.activateWidget(widget.id); + } + if (!widget) { + const id = v4(); + widget = await this.widgetManager.getOrCreateWidget(CustomEditorWidget.FACTORY_ID, { id }); + widget.viewType = this.editor.viewType; + widget.resource = uri; + } + + this.onDidOpenCustomEditorEmitter.fire(widget); + } + + matches(selectors: CustomEditorSelector[], resource: URI): boolean { + return selectors.some(selector => this.selectorMatches(selector, resource)); + } + + selectorMatches(selector: CustomEditorSelector, resource: URI): boolean { + if (selector.filenamePattern) { + if (glob.match(selector.filenamePattern.toLowerCase(), resource.path.name.toLowerCase() + resource.path.ext.toLowerCase())) { + return true; + } + } + return false; + } +} diff --git a/packages/plugin-ext/src/main/browser/custom-editors/custom-editor-service.ts b/packages/plugin-ext/src/main/browser/custom-editors/custom-editor-service.ts new file mode 100644 index 0000000000000..6434a1644581f --- /dev/null +++ b/packages/plugin-ext/src/main/browser/custom-editors/custom-editor-service.ts @@ -0,0 +1,108 @@ +/******************************************************************************** + * Copyright (c) 2021 SAP SE or an SAP affiliate company 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 + ********************************************************************************/ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +// copied and modified from https://github.com/microsoft/vscode/blob/53eac52308c4611000a171cc7bf1214293473c78/src/vs/workbench/contrib/customEditor/browser/customEditors.ts + +import { injectable } from 'inversify'; +import URI from '@theia/core/lib/common/uri'; +import { Reference } from '@theia/core/lib/common/reference'; +import { CustomEditorModel } from './custom-editors-main'; + +@injectable() +export class CustomEditorService { + protected _models = new CustomEditorModelManager(); + get models(): CustomEditorModelManager { return this._models; } +} + +export class CustomEditorModelManager { + + private readonly references = new Map, + counter: number + }>(); + + add(resource: URI, viewType: string, model: Promise): Promise> { + const key = this.key(resource, viewType); + const existing = this.references.get(key); + if (existing) { + throw new Error('Model already exists'); + } + + this.references.set(key, { viewType, model, counter: 0 }); + return this.tryRetain(resource, viewType)!; + } + + async get(resource: URI, viewType: string): Promise { + const key = this.key(resource, viewType); + const entry = this.references.get(key); + return entry?.model; + } + + tryRetain(resource: URI, viewType: string): Promise> | undefined { + const key = this.key(resource, viewType); + + const entry = this.references.get(key); + if (!entry) { + return undefined; + } + + entry.counter++; + + return entry.model.then(model => ({ + object: model, + dispose: once(() => { + if (--entry!.counter <= 0) { + entry.model.then(x => x.dispose()); + this.references.delete(key); + } + }), + })); + } + + disposeAllModelsForView(viewType: string): void { + for (const [key, value] of this.references) { + if (value.viewType === viewType) { + value.model.then(x => x.dispose()); + this.references.delete(key); + } + } + } + + private key(resource: URI, viewType: string): string { + return `${resource.toString()}@@@${viewType}`; + } +} + +export function once(this: unknown, fn: T): T { + const _this = this; + let didCall = false; + let result: unknown; + + return function (): unknown { + if (didCall) { + return result; + } + + didCall = true; + result = fn.apply(_this, arguments); + + return result; + } as unknown as T; +} diff --git a/packages/plugin-ext/src/main/browser/custom-editors/custom-editor-widget-factory.ts b/packages/plugin-ext/src/main/browser/custom-editors/custom-editor-widget-factory.ts new file mode 100644 index 0000000000000..e23ee857fb303 --- /dev/null +++ b/packages/plugin-ext/src/main/browser/custom-editors/custom-editor-widget-factory.ts @@ -0,0 +1,44 @@ +/******************************************************************************** + * Copyright (c) 2021 SAP SE or an SAP affiliate company 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 { CustomEditorWidget } from '../custom-editors/custom-editor-widget'; +import { interfaces } from 'inversify'; +import { WebviewWidgetIdentifier, WebviewWidgetExternalEndpoint } from '../webview/webview'; +import { WebviewEnvironment } from '../webview/webview-environment'; + +export class CustomEditorWidgetFactory { + + readonly id = CustomEditorWidget.FACTORY_ID; + + protected readonly container: interfaces.Container; + + constructor(container: interfaces.Container) { + this.container = container; + } + + async createWidget(identifier: WebviewWidgetIdentifier): Promise { + const externalEndpoint = await this.container.get(WebviewEnvironment).externalEndpoint(); + let endpoint = externalEndpoint.replace('{{uuid}}', identifier.id); + if (endpoint[endpoint.length - 1] === '/') { + endpoint = endpoint.slice(0, endpoint.length - 1); + } + const child = this.container.createChild(); + child.bind(WebviewWidgetIdentifier).toConstantValue(identifier); + child.bind(WebviewWidgetExternalEndpoint).toConstantValue(endpoint); + return child.get(CustomEditorWidget); + } + +} diff --git a/packages/plugin-ext/src/main/browser/custom-editors/custom-editor-widget.ts b/packages/plugin-ext/src/main/browser/custom-editors/custom-editor-widget.ts new file mode 100644 index 0000000000000..2ff60a022fa4b --- /dev/null +++ b/packages/plugin-ext/src/main/browser/custom-editors/custom-editor-widget.ts @@ -0,0 +1,116 @@ +/******************************************************************************** + * Copyright (c) 2021 SAP SE or an SAP affiliate company 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, postConstruct } from 'inversify'; +import URI from '@theia/core/lib/common/uri'; +import { FileOperation } from '@theia/filesystem/lib/common/files'; +import { NavigatableWidget, Saveable, SaveableSource, SaveOptions } from '@theia/core/lib/browser'; +import { Reference } from '@theia/core/lib/common/reference'; +import { WebviewWidget } from '../webview/webview'; +import { UndoRedoService } from './undo-redo-service'; +import { CustomEditorModel } from './custom-editors-main'; + +@injectable() +export class CustomEditorWidget extends WebviewWidget implements SaveableSource, NavigatableWidget { + static FACTORY_ID = 'plugin-custom-editor'; + + id: string; + resource: URI; + + protected _modelRef: Reference; + get modelRef(): Reference { + return this._modelRef; + } + set modelRef(modelRef: Reference) { + this._modelRef = modelRef; + this.doUpdateContent(); + Saveable.apply(this); + } + get saveable(): Saveable { + return this._modelRef.object; + } + + @inject(UndoRedoService) + protected readonly undoRedoService: UndoRedoService; + + @postConstruct() + protected init(): void { + super.init(); + this.id = CustomEditorWidget.FACTORY_ID + ':' + this.identifier.id; + this.toDispose.push(this.fileService.onDidRunOperation(e => { + if (e.isOperation(FileOperation.MOVE)) { + this.doMove(e.target.resource); + } + })); + } + + undo(): void { + this.undoRedoService.undo(this.resource); + } + + redo(): void { + this.undoRedoService.redo(this.resource); + } + + async save(options?: SaveOptions): Promise { + await this._modelRef.object.saveCustomEditor(options); + } + + async saveAs(source: URI, target: URI, options?: SaveOptions): Promise { + const result = await this._modelRef.object.saveCustomEditorAs(source, target, options); + this.doMove(target); + return result; + } + + getResourceUri(): URI | undefined { + return this.resource; + } + + createMoveToUri(resourceUri: URI): URI | undefined { + return this.resource.withPath(resourceUri.path); + } + + storeState(): CustomEditorWidget.State { + return { + ...super.storeState(), + strResource: this.resource.toString(), + }; + } + + restoreState(oldState: CustomEditorWidget.State): void { + const { strResource } = oldState; + this.resource = new URI(strResource); + super.restoreState(oldState); + } + + onMove(handler: (newResource: URI) => Promise): void { + this._moveHandler = handler; + } + + private _moveHandler?: (newResource: URI) => void; + + private doMove(target: URI): void { + if (this._moveHandler) { + this._moveHandler(target); + } + } +} + +export namespace CustomEditorWidget { + export interface State extends WebviewWidget.State { + strResource: string + } +} diff --git a/packages/plugin-ext/src/main/browser/custom-editors/custom-editors-main.ts b/packages/plugin-ext/src/main/browser/custom-editors/custom-editors-main.ts new file mode 100644 index 0000000000000..a5d2773949b1f --- /dev/null +++ b/packages/plugin-ext/src/main/browser/custom-editors/custom-editors-main.ts @@ -0,0 +1,555 @@ +/******************************************************************************** + * Copyright (c) 2021 SAP SE or an SAP affiliate company 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 + ********************************************************************************/ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +// some code copied and modified from https://github.com/microsoft/vscode/blob/53eac52308c4611000a171cc7bf1214293473c78/src/vs/workbench/api/browser/mainThreadCustomEditors.ts + +import { interfaces } from 'inversify'; +import { MAIN_RPC_CONTEXT, CustomEditorsMain, CustomEditorsExt, CustomTextEditorCapabilities } from '../../../common/plugin-api-rpc'; +import { RPCProtocol } from '../../../common/rpc-protocol'; +import { HostedPluginSupport } from '../../../hosted/browser/hosted-plugin'; +import { PluginCustomEditorRegistry } from './plugin-custom-editor-registry'; +import { CustomEditorWidget } from './custom-editor-widget'; +import { Emitter } from '@theia/core'; +import { UriComponents } from '../../../common/uri-components'; +import { URI } from 'vscode-uri'; +import TheiaURI from '@theia/core/lib/common/uri'; +import { Disposable, DisposableCollection } from '@theia/core/lib/common/disposable'; +import { Reference } from '@theia/core/lib/common/reference'; +import { CancellationToken, CancellationTokenSource } from '@theia/core/lib/common/cancellation'; +import { MonacoEditorModel } from '@theia/monaco/lib/browser/monaco-editor-model'; +import { EditorModelService } from '../text-editor-model-service'; +import { CustomEditorService } from './custom-editor-service'; +import { FileService } from '@theia/filesystem/lib/browser/file-service'; +import { UndoRedoService } from './undo-redo-service'; +import { WebviewsMainImpl } from '../webviews-main'; +import { WidgetManager } from '@theia/core/lib/browser/widget-manager'; +import { ApplicationShell, DefaultUriLabelProviderContribution, Saveable, SaveOptions } from '@theia/core/lib/browser'; +import { WebviewOptions, WebviewPanelOptions, ViewColumn } from '@theia/plugin'; +import { WebviewWidgetIdentifier } from '../webview/webview'; +import { EditorPreferences } from '@theia/editor/lib/browser'; +import { EditorPosition } from '../../../common/plugin-api-rpc'; + +const enum CustomEditorModelType { + Custom, + Text, +} + +export class CustomEditorsMainImpl implements CustomEditorsMain, Disposable { + protected readonly pluginService: HostedPluginSupport; + protected readonly shell: ApplicationShell; + protected readonly textModelService: EditorModelService; + protected readonly fileService: FileService; + protected readonly customEditorService: CustomEditorService; + protected readonly undoRedoService: UndoRedoService; + protected readonly customEditorRegistry: PluginCustomEditorRegistry; + protected readonly labelProvider: DefaultUriLabelProviderContribution; + protected readonly widgetManager: WidgetManager; + protected readonly editorPreferences: EditorPreferences; + private readonly proxy: CustomEditorsExt; + private readonly editorProviders = new Map(); + + constructor(rpc: RPCProtocol, + container: interfaces.Container, + readonly webviewsMain: WebviewsMainImpl, + ) { + this.pluginService = container.get(HostedPluginSupport); + this.shell = container.get(ApplicationShell); + this.textModelService = container.get(EditorModelService); + this.fileService = container.get(FileService); + this.customEditorService = container.get(CustomEditorService); + this.undoRedoService = container.get(UndoRedoService); + this.customEditorRegistry = container.get(PluginCustomEditorRegistry); + this.labelProvider = container.get(DefaultUriLabelProviderContribution); + this.editorPreferences = container.get(EditorPreferences); + this.widgetManager = container.get(WidgetManager); + this.proxy = rpc.getProxy(MAIN_RPC_CONTEXT.CUSTOM_EDITORS_EXT); + } + + dispose(): void { + for (const disposable of this.editorProviders.values()) { + disposable.dispose(); + } + this.editorProviders.clear(); + } + + $registerTextEditorProvider( + viewType: string, options: WebviewPanelOptions, capabilities: CustomTextEditorCapabilities): void { + this.registerEditorProvider(CustomEditorModelType.Text, viewType, options, capabilities, true); + } + + $registerCustomEditorProvider(viewType: string, options: WebviewPanelOptions, supportsMultipleEditorsPerDocument: boolean): void { + this.registerEditorProvider(CustomEditorModelType.Custom, viewType, options, {}, supportsMultipleEditorsPerDocument); + } + + protected async registerEditorProvider( + modelType: CustomEditorModelType, + viewType: string, + options: WebviewPanelOptions, + capabilities: CustomTextEditorCapabilities, + supportsMultipleEditorsPerDocument: boolean, + ): Promise { + if (this.editorProviders.has(viewType)) { + throw new Error(`Provider for ${viewType} already registered`); + } + + const disposables = new DisposableCollection(); + + disposables.push( + this.customEditorRegistry.registerResolver(viewType, async widget => { + const { resource, identifier } = widget; + widget.options = options; + + const cancellationSource = new CancellationTokenSource(); + let modelRef = await this.getOrCreateCustomEditorModel(modelType, resource, viewType, cancellationSource.token); + widget.modelRef = modelRef; + + widget.onDidDispose(() => { + // If the model is still dirty, make sure we have time to save it + if (modelRef.object.dirty) { + const sub = modelRef.object.onDirtyChanged(() => { + if (!modelRef.object.dirty) { + sub.dispose(); + modelRef.dispose(); + } + }); + return; + } + + modelRef.dispose(); + }); + + if (capabilities.supportsMove) { + const onMoveCancelTokenSource = new CancellationTokenSource(); + widget.onMove(async (newResource: TheiaURI) => { + const oldModel = modelRef; + modelRef = await this.getOrCreateCustomEditorModel(modelType, newResource, viewType, onMoveCancelTokenSource.token); + this.proxy.$onMoveCustomEditor(identifier.id, URI.file(newResource.path.toString()), viewType); + oldModel.dispose(); + }); + } + + const _cancellationSource = new CancellationTokenSource(); + await this.proxy.$resolveWebviewEditor( + URI.file(resource.path.toString()), + identifier.id, + viewType, + this.labelProvider.getName(resource)!, + EditorPosition.ONE, // TODO: fix this when Theia has support splitting editors, + options, + _cancellationSource.token + ); + }) + ); + + this.editorProviders.set(viewType, disposables); + } + + $unregisterEditorProvider(viewType: string): void { + const provider = this.editorProviders.get(viewType); + if (!provider) { + throw new Error(`No provider for ${viewType} registered`); + } + + provider.dispose(); + this.editorProviders.delete(viewType); + + this.customEditorService.models.disposeAllModelsForView(viewType); + } + + protected async getOrCreateCustomEditorModel( + modelType: CustomEditorModelType, + resource: TheiaURI, + viewType: string, + cancellationToken: CancellationToken, + ): Promise> { + const existingModel = this.customEditorService.models.tryRetain(resource, viewType); + if (existingModel) { + return existingModel; + } + + switch (modelType) { + case CustomEditorModelType.Text: { + const model = CustomTextEditorModel.create(viewType, resource, this.textModelService, this.fileService); + return this.customEditorService.models.add(resource, viewType, model); + } + case CustomEditorModelType.Custom: { + const model = MainCustomEditorModel.create(this.proxy, viewType, resource, this.undoRedoService, this.fileService, this.editorPreferences, cancellationToken); + return this.customEditorService.models.add(resource, viewType, model); + } + } + } + + protected async getCustomEditorModel(resourceComponents: UriComponents, viewType: string): Promise { + const resource = URI.revive(resourceComponents); + const model = await this.customEditorService.models.get(new TheiaURI(resource), viewType); + if (!model || !(model instanceof MainCustomEditorModel)) { + throw new Error('Could not find model for custom editor'); + } + return model; + } + + async $onDidEdit(resourceComponents: UriComponents, viewType: string, editId: number, label: string | undefined): Promise { + const model = await this.getCustomEditorModel(resourceComponents, viewType); + model.pushEdit(editId, label); + } + + async $onContentChange(resourceComponents: UriComponents, viewType: string): Promise { + const model = await this.getCustomEditorModel(resourceComponents, viewType); + model.changeContent(); + } + + async $createCustomEditorPanel( + panelId: string, + title: string, + viewColumn: ViewColumn, + options: WebviewPanelOptions & WebviewOptions + ): Promise { + const view = await this.widgetManager.getOrCreateWidget(CustomEditorWidget.FACTORY_ID, { id: panelId }); + this.webviewsMain.hookWebview(view); + view.title.label = title; + const { enableFindWidget, retainContextWhenHidden, enableScripts, localResourceRoots, ...contentOptions } = options; + view.viewColumn = viewColumn; + view.options = { enableFindWidget, retainContextWhenHidden }; + view.setContentOptions({ + allowScripts: enableScripts, + localResourceRoots: localResourceRoots && localResourceRoots.map(root => root.toString()), + ...contentOptions, + ...view.contentOptions + }); + if (view.isAttached) { + if (view.isVisible) { + this.shell.revealWidget(view.id); + } + return; + } + this.webviewsMain.addOrReattachWidget(view, { preserveFocus: true }); + } +} + +export interface CustomEditorModel extends Saveable, Disposable { + readonly viewType: string; + readonly resource: URI; + readonly readonly: boolean; + readonly dirty: boolean; + + revert(options?: Saveable.RevertOptions): Promise; + saveCustomEditor(options?: SaveOptions): Promise; + saveCustomEditorAs(resource: TheiaURI, targetResource: TheiaURI, options?: SaveOptions): Promise; +} + +export class MainCustomEditorModel implements CustomEditorModel { + private currentEditIndex: number = -1; + private savePoint: number = -1; + private isDirtyFromContentChange = false; + private ongoingSave?: CancellationTokenSource; + private readonly edits: Array = []; + private readonly toDispose = new DisposableCollection(); + + private readonly onDirtyChangedEmitter = new Emitter(); + readonly onDirtyChanged = this.onDirtyChangedEmitter.event; + + autoSave: 'on' | 'off'; + autoSaveDelay: number; + + static async create( + proxy: CustomEditorsExt, + viewType: string, + resource: TheiaURI, + undoRedoService: UndoRedoService, + fileService: FileService, + editorPreferences: EditorPreferences, + cancellation: CancellationToken, + ): Promise { + const { editable } = await proxy.$createCustomDocument(URI.file(resource.path.toString()), viewType, undefined, cancellation); + return new MainCustomEditorModel(proxy, viewType, resource, editable, undoRedoService, fileService, editorPreferences); + } + + constructor( + private proxy: CustomEditorsExt, + readonly viewType: string, + private readonly editorResource: TheiaURI, + private readonly editable: boolean, + private readonly undoRedoService: UndoRedoService, + private readonly fileService: FileService, + private readonly editorPreferences: EditorPreferences + ) { + this.autoSave = this.editorPreferences.get('editor.autoSave', undefined, editorResource.toString()); + this.autoSaveDelay = this.editorPreferences.get('editor.autoSaveDelay', undefined, editorResource.toString()); + + this.toDispose.push( + this.editorPreferences.onPreferenceChanged(event => { + if (event.preferenceName === 'editor.autoSave') { + this.autoSave = this.editorPreferences.get('editor.autoSave', undefined, editorResource.toString()); + } + if (event.preferenceName === 'editor.autoSaveDelay') { + this.autoSaveDelay = this.editorPreferences.get('editor.autoSaveDelay', undefined, editorResource.toString()); + } + }) + ); + this.toDispose.push(this.onDirtyChangedEmitter); + } + + get resource(): URI { + return URI.file(this.editorResource.path.toString()); + } + + get dirty(): boolean { + if (this.isDirtyFromContentChange) { + return true; + } + if (this.edits.length > 0) { + return this.savePoint !== this.currentEditIndex; + } + return false; + } + + get readonly(): boolean { + return !this.editable; + } + + setProxy(proxy: CustomEditorsExt): void { + this.proxy = proxy; + } + + dispose(): void { + if (this.editable) { + this.undoRedoService.removeElements(this.editorResource); + } + this.proxy.$disposeCustomDocument(this.resource, this.viewType); + } + + changeContent(): void { + this.change(() => { + this.isDirtyFromContentChange = true; + }); + } + + pushEdit(editId: number, label: string | undefined): void { + if (!this.editable) { + throw new Error('Document is not editable'); + } + + this.change(() => { + this.spliceEdits(editId); + this.currentEditIndex = this.edits.length - 1; + }); + + this.undoRedoService.pushElement( + this.editorResource, + () => this.undo(), + () => this.redo(), + ); + } + + async revert(options?: Saveable.RevertOptions): Promise { + if (!this.editable) { + return; + } + + if (this.currentEditIndex === this.savePoint && !this.isDirtyFromContentChange) { + return; + } + + const cancellationSource = new CancellationTokenSource(); + this.proxy.$revert(this.resource, this.viewType, cancellationSource.token); + this.change(() => { + this.isDirtyFromContentChange = false; + this.currentEditIndex = this.savePoint; + this.spliceEdits(); + }); + } + + async save(options?: SaveOptions): Promise { + await this.saveCustomEditor(options); + } + + async saveCustomEditor(options?: SaveOptions): Promise { + if (!this.editable) { + return; + } + + const cancelable = new CancellationTokenSource(); + const savePromise = this.proxy.$onSave(this.resource, this.viewType, cancelable.token); + this.ongoingSave?.cancel(); + this.ongoingSave = cancelable; + + try { + await savePromise; + + if (this.ongoingSave === cancelable) { // Make sure we are still doing the same save + this.change(() => { + this.isDirtyFromContentChange = false; + this.savePoint = this.currentEditIndex; + }); + } + } finally { + if (this.ongoingSave === cancelable) { // Make sure we are still doing the same save + this.ongoingSave = undefined; + } + } + } + + async saveCustomEditorAs(resource: TheiaURI, targetResource: TheiaURI, options?: SaveOptions): Promise { + if (this.editable) { + const source = new CancellationTokenSource(); + await this.proxy.$onSaveAs(this.resource, this.viewType, URI.file(targetResource.path.toString()), source.token); + this.change(() => { + this.savePoint = this.currentEditIndex; + }); + } else { + // Since the editor is readonly, just copy the file over + await this.fileService.copy(resource, targetResource, { overwrite: false }); + } + } + + private async undo(): Promise { + if (!this.editable) { + return; + } + + if (this.currentEditIndex < 0) { + // nothing to undo + return; + } + + const undoneEdit = this.edits[this.currentEditIndex]; + this.change(() => { + --this.currentEditIndex; + }); + await this.proxy.$undo(this.resource, this.viewType, undoneEdit, this.dirty); + } + + private async redo(): Promise { + if (!this.editable) { + return; + } + + if (this.currentEditIndex >= this.edits.length - 1) { + // nothing to redo + return; + } + + const redoneEdit = this.edits[this.currentEditIndex + 1]; + this.change(() => { + ++this.currentEditIndex; + }); + await this.proxy.$redo(this.resource, this.viewType, redoneEdit, this.dirty); + } + + private spliceEdits(editToInsert?: number): void { + const start = this.currentEditIndex + 1; + const toRemove = this.edits.length - this.currentEditIndex; + + const removedEdits = typeof editToInsert === 'number' + ? this.edits.splice(start, toRemove, editToInsert) + : this.edits.splice(start, toRemove); + + if (removedEdits.length) { + this.proxy.$disposeEdits(this.resource, this.viewType, removedEdits); + } + } + + private change(makeEdit: () => void): void { + const wasDirty = this.dirty; + makeEdit(); + + if (this.dirty !== wasDirty) { + this.onDirtyChangedEmitter.fire(); + } + + if (this.autoSave === 'on') { + const handle = window.setTimeout(() => { + this.save(); + window.clearTimeout(handle); + }, this.autoSaveDelay); + } + } + +} + +// copied from https://github.com/microsoft/vscode/blob/53eac52308c4611000a171cc7bf1214293473c78/src/vs/workbench/contrib/customEditor/common/customTextEditorModel.ts +export class CustomTextEditorModel implements CustomEditorModel { + private readonly toDispose = new DisposableCollection(); + private readonly onDirtyChangedEmitter = new Emitter(); + readonly onDirtyChanged = this.onDirtyChangedEmitter.event; + readonly autoSave: 'on' | 'off'; + + static async create( + viewType: string, + resource: TheiaURI, + editorModelService: EditorModelService, + fileService: FileService + ): Promise { + const model = await editorModelService.createModelReference(resource); + model.object.suppressOpenEditorWhenDirty = true; + return new CustomTextEditorModel(viewType, resource, model, fileService); + } + + constructor( + readonly viewType: string, + readonly editorResource: TheiaURI, + private readonly model: Reference, + private readonly fileService: FileService + ) { + this.toDispose.push( + this.editorTextModel.onDirtyChanged(e => { + this.onDirtyChangedEmitter.fire(); + }) + ); + this.toDispose.push(this.onDirtyChangedEmitter); + } + + dispose(): void { + this.toDispose.dispose(); + this.model.dispose(); + } + + get resource(): URI { + return URI.file(this.editorResource.path.toString()); + } + + get dirty(): boolean { + return this.editorTextModel.dirty; + }; + + get readonly(): boolean { + return this.editorTextModel.readOnly; + } + + get editorTextModel(): MonacoEditorModel { + return this.model.object; + } + + revert(options?: Saveable.RevertOptions): Promise { + return this.editorTextModel.revert(options); + } + + save(options?: SaveOptions): Promise { + return this.saveCustomEditor(options); + } + + saveCustomEditor(options?: SaveOptions): Promise { + return this.editorTextModel.save(options); + } + + async saveCustomEditorAs(resource: TheiaURI, targetResource: TheiaURI, options?: SaveOptions): Promise { + await this.saveCustomEditor(options); + await this.fileService.copy(resource, targetResource, { overwrite: false }); + } +} diff --git a/packages/plugin-ext/src/main/browser/custom-editors/glob.ts b/packages/plugin-ext/src/main/browser/custom-editors/glob.ts new file mode 100644 index 0000000000000..d915b91d76187 --- /dev/null +++ b/packages/plugin-ext/src/main/browser/custom-editors/glob.ts @@ -0,0 +1,743 @@ +/******************************************************************************** + * Copyright (c) 2021 SAP SE or an SAP affiliate company 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 + ********************************************************************************/ +// copied from https://github.com/Microsoft/vscode/blob/bf7ac9201e7a7d01741d4e6e64b5dc9f3197d97b/src/vs/base/common/glob.ts +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +'use strict'; + +import * as strings from '@theia/core/lib/common/strings'; +import * as paths from './paths'; +import { CharCode } from '@theia/core/lib/common/char-code'; + +/* eslint-disable @typescript-eslint/no-shadow, no-null/no-null */ + +export interface IExpression { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + [pattern: string]: boolean | SiblingClause | any; +} + +export interface IRelativePattern { + base: string; + pattern: string; + pathToRelative(from: string, to: string): string; +} + +export function getEmptyExpression(): IExpression { + return Object.create(null); +} + +export interface SiblingClause { + when: string; +} + +const GLOBSTAR = '**'; +const GLOB_SPLIT = '/'; +const PATH_REGEX = '[/\\\\]'; // any slash or backslash +const NO_PATH_REGEX = '[^/\\\\]'; // any non-slash and non-backslash +const ALL_FORWARD_SLASHES = /\//g; + +function starsToRegExp(starCount: number): string { + switch (starCount) { + case 0: + return ''; + case 1: + return `${NO_PATH_REGEX}*?`; // 1 star matches any number of characters except path separator (/ and \) - non greedy (?) + default: + // Matches: (Path Sep OR Path Val followed by Path Sep OR Path Sep followed by Path Val) 0-many times + // Group is non capturing because we don't need to capture at all (?:...) + // Overall we use non-greedy matching because it could be that we match too much + return `(?:${PATH_REGEX}|${NO_PATH_REGEX}+${PATH_REGEX}|${PATH_REGEX}${NO_PATH_REGEX}+)*?`; + } +} + +export function splitGlobAware(pattern: string, splitChar: string): string[] { + if (!pattern) { + return []; + } + + const segments: string[] = []; + + let inBraces = false; + let inBrackets = false; + + let char: string; + let curVal = ''; + for (let i = 0; i < pattern.length; i++) { + char = pattern[i]; + + switch (char) { + case splitChar: + if (!inBraces && !inBrackets) { + segments.push(curVal); + curVal = ''; + + continue; + } + break; + case '{': + inBraces = true; + break; + case '}': + inBraces = false; + break; + case '[': + inBrackets = true; + break; + case ']': + inBrackets = false; + break; + } + + curVal += char; + } + + // Tail + if (curVal) { + segments.push(curVal); + } + + return segments; +} + +function parseRegExp(pattern: string): string { + if (!pattern) { + return ''; + } + + let regEx = ''; + + // Split up into segments for each slash found + // eslint-disable-next-line prefer-const + let segments = splitGlobAware(pattern, GLOB_SPLIT); + + // Special case where we only have globstars + if (segments.every(s => s === GLOBSTAR)) { + regEx = '.*'; + } + + // Build regex over segments + // tslint:disable-next-line:one-line + else { + let previousSegmentWasGlobStar = false; + segments.forEach((segment, index) => { + + // Globstar is special + if (segment === GLOBSTAR) { + + // if we have more than one globstar after another, just ignore it + if (!previousSegmentWasGlobStar) { + regEx += starsToRegExp(2); + previousSegmentWasGlobStar = true; + } + + return; + } + + // States + let inBraces = false; + let braceVal = ''; + + let inBrackets = false; + let bracketVal = ''; + + let char: string; + for (let i = 0; i < segment.length; i++) { + char = segment[i]; + + // Support brace expansion + if (char !== '}' && inBraces) { + braceVal += char; + continue; + } + + // Support brackets + if (inBrackets && (char !== ']' || !bracketVal) /* ] is literally only allowed as first character in brackets to match it */) { + let res: string; + + // range operator + if (char === '-') { + res = char; + } + + // negation operator (only valid on first index in bracket) + // tslint:disable-next-line:one-line + else if ((char === '^' || char === '!') && !bracketVal) { + res = '^'; + } + + // glob split matching is not allowed within character ranges + // see http://man7.org/linux/man-pages/man7/glob.7.html + // tslint:disable-next-line:one-line + else if (char === GLOB_SPLIT) { + res = ''; + } + + // anything else gets escaped + // tslint:disable-next-line:one-line + else { + res = strings.escapeRegExpCharacters(char); + } + + bracketVal += res; + continue; + } + + switch (char) { + case '{': + inBraces = true; + continue; + + case '[': + inBrackets = true; + continue; + + case '}': + // eslint-disable-next-line prefer-const + let choices = splitGlobAware(braceVal, ','); + + // Converts {foo,bar} => [foo|bar] + // eslint-disable-next-line prefer-const + let braceRegExp = `(?:${choices.map(c => parseRegExp(c)).join('|')})`; + + regEx += braceRegExp; + + inBraces = false; + braceVal = ''; + + break; + + case ']': + regEx += ('[' + bracketVal + ']'); + + inBrackets = false; + bracketVal = ''; + + break; + + case '?': + regEx += NO_PATH_REGEX; // 1 ? matches any single character except path separator (/ and \) + continue; + + case '*': + regEx += starsToRegExp(1); + continue; + + default: + regEx += strings.escapeRegExpCharacters(char); + } + } + + // Tail: Add the slash we had split on if there is more to come and the remaining pattern is not a globstar + // For example if pattern: some/**/*.js we want the "/" after some to be included in the RegEx to prevent + // a folder called "something" to match as well. + // However, if pattern: some/**, we tolerate that we also match on "something" because our globstar behavior + // is to match 0-N segments. + if (index < segments.length - 1 && (segments[index + 1] !== GLOBSTAR || index + 2 < segments.length)) { + regEx += PATH_REGEX; + } + + // reset state + previousSegmentWasGlobStar = false; + }); + } + + return regEx; +} + +// regexes to check for trivial glob patterns that just check for String#endsWith +const T1 = /^\*\*\/\*\.[\w\.-]+$/; // **/*.something +const T2 = /^\*\*\/([\w\.-]+)\/?$/; // **/something +const T3 = /^{\*\*\/[\*\.]?[\w\.-]+\/?(,\*\*\/[\*\.]?[\w\.-]+\/?)*}$/; // {**/*.something,**/*.else} or {**/package.json,**/project.json} +const T3_2 = /^{\*\*\/[\*\.]?[\w\.-]+(\/(\*\*)?)?(,\*\*\/[\*\.]?[\w\.-]+(\/(\*\*)?)?)*}$/; // Like T3, with optional trailing /** +const T4 = /^\*\*((\/[\w\.-]+)+)\/?$/; // **/something/else +const T5 = /^([\w\.-]+(\/[\w\.-]+)*)\/?$/; // something/else + +export type ParsedPattern = (path: string, basename?: string) => boolean; + +// The ParsedExpression returns a Promise iff hasSibling returns a Promise. +// eslint-disable-next-line max-len +export type ParsedExpression = (path: string, basename?: string, hasSibling?: (name: string) => boolean | Promise) => string | Promise /* the matching pattern */; + +export interface IGlobOptions { + /** + * Simplify patterns for use as exclusion filters during tree traversal to skip entire subtrees. Cannot be used outside of a tree traversal. + */ + trimForExclusions?: boolean; +} + +interface ParsedStringPattern { + (path: string, basename: string): string | Promise /* the matching pattern */; + basenames?: string[]; + patterns?: string[]; + allBasenames?: string[]; + allPaths?: string[]; +} +interface ParsedExpressionPattern { + (path: string, basename: string, name: string, hasSibling: (name: string) => boolean | Promise): string | Promise /* the matching pattern */; + requiresSiblings?: boolean; + allBasenames?: string[]; + allPaths?: string[]; +} + +const CACHE = new Map(); // new LRUCache(10000); // bounded to 10000 elements + +const FALSE = function (): boolean { + return false; +}; + +const NULL = function (): string { + return null!; +}; + +function parsePattern(arg1: string | IRelativePattern, options: IGlobOptions): ParsedStringPattern { + if (!arg1) { + return NULL; + } + + // Handle IRelativePattern + let pattern: string; + if (typeof arg1 !== 'string') { + pattern = arg1.pattern; + } else { + pattern = arg1; + } + + // Whitespace trimming + pattern = pattern.trim(); + + // Check cache + const patternKey = `${pattern}_${!!options.trimForExclusions}`; + let parsedPattern = CACHE.get(patternKey); + if (parsedPattern) { + return wrapRelativePattern(parsedPattern, arg1); + } + + // Check for Trivias + let match: RegExpExecArray; + if (T1.test(pattern)) { // common pattern: **/*.txt just need endsWith check + const base = pattern.substr(4); // '**/*'.length === 4 + parsedPattern = function (path, basename): string { + return path && strings.endsWith(path, base) ? pattern : null!; + }; + } else if (match = T2.exec(trimForExclusions(pattern, options))!) { // common pattern: **/some.txt just need basename check + parsedPattern = trivia2(match[1], pattern); + } else if ((options.trimForExclusions ? T3_2 : T3).test(pattern)) { // repetition of common patterns (see above) {**/*.txt,**/*.png} + parsedPattern = trivia3(pattern, options); + } else if (match = T4.exec(trimForExclusions(pattern, options))!) { // common pattern: **/something/else just need endsWith check + parsedPattern = trivia4and5(match[1].substr(1), pattern, true); + } else if (match = T5.exec(trimForExclusions(pattern, options))!) { // common pattern: something/else just need equals check + parsedPattern = trivia4and5(match[1], pattern, false); + } + + // Otherwise convert to pattern + // tslint:disable-next-line:one-line + else { + parsedPattern = toRegExp(pattern); + } + + // Cache + CACHE.set(patternKey, parsedPattern); + + return wrapRelativePattern(parsedPattern, arg1); +} + +function wrapRelativePattern(parsedPattern: ParsedStringPattern, arg2: string | IRelativePattern): ParsedStringPattern { + if (typeof arg2 === 'string') { + return parsedPattern; + } + + return function (path, basename): string | Promise { + if (!paths.isEqualOrParent(path, arg2.base)) { + return null!; + } + + return parsedPattern(paths.normalize(arg2.pathToRelative(arg2.base, path)), basename); + }; +} + +function trimForExclusions(pattern: string, options: IGlobOptions): string { + return options.trimForExclusions && strings.endsWith(pattern, '/**') ? pattern.substr(0, pattern.length - 2) : pattern; // dropping **, tailing / is dropped later +} + +// common pattern: **/some.txt just need basename check +function trivia2(base: string, originalPattern: string): ParsedStringPattern { + const slashBase = `/${base}`; + const backslashBase = `\\${base}`; + const parsedPattern: ParsedStringPattern = function (path, basename): string { + if (!path) { + return null!; + } + if (basename) { + return basename === base ? originalPattern : null!; + } + return path === base || strings.endsWith(path, slashBase) || strings.endsWith(path, backslashBase) ? originalPattern : null!; + }; + const basenames = [base]; + parsedPattern.basenames = basenames; + parsedPattern.patterns = [originalPattern]; + parsedPattern.allBasenames = basenames; + return parsedPattern; +} + +// repetition of common patterns (see above) {**/*.txt,**/*.png} +function trivia3(pattern: string, options: IGlobOptions): ParsedStringPattern { + const parsedPatterns = aggregateBasenameMatches(pattern.slice(1, -1).split(',') + .map(pattern => parsePattern(pattern, options)) + .filter(pattern => pattern !== NULL), pattern); + const n = parsedPatterns.length; + if (!n) { + return NULL; + } + if (n === 1) { + return parsedPatterns[0]; + } + const parsedPattern: ParsedStringPattern = function (path: string, basename: string): string { + for (let i = 0, n = parsedPatterns.length; i < n; i++) { + if ((parsedPatterns[i])(path, basename)) { + return pattern; + } + } + return null!; + }; + const withBasenames = parsedPatterns.find(pattern => !!(pattern).allBasenames); + // const withBasenames = arrays.first(parsedPatterns, pattern => !!(pattern).allBasenames); + if (withBasenames) { + parsedPattern.allBasenames = (withBasenames).allBasenames; + } + const allPaths = parsedPatterns.reduce((all, current) => current.allPaths ? all.concat(current.allPaths) : all, []); + if (allPaths.length) { + parsedPattern.allPaths = allPaths; + } + return parsedPattern; +} + +// common patterns: **/something/else just need endsWith check, something/else just needs and equals check +function trivia4and5(path: string, pattern: string, matchPathEnds: boolean): ParsedStringPattern { + const nativePath = paths.nativeSep !== paths.sep ? path.replace(ALL_FORWARD_SLASHES, paths.nativeSep) : path; + const nativePathEnd = paths.nativeSep + nativePath; + // eslint-disable-next-line @typescript-eslint/no-shadow + const parsedPattern: ParsedStringPattern = matchPathEnds ? function (path, basename): string { + return path && (path === nativePath || strings.endsWith(path, nativePathEnd)) ? pattern : null!; + // eslint-disable-next-line @typescript-eslint/no-shadow + } : function (path, basename): string { + return path && path === nativePath ? pattern : null!; + }; + parsedPattern.allPaths = [(matchPathEnds ? '*/' : './') + path]; + return parsedPattern; +} + +function toRegExp(pattern: string): ParsedStringPattern { + try { + const regExp = new RegExp(`^${parseRegExp(pattern)}$`); + return function (path: string, basename: string): string { + regExp.lastIndex = 0; // reset RegExp to its initial state to reuse it! + return path && regExp.test(path) ? pattern : null!; + }; + } catch (error) { + return NULL; + } +} + +/** + * Simplified glob matching. Supports a subset of glob patterns: + * - * matches anything inside a path segment + * - ? matches 1 character inside a path segment + * - ** matches anything including an empty path segment + * - simple brace expansion ({js,ts} => js or ts) + * - character ranges (using [...]) + */ +export function match(pattern: string | IRelativePattern, path: string): boolean; +export function match(expression: IExpression, path: string, hasSibling?: (name: string) => boolean): string /* the matching pattern */; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function match(arg1: string | IExpression | IRelativePattern, path: string, hasSibling?: (name: string) => boolean): any { + if (!arg1 || !path) { + return false; + } + + return parse(arg1)(path, undefined, hasSibling); +} + +/** + * Simplified glob matching. Supports a subset of glob patterns: + * - * matches anything inside a path segment + * - ? matches 1 character inside a path segment + * - ** matches anything including an empty path segment + * - simple brace expansion ({js,ts} => js or ts) + * - character ranges (using [...]) + */ +export function parse(pattern: string | IRelativePattern, options?: IGlobOptions): ParsedPattern; +export function parse(expression: IExpression, options?: IGlobOptions): ParsedExpression; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function parse(arg1: string | IExpression | IRelativePattern, options: IGlobOptions = {}): any { + if (!arg1) { + return FALSE; + } + + // Glob with String + if (typeof arg1 === 'string' || isRelativePattern(arg1)) { + const parsedPattern = parsePattern(arg1 as string | IRelativePattern, options); + if (parsedPattern === NULL) { + return FALSE; + } + const resultPattern = function (path: string, basename: string): boolean { + return !!parsedPattern(path, basename); + }; + if (parsedPattern.allBasenames) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (resultPattern).allBasenames = parsedPattern.allBasenames; + } + if (parsedPattern.allPaths) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (resultPattern).allPaths = parsedPattern.allPaths; + } + return resultPattern; + } + + // Glob with Expression + return parsedExpression(arg1, options); +} + +export function hasSiblingPromiseFn(siblingsFn?: () => Promise): ((name: string) => Promise) | undefined { + if (!siblingsFn) { + return undefined; + } + + let siblings: Promise>; + return (name: string) => { + if (!siblings) { + siblings = (siblingsFn() || Promise.resolve([])) + .then(list => list ? listToMap(list) : {}); + } + return siblings.then(map => !!map[name]); + }; +} + +export function hasSiblingFn(siblingsFn?: () => string[]): ((name: string) => boolean) | undefined { + if (!siblingsFn) { + return undefined; + } + + let siblings: Record; + return (name: string) => { + if (!siblings) { + const list = siblingsFn(); + siblings = list ? listToMap(list) : {}; + } + return !!siblings[name]; + }; +} + +function listToMap(list: string[]): Record { + const map: Record = {}; + for (const key of list) { + map[key] = true; + } + return map; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function isRelativePattern(obj: any): obj is IRelativePattern { + const rp = obj as IRelativePattern; + + return rp && typeof rp.base === 'string' && typeof rp.pattern === 'string' && typeof rp.pathToRelative === 'function'; +} + +/** + * Same as `parse`, but the ParsedExpression is guaranteed to return a Promise + */ +export function parseToAsync(expression: IExpression, options?: IGlobOptions): ParsedExpression { + // eslint-disable-next-line @typescript-eslint/no-shadow + const parsedExpression = parse(expression, options); + return (path: string, basename?: string, hasSibling?: (name: string) => boolean | Promise): string | Promise => { + const result = parsedExpression(path, basename, hasSibling); + return result instanceof Promise ? result : Promise.resolve(result); + }; +} + +export function getBasenameTerms(patternOrExpression: ParsedPattern | ParsedExpression): string[] { + return (patternOrExpression).allBasenames || []; +} + +export function getPathTerms(patternOrExpression: ParsedPattern | ParsedExpression): string[] { + return (patternOrExpression).allPaths || []; +} + +function parsedExpression(expression: IExpression, options: IGlobOptions): ParsedExpression { + const parsedPatterns = aggregateBasenameMatches(Object.getOwnPropertyNames(expression) + .map(pattern => parseExpressionPattern(pattern, expression[pattern], options)) + .filter(pattern => pattern !== NULL)); + + const n = parsedPatterns.length; + if (!n) { + return NULL; + } + + if (!parsedPatterns.some(parsedPattern => (parsedPattern).requiresSiblings!)) { + if (n === 1) { + return parsedPatterns[0]; + } + + // eslint-disable-next-line @typescript-eslint/no-shadow + const resultExpression: ParsedStringPattern = function (path: string, basename: string): string | Promise { + // eslint-disable-next-line @typescript-eslint/no-shadow + // tslint:disable-next-line:one-variable-per-declaration + for (let i = 0, n = parsedPatterns.length; i < n; i++) { + // Pattern matches path + const result = (parsedPatterns[i])(path, basename); + if (result) { + return result; + } + } + + return null!; + }; + + // eslint-disable-next-line @typescript-eslint/no-shadow + const withBasenames = parsedPatterns.find(pattern => !!(pattern).allBasenames); + if (withBasenames) { + resultExpression.allBasenames = (withBasenames).allBasenames; + } + + // eslint-disable-next-line @typescript-eslint/no-shadow + const allPaths = parsedPatterns.reduce((all, current) => current.allPaths ? all.concat(current.allPaths) : all, []); + if (allPaths.length) { + resultExpression.allPaths = allPaths; + } + + return resultExpression; + } + + const resultExpression: ParsedStringPattern = function (path: string, basename: string, hasSibling?: (name: string) => boolean | Promise): string | Promise { + let name: string = null!; + + // eslint-disable-next-line @typescript-eslint/no-shadow + for (let i = 0, n = parsedPatterns.length; i < n; i++) { + // Pattern matches path + const parsedPattern = (parsedPatterns[i]); + if (parsedPattern.requiresSiblings && hasSibling) { + if (!basename) { + basename = paths.basename(path); + } + if (!name) { + name = basename.substr(0, basename.length - paths.extname(path).length); + } + } + const result = parsedPattern(path, basename, name, hasSibling!); + if (result) { + return result; + } + } + + return null!; + }; + + const withBasenames = parsedPatterns.find(pattern => !!(pattern).allBasenames); + if (withBasenames) { + resultExpression.allBasenames = (withBasenames).allBasenames; + } + + const allPaths = parsedPatterns.reduce((all, current) => current.allPaths ? all.concat(current.allPaths) : all, []); + if (allPaths.length) { + resultExpression.allPaths = allPaths; + } + + return resultExpression; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function parseExpressionPattern(pattern: string, value: any, options: IGlobOptions): (ParsedStringPattern | ParsedExpressionPattern) { + if (value === false) { + return NULL; // pattern is disabled + } + + const parsedPattern = parsePattern(pattern, options); + if (parsedPattern === NULL) { + return NULL; + } + + // Expression Pattern is + if (typeof value === 'boolean') { + return parsedPattern; + } + + // Expression Pattern is + if (value) { + const when = (value).when; + if (typeof when === 'string') { + const result: ParsedExpressionPattern = (path: string, basename: string, name: string, hasSibling: (name: string) => boolean | Promise) => { + if (!hasSibling || !parsedPattern(path, basename)) { + return null!; + } + + const clausePattern = when.replace('$(basename)', name); + const matched = hasSibling(clausePattern); + return matched instanceof Promise ? + matched.then(m => m ? pattern : null!) : + matched ? pattern : null!; + }; + result.requiresSiblings = true; + return result; + } + } + + // Expression is Anything + return parsedPattern; +} + +function aggregateBasenameMatches(parsedPatterns: (ParsedStringPattern | ParsedExpressionPattern)[], result?: string): (ParsedStringPattern | ParsedExpressionPattern)[] { + const basenamePatterns = parsedPatterns.filter(parsedPattern => !!(parsedPattern).basenames); + if (basenamePatterns.length < 2) { + return parsedPatterns; + } + + const basenames = basenamePatterns.reduce((all, current) => all.concat((current).basenames!), []); + let patterns: string[]; + if (result) { + patterns = []; + // tslint:disable-next-line:one-variable-per-declaration + for (let i = 0, n = basenames.length; i < n; i++) { + patterns.push(result); + } + } else { + patterns = basenamePatterns.reduce((all, current) => all.concat((current).patterns!), []); + } + const aggregate: ParsedStringPattern = function (path, basename): string { + if (!path) { + return null!; + } + if (!basename) { + let i: number; + for (i = path.length; i > 0; i--) { + const ch = path.charCodeAt(i - 1); + if (ch === CharCode.Slash || ch === CharCode.Backslash) { + break; + } + } + basename = path.substr(i); + } + const index = basenames.indexOf(basename); + return index !== -1 ? patterns[index] : null!; + }; + aggregate.basenames = basenames; + aggregate.patterns = patterns; + aggregate.allBasenames = basenames; + + const aggregatedPatterns = parsedPatterns.filter(parsedPattern => !(parsedPattern).basenames); + aggregatedPatterns.push(aggregate); + return aggregatedPatterns; +} diff --git a/packages/plugin-ext/src/main/browser/custom-editors/paths.ts b/packages/plugin-ext/src/main/browser/custom-editors/paths.ts new file mode 100644 index 0000000000000..3799af4147edd --- /dev/null +++ b/packages/plugin-ext/src/main/browser/custom-editors/paths.ts @@ -0,0 +1,250 @@ +/******************************************************************************** + * Copyright (c) 2021 SAP SE or an SAP affiliate company 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 + ********************************************************************************/ + +// copied from https://github.com/Microsoft/vscode/blob/bf7ac9201e7a7d01741d4e6e64b5dc9f3197d97b/src/vs/base/common/paths.ts +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +/* eslint-disable no-void */ +/* eslint-disable no-null/no-null */ +'use strict'; +import { isWindows } from '@theia/core/lib/common/os'; +import { startsWithIgnoreCase } from '@theia/core/lib/common/strings'; +import { CharCode } from '@theia/core/lib/common/char-code'; + +/** + * The forward slash path separator. + */ +export const sep = '/'; + +/** + * The native path separator depending on the OS. + */ +export const nativeSep = isWindows ? '\\' : '/'; + +const _posixBadPath = /(\/\.\.?\/)|(\/\.\.?)$|^(\.\.?\/)|(\/\/+)|(\\)/; +const _winBadPath = /(\\\.\.?\\)|(\\\.\.?)$|^(\.\.?\\)|(\\\\+)|(\/)/; + +function _isNormal(path: string, win: boolean): boolean { + return win + ? !_winBadPath.test(path) + : !_posixBadPath.test(path); +} + +/** + * @returns the base name of a path. + */ +export function basename(path: string): string { + const idx = ~path.lastIndexOf('/') || ~path.lastIndexOf('\\'); + if (idx === 0) { + return path; + } else if (~idx === path.length - 1) { + return basename(path.substring(0, path.length - 1)); + } else { + return path.substr(~idx + 1); + } +} + +/** + * @returns `.far` from `boo.far` or the empty string. + */ +export function extname(path: string): string { + path = basename(path); + const idx = ~path.lastIndexOf('.'); + return idx ? path.substring(~idx) : ''; +} + +export function normalize(path: string, toOSPath?: boolean): string { + if (path === null || path === void 0) { + return path; + } + + const len = path.length; + if (len === 0) { + return '.'; + } + + const wantsBackslash = isWindows && toOSPath; + if (_isNormal(path, wantsBackslash!)) { + return path; + } + + // eslint-disable-next-line @typescript-eslint/no-shadow + const sep = wantsBackslash ? '\\' : '/'; + const root = getRoot(path, sep); + + // skip the root-portion of the path + let start = root.length; + let skip = false; + let res = ''; + + for (let end = root.length; end <= len; end++) { + + // either at the end or at a path-separator character + if (end === len || path.charCodeAt(end) === CharCode.Slash || path.charCodeAt(end) === CharCode.Backslash) { + + if (streql(path, start, end, '..')) { + // skip current and remove parent (if there is already something) + const prev_start = res.lastIndexOf(sep); + const prev_part = res.slice(prev_start + 1); + if ((root || prev_part.length > 0) && prev_part !== '..') { + res = prev_start === -1 ? '' : res.slice(0, prev_start); + skip = true; + } + } else if (streql(path, start, end, '.') && (root || res || end < len - 1)) { + // skip current (if there is already something or if there is more to come) + skip = true; + } + + if (!skip) { + const part = path.slice(start, end); + if (res !== '' && res[res.length - 1] !== sep) { + res += sep; + } + res += part; + } + start = end + 1; + skip = false; + } + } + + return root + res; +} +function streql(value: string, start: number, end: number, other: string): boolean { + return start + other.length === end && value.indexOf(other, start) === start; +} + +/** + * Computes the _root_ this path, like `getRoot('c:\files') === c:\`, + * `getRoot('files:///files/path') === files:///`, + * or `getRoot('\\server\shares\path') === \\server\shares\` + */ +// eslint-disable-next-line @typescript-eslint/no-shadow +export function getRoot(path: string, sep: string = '/'): string { + + if (!path) { + return ''; + } + + const len = path.length; + let code = path.charCodeAt(0); + if (code === CharCode.Slash || code === CharCode.Backslash) { + + code = path.charCodeAt(1); + if (code === CharCode.Slash || code === CharCode.Backslash) { + // UNC candidate \\localhost\shares\ddd + // ^^^^^^^^^^^^^^^^^^^ + code = path.charCodeAt(2); + if (code !== CharCode.Slash && code !== CharCode.Backslash) { + // eslint-disable-next-line @typescript-eslint/no-shadow + let pos = 3; + const start = pos; + for (; pos < len; pos++) { + code = path.charCodeAt(pos); + if (code === CharCode.Slash || code === CharCode.Backslash) { + break; + } + } + code = path.charCodeAt(pos + 1); + if (start !== pos && code !== CharCode.Slash && code !== CharCode.Backslash) { + pos += 1; + for (; pos < len; pos++) { + code = path.charCodeAt(pos); + if (code === CharCode.Slash || code === CharCode.Backslash) { + return path.slice(0, pos + 1) // consume this separator + .replace(/[\\/]/g, sep); + } + } + } + } + } + + // /user/far + // ^ + return sep; + + } else if ((code >= CharCode.A && code <= CharCode.Z) || (code >= CharCode.a && code <= CharCode.z)) { + // check for windows drive letter c:\ or c: + + if (path.charCodeAt(1) === CharCode.Colon) { + code = path.charCodeAt(2); + if (code === CharCode.Slash || code === CharCode.Backslash) { + // C:\fff + // ^^^ + return path.slice(0, 2) + sep; + } else { + // C: + // ^^ + return path.slice(0, 2); + } + } + } + + // check for URI + // scheme://authority/path + // ^^^^^^^^^^^^^^^^^^^ + let pos = path.indexOf('://'); + if (pos !== -1) { + pos += 3; // 3 -> "://".length + for (; pos < len; pos++) { + code = path.charCodeAt(pos); + if (code === CharCode.Slash || code === CharCode.Backslash) { + return path.slice(0, pos + 1); // consume this separator + } + } + } + + return ''; +} + +export function isEqualOrParent(path: string, candidate: string, ignoreCase?: boolean): boolean { + if (path === candidate) { + return true; + } + + if (!path || !candidate) { + return false; + } + + if (candidate.length > path.length) { + return false; + } + + if (ignoreCase) { + const beginsWith = startsWithIgnoreCase(path, candidate); + if (!beginsWith) { + return false; + } + + if (candidate.length === path.length) { + return true; // same path, different casing + } + + let sepOffset = candidate.length; + if (candidate.charAt(candidate.length - 1) === nativeSep) { + sepOffset--; // adjust the expected sep offset in case our candidate already ends in separator character + } + + return path.charAt(sepOffset) === nativeSep; + } + + if (candidate.charAt(candidate.length - 1) !== nativeSep) { + candidate += nativeSep; + } + + return path.indexOf(candidate) === 0; +} diff --git a/packages/plugin-ext/src/main/browser/custom-editors/plugin-custom-editor-registry.ts b/packages/plugin-ext/src/main/browser/custom-editors/plugin-custom-editor-registry.ts new file mode 100644 index 0000000000000..3ded9a43de887 --- /dev/null +++ b/packages/plugin-ext/src/main/browser/custom-editors/plugin-custom-editor-registry.ts @@ -0,0 +1,142 @@ +/******************************************************************************** + * Copyright (c) 2021 SAP SE or an SAP affiliate company 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, postConstruct } from 'inversify'; +import { CustomEditor } from '../../../common'; +import { Disposable, DisposableCollection } from '@theia/core/lib/common/disposable'; +import { CustomEditorOpener } from './custom-editor-opener'; +import { WorkspaceCommands } from '@theia/workspace/lib/browser'; +import { CommandRegistry, Emitter, MenuModelRegistry } from '@theia/core'; +import { SelectionService } from '@theia/core/lib/common'; +import { UriAwareCommandHandler } from '@theia/core/lib/common/uri-command-handler'; +import { NavigatorContextMenu } from '@theia/navigator/lib//browser/navigator-contribution'; +import { ApplicationShell, DefaultOpenerService, WidgetManager } from '@theia/core/lib/browser'; +import { CustomEditorWidget } from './custom-editor-widget'; + +@injectable() +export class PluginCustomEditorRegistry { + private readonly editors = new Map(); + private readonly pendingEditors = new Set(); + private readonly resolvers = new Map void>(); + + private readonly onWillOpenCustomEditorEmitter = new Emitter(); + readonly onWillOpenCustomEditor = this.onWillOpenCustomEditorEmitter.event; + + @inject(DefaultOpenerService) + protected readonly defaultOpenerService: DefaultOpenerService; + + @inject(MenuModelRegistry) + protected readonly menuModelRegistry: MenuModelRegistry; + + @inject(CommandRegistry) + protected readonly commandRegistry: CommandRegistry; + + @inject(SelectionService) + protected readonly selectionService: SelectionService; + + @inject(WidgetManager) + protected readonly widgetManager: WidgetManager; + + @inject(ApplicationShell) + protected readonly shell: ApplicationShell; + + @postConstruct() + protected init(): void { + this.widgetManager.onDidCreateWidget(({ factoryId, widget }) => { + if (factoryId === CustomEditorWidget.FACTORY_ID && widget instanceof CustomEditorWidget) { + const restoreState = widget.restoreState.bind(widget); + + widget.restoreState = state => { + if (state.viewType && state.strResource) { + restoreState(state); + this.resolveWidget(widget); + } else { + widget.dispose(); + } + }; + } + }); + } + + registerCustomEditor(editor: CustomEditor): Disposable { + if (this.editors.has(editor.viewType)) { + console.warn('editor with such id already registered: ', JSON.stringify(editor)); + return Disposable.NULL; + } + this.editors.set(editor.viewType, editor); + + const toDispose = new DisposableCollection(); + toDispose.push(Disposable.create(() => this.editors.delete(editor.viewType))); + + const editorOpenHandler = new CustomEditorOpener( + editor, + this.shell, + this.widgetManager + ); + toDispose.push(this.defaultOpenerService.addHandler(editorOpenHandler)); + + const openWithCommand = WorkspaceCommands.FILE_OPEN_WITH(editorOpenHandler); + toDispose.push( + this.menuModelRegistry.registerMenuAction( + NavigatorContextMenu.OPEN_WITH, + { + commandId: openWithCommand.id, + label: editorOpenHandler.label + } + ) + ); + toDispose.push( + this.commandRegistry.registerCommand( + openWithCommand, + UriAwareCommandHandler.MonoSelect(this.selectionService, { + execute: uri => editorOpenHandler.open(uri), + isEnabled: uri => editorOpenHandler.canHandle(uri) > 0, + isVisible: uri => editorOpenHandler.canHandle(uri) > 0 + }) + ) + ); + toDispose.push( + editorOpenHandler.onDidOpenCustomEditor(widget => this.resolveWidget(widget)) + ); + return toDispose; + } + + resolveWidget = (widget: CustomEditorWidget) => { + const resolver = this.resolvers.get(widget.viewType); + if (resolver) { + resolver(widget); + } else { + this.pendingEditors.add(widget); + this.onWillOpenCustomEditorEmitter.fire(widget.viewType); + } + }; + + registerResolver(viewType: string, resolver: (widget: CustomEditorWidget) => void): Disposable { + if (this.resolvers.has(viewType)) { + throw new Error(`Resolver for ${viewType} already registered`); + } + + for (const editorWidget of this.pendingEditors) { + if (editorWidget.viewType === viewType) { + resolver(editorWidget); + this.pendingEditors.delete(editorWidget); + } + } + + this.resolvers.set(viewType, resolver); + return Disposable.create(() => this.resolvers.delete(viewType)); + } +} diff --git a/packages/plugin-ext/src/main/browser/custom-editors/undo-redo-service.ts b/packages/plugin-ext/src/main/browser/custom-editors/undo-redo-service.ts new file mode 100644 index 0000000000000..143dc6892ebbf --- /dev/null +++ b/packages/plugin-ext/src/main/browser/custom-editors/undo-redo-service.ts @@ -0,0 +1,120 @@ +/******************************************************************************** + * Copyright (c) 2021 SAP SE or an SAP affiliate company 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 + ********************************************************************************/ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +// copied and modified from https://github.com/microsoft/vscode/blob/53eac52308c4611000a171cc7bf1214293473c78/src/vs/platform/undoRedo/common/undoRedoService.ts# + +import { injectable } from 'inversify'; +import URI from '@theia/core/lib/common/uri'; + +@injectable() +export class UndoRedoService { + private readonly editStacks = new Map(); + + pushElement(resource: URI, undo: () => Promise, redo: () => Promise): void { + let editStack: ResourceEditStack; + if (this.editStacks.has(resource.toString())) { + editStack = this.editStacks.get(resource.toString())!; + } else { + editStack = new ResourceEditStack(); + this.editStacks.set(resource.toString(), editStack); + } + + editStack.pushElement({ undo, redo }); + } + + removeElements(resource: URI): void { + if (this.editStacks.has(resource.toString())) { + this.editStacks.delete(resource.toString()); + } + } + + undo(resource: URI): void { + if (!this.editStacks.has(resource.toString())) { + return; + } + + const editStack = this.editStacks.get(resource.toString())!; + const element = editStack.getClosestPastElement(); + if (!element) { + return; + } + + editStack.moveBackward(element); + element.undo(); + } + + redo(resource: URI): void { + if (!this.editStacks.has(resource.toString())) { + return; + } + + const editStack = this.editStacks.get(resource.toString())!; + const element = editStack.getClosestFutureElement(); + if (!element) { + return; + } + + editStack.moveForward(element); + element.redo(); + } +} + +interface StackElement { + undo(): Promise | void; + redo(): Promise | void; +} + +export class ResourceEditStack { + private past: StackElement[]; + private future: StackElement[]; + + constructor() { + this.past = []; + this.future = []; + } + + pushElement(element: StackElement): void { + this.future = []; + this.past.push(element); + } + + getClosestPastElement(): StackElement | null { + if (this.past.length === 0) { + return null; + } + return this.past[this.past.length - 1]; + } + + getClosestFutureElement(): StackElement | null { + if (this.future.length === 0) { + return null; + } + return this.future[this.future.length - 1]; + } + + moveBackward(element: StackElement): void { + this.past.pop(); + this.future.push(element); + } + + moveForward(element: StackElement): void { + this.future.pop(); + this.past.push(element); + } +} diff --git a/packages/plugin-ext/src/main/browser/main-context.ts b/packages/plugin-ext/src/main/browser/main-context.ts index 0aa70f7915ccb..dbd30180d0d00 100644 --- a/packages/plugin-ext/src/main/browser/main-context.ts +++ b/packages/plugin-ext/src/main/browser/main-context.ts @@ -54,6 +54,7 @@ import { TimelineMainImpl } from './timeline-main'; import { AuthenticationMainImpl } from './authentication-main'; import { ThemingMainImpl } from './theming-main'; import { CommentsMainImp } from './comments/comments-main'; +import { CustomEditorsMainImpl } from './custom-editors/custom-editors-main'; export function setUpPluginApi(rpc: RPCProtocol, container: interfaces.Container): void { const authenticationMain = new AuthenticationMainImpl(rpc, container); @@ -122,6 +123,9 @@ export function setUpPluginApi(rpc: RPCProtocol, container: interfaces.Container const webviewsMain = new WebviewsMainImpl(rpc, container); rpc.set(PLUGIN_RPC_CONTEXT.WEBVIEWS_MAIN, webviewsMain); + const customEditorsMain = new CustomEditorsMainImpl(rpc, container, webviewsMain); + rpc.set(PLUGIN_RPC_CONTEXT.CUSTOM_EDITORS_MAIN, customEditorsMain); + const storageMain = new StorageMainImpl(container); rpc.set(PLUGIN_RPC_CONTEXT.STORAGE_MAIN, storageMain); diff --git a/packages/plugin-ext/src/main/browser/plugin-contribution-handler.ts b/packages/plugin-ext/src/main/browser/plugin-contribution-handler.ts index fa48da1159b03..02a79a025dd5f 100644 --- a/packages/plugin-ext/src/main/browser/plugin-contribution-handler.ts +++ b/packages/plugin-ext/src/main/browser/plugin-contribution-handler.ts @@ -19,6 +19,7 @@ import { ITokenTypeMap, IEmbeddedLanguagesMap, StandardTokenType } from 'vscode- import { TextmateRegistry, getEncodedLanguageId, MonacoTextmateService, GrammarDefinition } from '@theia/monaco/lib/browser/textmate'; import { MenusContributionPointHandler } from './menus/menus-contribution-handler'; import { PluginViewRegistry } from './view/plugin-view-registry'; +import { PluginCustomEditorRegistry } from './custom-editors/plugin-custom-editor-registry'; import { PluginContribution, IndentationRules, FoldingRules, ScopeMap, DeployedPlugin, GrammarsContribution } from '../../common'; import { DefaultUriLabelProviderContribution, @@ -51,6 +52,9 @@ export class PluginContributionHandler { @inject(PluginViewRegistry) private readonly viewRegistry: PluginViewRegistry; + @inject(PluginCustomEditorRegistry) + private readonly customEditorRegistry: PluginCustomEditorRegistry; + @inject(MenusContributionPointHandler) private readonly menusContributionHandler: MenusContributionPointHandler; @@ -232,6 +236,14 @@ export class PluginContributionHandler { pushContribution('menus', () => this.menusContributionHandler.handle(plugin)); pushContribution('keybindings', () => this.keybindingsContributionHandler.handle(contributions)); + if (contributions.customEditors) { + for (const customEditor of contributions.customEditors) { + pushContribution(`customEditors.${customEditor.viewType}`, + () => this.customEditorRegistry.registerCustomEditor(customEditor) + ); + } + } + if (contributions.viewsContainers) { for (const location in contributions.viewsContainers) { if (contributions.viewsContainers!.hasOwnProperty(location)) { diff --git a/packages/plugin-ext/src/main/browser/plugin-ext-frontend-module.ts b/packages/plugin-ext/src/main/browser/plugin-ext-frontend-module.ts index dbb005c6952ef..d5e9618d6dee9 100644 --- a/packages/plugin-ext/src/main/browser/plugin-ext-frontend-module.ts +++ b/packages/plugin-ext/src/main/browser/plugin-ext-frontend-module.ts @@ -69,6 +69,12 @@ import { CommentsService, PluginCommentService } from './comments/comments-servi import { CommentingRangeDecorator } from './comments/comments-decorator'; import { CommentsContribution } from './comments/comments-contribution'; import { CommentsContextKeyService } from './comments/comments-context-key-service'; +import { CustomEditorContribution } from './custom-editors/custom-editor-contribution'; +import { PluginCustomEditorRegistry } from './custom-editors/plugin-custom-editor-registry'; +import { CustomEditorWidgetFactory } from '../browser/custom-editors/custom-editor-widget-factory'; +import { CustomEditorWidget } from './custom-editors/custom-editor-widget'; +import { CustomEditorService } from './custom-editors/custom-editor-service'; +import { UndoRedoService } from './custom-editors/undo-redo-service'; export default new ContainerModule((bind, unbind, isBound, rebind) => { @@ -161,6 +167,17 @@ export default new ContainerModule((bind, unbind, isBound, rebind) => { bind(WebviewWidgetFactory).toDynamicValue(ctx => new WebviewWidgetFactory(ctx.container)).inSingletonScope(); bind(WidgetFactory).toService(WebviewWidgetFactory); + bind(CustomEditorContribution).toSelf().inSingletonScope(); + bind(CommandContribution).toService(CustomEditorContribution); + + bind(PluginCustomEditorRegistry).toSelf().inSingletonScope(); + bind(CustomEditorService).toSelf().inSingletonScope(); + bind(CustomEditorWidget).toSelf(); + bind(CustomEditorWidgetFactory).toDynamicValue(ctx => new CustomEditorWidgetFactory(ctx.container)).inSingletonScope(); + bind(WidgetFactory).toService(CustomEditorWidgetFactory); + + bind(UndoRedoService).toSelf().inSingletonScope(); + bind(PluginViewWidget).toSelf(); bind(WidgetFactory).toDynamicValue(({ container }) => ({ id: PLUGIN_VIEW_FACTORY_ID, diff --git a/packages/plugin-ext/src/main/browser/webview/pre/main.js b/packages/plugin-ext/src/main/browser/webview/pre/main.js index 58560f55b57bd..0c1bb0894cc9f 100644 --- a/packages/plugin-ext/src/main/browser/webview/pre/main.js +++ b/packages/plugin-ext/src/main/browser/webview/pre/main.js @@ -34,13 +34,13 @@ (function () { 'use strict'; - /** - * Use polling to track focus of main webview and iframes within the webview - * - * @param {Object} handlers - * @param {() => void} handlers.onFocus - * @param {() => void} handlers.onBlur - */ + /** + * Use polling to track focus of main webview and iframes within the webview + * + * @param {Object} handlers + * @param {() => void} handlers.onFocus + * @param {() => void} handlers.onBlur + */ const trackFocus = ({ onFocus, onBlur }) => { const interval = 50; let isFocused = document.hasFocus(); @@ -140,10 +140,10 @@ background-color: var(--vscode-scrollbarSlider-activeBackground); }`; - /** - * @param {*} [state] - * @return {string} - */ + /** + * @param {*} [state] + * @return {string} + */ function getVsCodeApiScript(state) { return ` const acquireVsCodeApi = (function() { @@ -180,9 +180,9 @@ `; } - /** - * @param {WebviewHost} host - */ + /** + * @param {WebviewHost} host + */ function createWebviewManager(host) { // state let firstLoad = true; @@ -194,10 +194,10 @@ }; - /** - * @param {HTMLDocument?} document - * @param {HTMLElement?} body - */ + /** + * @param {HTMLDocument?} document + * @param {HTMLElement?} body + */ const applyStyles = (document, body) => { if (!document) { return; @@ -215,9 +215,9 @@ } }; - /** - * @param {MouseEvent} event - */ + /** + * @param {MouseEvent} event + */ const handleInnerClick = (event) => { if (!event || !event.view || !event.view.document) { return; @@ -245,9 +245,9 @@ } }; - /** - * @param {MouseEvent} event - */ + /** + * @param {MouseEvent} event + */ const handleAuxClick = (event) => { // Prevent middle clicks opening a broken link in the browser @@ -267,9 +267,9 @@ } }; - /** - * @param {KeyboardEvent} e - */ + /** + * @param {KeyboardEvent} e + */ const handleInnerKeydown = (e) => { preventDefaultBrowserHotkeys(e); @@ -318,10 +318,10 @@ }; function preventDefaultBrowserHotkeys(e) { - var isOSX = navigator.platform.toUpperCase().indexOf('MAC')>=0; + var isOSX = navigator.platform.toUpperCase().indexOf('MAC') >= 0; - // F1 or CtrlCmd+P - if (e.keyCode === 112 || (((e.ctrlKey && !isOSX) || (e.metaKey && isOSX)) && e.keyCode === 80)) { + // F1 or CtrlCmd+P or CtrlCmd+S + if (e.keyCode === 112 || (((e.ctrlKey && !isOSX) || (e.metaKey && isOSX)) && (e.keyCode === 80 || e.keyCode === 83))) { e.preventDefault(); } } @@ -351,9 +351,9 @@ }); }; - /** - * @return {string} - */ + /** + * @return {string} + */ function toContentHtml(data) { const options = data.options; const text = data.contents; @@ -539,9 +539,9 @@ } }; - /** - * @param {HTMLIFrameElement} newFrame - */ + /** + * @param {HTMLIFrameElement} newFrame + */ function hookupOnLoadHandlers(newFrame) { const timeoutDelay = 5000; clearTimeout(loadTimeout); diff --git a/packages/plugin-ext/src/main/browser/webview/webview.ts b/packages/plugin-ext/src/main/browser/webview/webview.ts index 39491a2d5c031..6960e2be4d821 100644 --- a/packages/plugin-ext/src/main/browser/webview/webview.ts +++ b/packages/plugin-ext/src/main/browser/webview/webview.ts @@ -47,6 +47,7 @@ import { Endpoint } from '@theia/core/lib/browser/endpoint'; import { FileService } from '@theia/filesystem/lib/browser/file-service'; import { FileOperationError, FileOperationResult } from '@theia/filesystem/lib/common/files'; import { BinaryBufferReadableStream } from '@theia/core/lib/common/buffer'; +import { ViewColumn } from '../../../plugin/types-impl'; // Style from core const TRANSPARENT_OVERLAY_STYLE = 'theia-transparent-overlay'; @@ -159,6 +160,7 @@ export class WebviewWidget extends BaseWidget implements StatefulWidget { } viewType: string; + viewColumn: ViewColumn; options: WebviewPanelOptions = {}; protected ready = new Deferred(); diff --git a/packages/plugin-ext/src/main/browser/webviews-main.ts b/packages/plugin-ext/src/main/browser/webviews-main.ts index 9c2524d10b16d..d68480a85708b 100644 --- a/packages/plugin-ext/src/main/browser/webviews-main.ts +++ b/packages/plugin-ext/src/main/browser/webviews-main.ts @@ -29,6 +29,7 @@ import { JSONExt } from '@phosphor/coreutils/lib/json'; import { Mutable } from '@theia/core/lib/common/types'; import { HostedPluginSupport } from '../../hosted/browser/hosted-plugin'; import { IconUrl } from '../../common/plugin-protocol'; +import { CustomEditorWidget } from './custom-editors/custom-editor-widget'; export class WebviewsMainImpl implements WebviewsMain, Disposable { @@ -75,7 +76,7 @@ export class WebviewsMainImpl implements WebviewsMain, Disposable { this.addOrReattachWidget(view, showOptions); } - protected hookWebview(view: WebviewWidget): void { + hookWebview(view: WebviewWidget): void { const handle = view.identifier.id; this.toDispose.push(view.onDidChangeVisibility(() => this.updateViewState(view))); this.toDispose.push(view.onMessage(data => this.proxy.$onMessage(handle, data))); @@ -87,7 +88,7 @@ export class WebviewsMainImpl implements WebviewsMain, Disposable { }); } - private addOrReattachWidget(widget: WebviewWidget, showOptions: WebviewPanelShowOptions): void { + addOrReattachWidget(widget: WebviewWidget, showOptions: WebviewPanelShowOptions): void { const widgetOptions: ApplicationShell.WidgetOptions = { area: showOptions.area ? showOptions.area : 'main' }; let mode = 'open-to-right'; @@ -217,7 +218,10 @@ export class WebviewsMainImpl implements WebviewsMain, Disposable { } protected readonly updateViewStates = debounce(() => { - for (const widget of this.widgetManager.getWidgets(WebviewWidget.FACTORY_ID)) { + const widgets = this.widgetManager.getWidgets(WebviewWidget.FACTORY_ID); + const customEditors = this.widgetManager.getWidgets(CustomEditorWidget.FACTORY_ID); + + for (const widget of widgets.concat(customEditors)) { if (widget instanceof WebviewWidget) { this.updateViewState(widget); } @@ -251,7 +255,9 @@ export class WebviewsMainImpl implements WebviewsMain, Disposable { } private async tryGetWebview(id: string): Promise { - return this.widgetManager.getWidget(WebviewWidget.FACTORY_ID, { id }); + const webview = await this.widgetManager.getWidget(WebviewWidget.FACTORY_ID, { id }) + || await this.widgetManager.getWidget(CustomEditorWidget.FACTORY_ID, { id }); + return webview; } } diff --git a/packages/plugin-ext/src/main/electron-browser/plugin-ext-frontend-electron-module.ts b/packages/plugin-ext/src/main/electron-browser/plugin-ext-frontend-electron-module.ts index 063d29909eb11..2a89707ebe024 100644 --- a/packages/plugin-ext/src/main/electron-browser/plugin-ext-frontend-electron-module.ts +++ b/packages/plugin-ext/src/main/electron-browser/plugin-ext-frontend-electron-module.ts @@ -16,8 +16,10 @@ import { ContainerModule } from 'inversify'; import { WebviewWidgetFactory } from '../browser/webview/webview-widget-factory'; -import { ElectronWebviewWidgetFactory } from './webview/electron-webview-widget-factory'; +import { CustomEditorWidgetFactory } from '../browser/custom-editors/custom-editor-widget-factory'; +import { ElectronCustomEditorWidgetFactory, ElectronWebviewWidgetFactory } from './webview/electron-webview-widget-factory'; export default new ContainerModule((bind, unbind, isBound, rebind) => { rebind(WebviewWidgetFactory).toDynamicValue(ctx => new ElectronWebviewWidgetFactory(ctx.container)).inSingletonScope(); + rebind(CustomEditorWidgetFactory).toDynamicValue(ctx => new ElectronCustomEditorWidgetFactory(ctx.container)).inSingletonScope(); }); diff --git a/packages/plugin-ext/src/main/electron-browser/webview/electron-webview-widget-factory.ts b/packages/plugin-ext/src/main/electron-browser/webview/electron-webview-widget-factory.ts index 9ba5a26fde40b..7230af0eff04a 100644 --- a/packages/plugin-ext/src/main/electron-browser/webview/electron-webview-widget-factory.ts +++ b/packages/plugin-ext/src/main/electron-browser/webview/electron-webview-widget-factory.ts @@ -18,6 +18,8 @@ import { remote } from 'electron'; import { ElectronSecurityToken } from '@theia/core/lib/electron-common/electron-token'; import { WebviewWidgetFactory } from '../../browser/webview/webview-widget-factory'; import { WebviewWidgetIdentifier, WebviewWidget } from '../../browser/webview/webview'; +import { CustomEditorWidgetFactory } from '../../browser/custom-editors/custom-editor-widget-factory'; +import { CustomEditorWidget } from '../../browser/custom-editors/custom-editor-widget'; export class ElectronWebviewWidgetFactory extends WebviewWidgetFactory { @@ -42,3 +44,27 @@ export class ElectronWebviewWidgetFactory extends WebviewWidgetFactory { } } + +export class ElectronCustomEditorWidgetFactory extends CustomEditorWidgetFactory { + + async createWidget(identifier: WebviewWidgetIdentifier): Promise { + const widget = await super.createWidget(identifier); + await this.attachElectronSecurityCookie(widget.externalEndpoint); + return widget; + } + + /** + * Attach the ElectronSecurityToken to a cookie that will be sent with each webview request. + * + * @param endpoint cookie's target url + */ + protected async attachElectronSecurityCookie(endpoint: string): Promise { + await remote.session.defaultSession!.cookies.set({ + url: endpoint, + name: ElectronSecurityToken, + value: JSON.stringify(this.container.get(ElectronSecurityToken)), + httpOnly: true + }); + } + +} diff --git a/packages/plugin-ext/src/plugin/custom-editors.ts b/packages/plugin-ext/src/plugin/custom-editors.ts new file mode 100644 index 0000000000000..9419d0bb86820 --- /dev/null +++ b/packages/plugin-ext/src/plugin/custom-editors.ts @@ -0,0 +1,372 @@ +/******************************************************************************** + * Copyright (c) 2021 SAP SE or an SAP affiliate company 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 + ********************************************************************************/ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +// copied and modified from https://github.com/microsoft/vscode/blob/53eac52308c4611000a171cc7bf1214293473c78/src/vs/workbench/api/common/extHostCustomEditors.ts + +import { CustomEditorsExt, CustomEditorsMain, PLUGIN_RPC_CONTEXT } from '../common/plugin-api-rpc'; +import * as theia from '@theia/plugin'; +import { RPCProtocol } from '../common/rpc-protocol'; +import { Plugin } from '../common/plugin-api-rpc'; +import { URI } from 'vscode-uri'; +import { UriComponents } from '../common/uri-components'; +import { DocumentsExtImpl } from './documents'; +import { WebviewImpl, WebviewsExtImpl } from './webviews'; +import { CancellationToken, CancellationTokenSource } from '@theia/core/lib/common/cancellation'; +import { DisposableCollection } from '@theia/core/lib/common/disposable'; +import { Disposable } from './types-impl'; +import { WorkspaceExtImpl } from './workspace'; +import * as Converters from './type-converters'; + +export class CustomEditorsExtImpl implements CustomEditorsExt { + private readonly proxy: CustomEditorsMain; + private readonly editorProviders = new EditorProviderStore(); + private readonly documents = new CustomDocumentStore(); + + constructor(rpc: RPCProtocol, + private readonly documentExt: DocumentsExtImpl, + private readonly webviewExt: WebviewsExtImpl, + private readonly workspace: WorkspaceExtImpl) { + this.proxy = rpc.getProxy(PLUGIN_RPC_CONTEXT.CUSTOM_EDITORS_MAIN); + } + + registerCustomEditorProvider( + viewType: string, + provider: theia.CustomReadonlyEditorProvider | theia.CustomTextEditorProvider, + options: { webviewOptions?: theia.WebviewPanelOptions, supportsMultipleEditorsPerDocument?: boolean }, + plugin: Plugin + ): theia.Disposable { + const disposables = new DisposableCollection(); + if ('resolveCustomTextEditor' in provider) { + disposables.push(this.editorProviders.addTextProvider(viewType, plugin, provider)); + this.proxy.$registerTextEditorProvider(viewType, options.webviewOptions || {}, { + supportsMove: !!provider.moveCustomTextEditor, + }); + } else { + disposables.push(this.editorProviders.addCustomProvider(viewType, plugin, provider)); + + if (this.supportEditing(provider)) { + disposables.push(provider.onDidChangeCustomDocument(e => { + const entry = this.getCustomDocumentEntry(viewType, e.document.uri); + if (isEditEvent(e)) { + const editId = entry.addEdit(e); + this.proxy.$onDidEdit(e.document.uri, viewType, editId, e.label); + } else { + this.proxy.$onContentChange(e.document.uri, viewType); + } + })); + } + + this.proxy.$registerCustomEditorProvider(viewType, options.webviewOptions || {}, !!options.supportsMultipleEditorsPerDocument); + } + + return Disposable.from( + disposables, + Disposable.create(() => { + this.proxy.$unregisterEditorProvider(viewType); + }) + ); + } + + async $createCustomDocument(resource: UriComponents, viewType: string, backupId: string | undefined, cancellation: CancellationToken): Promise<{ + editable: boolean; + }> { + const entry = this.editorProviders.get(viewType); + if (!entry) { + throw new Error(`No provider found for '${viewType}'`); + } + + if (entry.type !== CustomEditorType.Custom) { + throw new Error(`Invalid provide type for '${viewType}'`); + } + + const revivedResource = URI.revive(resource); + const document = await entry.provider.openCustomDocument(revivedResource, { backupId }, cancellation); + this.documents.add(viewType, document); + + return { editable: this.supportEditing(entry.provider) }; + } + + async $disposeCustomDocument(resource: UriComponents, viewType: string): Promise { + const entry = this.editorProviders.get(viewType); + if (!entry) { + throw new Error(`No provider found for '${viewType}'`); + } + + if (entry.type !== CustomEditorType.Custom) { + throw new Error(`Invalid provider type for '${viewType}'`); + } + + const revivedResource = URI.revive(resource); + const { document } = this.getCustomDocumentEntry(viewType, revivedResource); + this.documents.delete(viewType, document); + document.dispose(); + } + + async $resolveWebviewEditor( + resource: UriComponents, + handler: string, + viewType: string, + title: string, + position: number, + options: theia.WebviewPanelOptions & theia.WebviewOptions, + cancellation: CancellationToken + ): Promise { + const entry = this.editorProviders.get(viewType); + if (!entry) { + throw new Error(`No provider found for '${viewType}'`); + } + const viewColumn = Converters.toViewColumn(position); + const panel = this.webviewExt.createWebviewPanel(viewType, title, {}, options, entry.plugin, handler); + const webviewOptions = WebviewImpl.toWebviewOptions(options, this.workspace, entry.plugin); + await this.proxy.$createCustomEditorPanel(handler, title, viewColumn, webviewOptions); + + const revivedResource = URI.revive(resource); + + switch (entry.type) { + case CustomEditorType.Custom: { + const { document } = this.getCustomDocumentEntry(viewType, revivedResource); + return entry.provider.resolveCustomEditor(document, panel, cancellation); + } + case CustomEditorType.Text: { + const document = this.documentExt.getDocument(revivedResource); + return entry.provider.resolveCustomTextEditor(document, panel, cancellation); + } + default: { + throw new Error('Unknown webview provider type'); + } + } + } + + getCustomDocumentEntry(viewType: string, resource: UriComponents): CustomDocumentStoreEntry { + const entry = this.documents.get(viewType, URI.revive(resource)); + if (!entry) { + throw new Error('No custom document found'); + } + return entry; + } + + $disposeEdits(resourceComponents: UriComponents, viewType: string, editIds: number[]): void { + const document = this.getCustomDocumentEntry(viewType, resourceComponents); + document.disposeEdits(editIds); + } + + async $onMoveCustomEditor(handle: string, newResourceComponents: UriComponents, viewType: string): Promise { + const entry = this.editorProviders.get(viewType); + if (!entry) { + throw new Error(`No provider found for '${viewType}'`); + } + + if (!(entry.provider as theia.CustomTextEditorProvider).moveCustomTextEditor) { + throw new Error(`Provider does not implement move '${viewType}'`); + } + + const webview = this.webviewExt.getWebviewPanel(handle); + if (!webview) { + throw new Error('No webview found'); + } + + const resource = URI.revive(newResourceComponents); + const document = this.documentExt.getDocument(resource); + const cancellationSource = new CancellationTokenSource(); + await (entry.provider as theia.CustomTextEditorProvider).moveCustomTextEditor!(document, webview, cancellationSource.token); + } + + async $undo(resourceComponents: UriComponents, viewType: string, editId: number, isDirty: boolean): Promise { + const entry = this.getCustomDocumentEntry(viewType, resourceComponents); + return entry.undo(editId, isDirty); + } + + async $redo(resourceComponents: UriComponents, viewType: string, editId: number, isDirty: boolean): Promise { + const entry = this.getCustomDocumentEntry(viewType, resourceComponents); + return entry.redo(editId, isDirty); + } + + async $revert(resourceComponents: UriComponents, viewType: string, cancellation: CancellationToken): Promise { + const entry = this.getCustomDocumentEntry(viewType, resourceComponents); + const provider = this.getCustomEditorProvider(viewType); + await provider.revertCustomDocument(entry.document, cancellation); + } + + async $onSave(resourceComponents: UriComponents, viewType: string, cancellation: CancellationToken): Promise { + const entry = this.getCustomDocumentEntry(viewType, resourceComponents); + const provider = this.getCustomEditorProvider(viewType); + await provider.saveCustomDocument(entry.document, cancellation); + } + + async $onSaveAs(resourceComponents: UriComponents, viewType: string, targetResource: UriComponents, cancellation: CancellationToken): Promise { + const entry = this.getCustomDocumentEntry(viewType, resourceComponents); + const provider = this.getCustomEditorProvider(viewType); + return provider.saveCustomDocumentAs(entry.document, URI.revive(targetResource), cancellation); + } + + private getCustomEditorProvider(viewType: string): theia.CustomEditorProvider { + const entry = this.editorProviders.get(viewType); + const provider = entry?.provider; + if (!provider || !this.supportEditing(provider)) { + throw new Error('Custom document is not editable'); + } + return provider; + } + + private supportEditing( + provider: theia.CustomTextEditorProvider | theia.CustomEditorProvider | theia.CustomReadonlyEditorProvider + ): provider is theia.CustomEditorProvider { + return !!(provider as theia.CustomEditorProvider).onDidChangeCustomDocument; + } +} + +function isEditEvent(e: theia.CustomDocumentContentChangeEvent | theia.CustomDocumentEditEvent): e is theia.CustomDocumentEditEvent { + return typeof (e as theia.CustomDocumentEditEvent).undo === 'function' + && typeof (e as theia.CustomDocumentEditEvent).redo === 'function'; +} + +class CustomDocumentStoreEntry { + constructor( + readonly document: theia.CustomDocument, + ) { } + + private readonly edits = new Cache('custom documents'); + + addEdit(item: theia.CustomDocumentEditEvent): number { + return this.edits.add([item]); + } + + async undo(editId: number, isDirty: boolean): Promise { + await this.getEdit(editId).undo(); + } + + async redo(editId: number, isDirty: boolean): Promise { + await this.getEdit(editId).redo(); + } + + disposeEdits(editIds: number[]): void { + for (const id of editIds) { + this.edits.delete(id); + } + } + + private getEdit(editId: number): theia.CustomDocumentEditEvent { + const edit = this.edits.get(editId, 0); + if (!edit) { + throw new Error('No edit found'); + } + return edit; + } +} + +const enum CustomEditorType { + Text, + Custom +} + +type ProviderEntry = { + readonly plugin: Plugin; + readonly type: CustomEditorType.Text; + readonly provider: theia.CustomTextEditorProvider; +} | { + readonly plugin: Plugin; + readonly type: CustomEditorType.Custom; + readonly provider: theia.CustomReadonlyEditorProvider; +}; + +class EditorProviderStore { + private readonly providers = new Map(); + + addTextProvider(viewType: string, plugin: Plugin, provider: theia.CustomTextEditorProvider): theia.Disposable { + return this.add(CustomEditorType.Text, viewType, plugin, provider); + } + + addCustomProvider(viewType: string, plugin: Plugin, provider: theia.CustomReadonlyEditorProvider): theia.Disposable { + return this.add(CustomEditorType.Custom, viewType, plugin, provider); + } + + get(viewType: string): ProviderEntry | undefined { + return this.providers.get(viewType); + } + + private add(type: CustomEditorType, viewType: string, + plugin: Plugin, provider: theia.CustomTextEditorProvider | theia.CustomReadonlyEditorProvider): theia.Disposable { + if (this.providers.has(viewType)) { + throw new Error(`Provider for viewType:${viewType} already registered`); + } + this.providers.set(viewType, { type, plugin: plugin, provider } as ProviderEntry); + return new Disposable(() => this.providers.delete(viewType)); + } +} + +class CustomDocumentStore { + private readonly documents = new Map(); + + get(viewType: string, resource: theia.Uri): CustomDocumentStoreEntry | undefined { + return this.documents.get(this.key(viewType, resource)); + } + + add(viewType: string, document: theia.CustomDocument): CustomDocumentStoreEntry { + const key = this.key(viewType, document.uri); + if (this.documents.has(key)) { + throw new Error(`Document already exists for viewType:${viewType} resource:${document.uri}`); + } + const entry = new CustomDocumentStoreEntry(document); + this.documents.set(key, entry); + return entry; + } + + delete(viewType: string, document: theia.CustomDocument): void { + const key = this.key(viewType, document.uri); + this.documents.delete(key); + } + + private key(viewType: string, resource: theia.Uri): string { + return `${viewType}@@@${resource}`; + } +} + +// copied from https://github.com/microsoft/vscode/blob/53eac52308c4611000a171cc7bf1214293473c78/src/vs/workbench/api/common/cache.ts +class Cache { + private static readonly enableDebugLogging = false; + private readonly _data = new Map(); + private _idPool = 1; + + constructor( + private readonly id: string + ) { } + + add(item: readonly T[]): number { + const id = this._idPool++; + this._data.set(id, item); + this.logDebugInfo(); + return id; + } + + get(pid: number, id: number): T | undefined { + return this._data.has(pid) ? this._data.get(pid)![id] : undefined; + } + + delete(id: number): void { + this._data.delete(id); + this.logDebugInfo(); + } + + private logDebugInfo(): void { + if (!Cache.enableDebugLogging) { + return; + } + console.log(`${this.id} cache size — ${this._data.size}`); + } +} diff --git a/packages/plugin-ext/src/plugin/plugin-context.ts b/packages/plugin-ext/src/plugin/plugin-context.ts index fc6967e9dd31e..1949c094c1191 100644 --- a/packages/plugin-ext/src/plugin/plugin-context.ts +++ b/packages/plugin-ext/src/plugin/plugin-context.ts @@ -165,6 +165,7 @@ import { LabelServiceExtImpl } from '../plugin/label-service'; import { TimelineExtImpl } from './timeline'; import { ThemingExtImpl } from './theming'; import { CommentsExtImpl } from './comments'; +import { CustomEditorsExtImpl } from './custom-editors'; export function createAPIFactory( rpc: RPCProtocol, @@ -202,6 +203,7 @@ export function createAPIFactory( const timelineExt = rpc.set(MAIN_RPC_CONTEXT.TIMELINE_EXT, new TimelineExtImpl(rpc, commandRegistry)); const themingExt = rpc.set(MAIN_RPC_CONTEXT.THEMING_EXT, new ThemingExtImpl(rpc)); const commentsExt = rpc.set(MAIN_RPC_CONTEXT.COMMENTS_EXT, new CommentsExtImpl(rpc, commandRegistry, documents)); + const customEditorExt = rpc.set(MAIN_RPC_CONTEXT.CUSTOM_EDITORS_EXT, new CustomEditorsExtImpl(rpc, documents, webviewExt, workspaceExt)); rpc.set(MAIN_RPC_CONTEXT.DEBUG_EXT, debugExt); return function (plugin: InternalPlugin): typeof theia { @@ -396,6 +398,11 @@ export function createAPIFactory( registerWebviewPanelSerializer(viewType: string, serializer: theia.WebviewPanelSerializer): theia.Disposable { return webviewExt.registerWebviewPanelSerializer(viewType, serializer, plugin); }, + registerCustomEditorProvider(viewType: string, + provider: theia.CustomTextEditorProvider | theia.CustomReadonlyEditorProvider, + options: { webviewOptions?: theia.WebviewPanelOptions, supportsMultipleEditorsPerDocument?: boolean } = {}): theia.Disposable { + return customEditorExt.registerCustomEditorProvider(viewType, provider, options, plugin); + }, get state(): theia.WindowState { return windowStateExt.getWindowState(); }, diff --git a/packages/plugin-ext/src/plugin/plugin-manager.ts b/packages/plugin-ext/src/plugin/plugin-manager.ts index 274da9e107af5..cf9a894ae22f1 100644 --- a/packages/plugin-ext/src/plugin/plugin-manager.ts +++ b/packages/plugin-ext/src/plugin/plugin-manager.ts @@ -82,7 +82,8 @@ export class PluginManagerExtImpl implements PluginManagerExt, PluginManager { 'onView', 'onUri', 'onWebviewPanel', - 'onFileSystem' + 'onFileSystem', + 'onCustomEditor' ]); private configStorage: ConfigStorage | undefined; diff --git a/packages/plugin-ext/src/plugin/webviews.ts b/packages/plugin-ext/src/plugin/webviews.ts index 76d189afc51df..b66dbca6303ad 100644 --- a/packages/plugin-ext/src/plugin/webviews.ts +++ b/packages/plugin-ext/src/plugin/webviews.ts @@ -104,13 +104,26 @@ export class WebviewsExtImpl implements WebviewsExt { options: theia.WebviewPanelOptions & theia.WebviewOptions, plugin: Plugin ): theia.WebviewPanel { + const viewId = v4(); + const webviewShowOptions = toWebviewPanelShowOptions(showOptions); + const webviewOptions = WebviewImpl.toWebviewOptions(options, this.workspace, plugin); + this.proxy.$createWebviewPanel(viewId, viewType, title, webviewShowOptions, webviewOptions); + const panel = this.createWebviewPanel(viewType, title, showOptions, options, plugin, viewId); + return panel; + } + + createWebviewPanel( + viewType: string, + title: string, + showOptions: theia.ViewColumn | theia.WebviewPanelShowOptions, + options: theia.WebviewPanelOptions & theia.WebviewOptions, + plugin: Plugin, + viewId: string + ): WebviewPanelImpl { if (!this.initData) { throw new Error('Webviews are not initialized'); } const webviewShowOptions = toWebviewPanelShowOptions(showOptions); - const viewId = v4(); - this.proxy.$createWebviewPanel(viewId, viewType, title, webviewShowOptions, WebviewImpl.toWebviewOptions(options, this.workspace, plugin)); - const webview = new WebviewImpl(viewId, this.proxy, options, this.initData, this.workspace, plugin); const panel = new WebviewPanelImpl(viewId, this.proxy, viewType, title, webviewShowOptions, options, webview); this.webviewPanels.set(viewId, panel); @@ -135,7 +148,7 @@ export class WebviewsExtImpl implements WebviewsExt { }); } - private getWebviewPanel(viewId: string): WebviewPanelImpl | undefined { + getWebviewPanel(viewId: string): WebviewPanelImpl | undefined { if (this.webviewPanels.has(viewId)) { return this.webviewPanels.get(viewId); } diff --git a/packages/plugin/src/theia-proposed.d.ts b/packages/plugin/src/theia-proposed.d.ts index fef39a9b1d105..6384e514e4675 100644 --- a/packages/plugin/src/theia-proposed.d.ts +++ b/packages/plugin/src/theia-proposed.d.ts @@ -505,6 +505,30 @@ declare module '@theia/plugin' { // #endregion + // #region Custom editor move https://github.com/microsoft/vscode/issues/86146 + // copied from https://github.com/microsoft/vscode/blob/53eac52308c4611000a171cc7bf1214293473c78/src/vs/vscode.proposed.d.ts#L986-L1007 + + // TODO: Also for custom editor + + export interface CustomTextEditorProvider { + + /** + * Handle when the underlying resource for a custom editor is renamed. + * + * This allows the webview for the editor be preserved throughout the rename. If this method is not implemented, + * Theia will destory the previous custom editor and create a replacement one. + * + * @param newDocument New text document to use for the custom editor. + * @param existingWebviewPanel Webview panel for the custom editor. + * @param token A cancellation token that indicates the result is no longer needed. + * + * @return Thenable indicating that the webview editor has been moved. + */ + moveCustomTextEditor?(newDocument: TextDocument, existingWebviewPanel: WebviewPanel, token: CancellationToken): Thenable; + } + + // #endregion + export interface ResourceLabelFormatter { scheme: string; authority?: string; diff --git a/packages/plugin/src/theia.d.ts b/packages/plugin/src/theia.d.ts index b79067993057f..a303dfe79ae76 100644 --- a/packages/plugin/src/theia.d.ts +++ b/packages/plugin/src/theia.d.ts @@ -3520,6 +3520,318 @@ declare module '@theia/plugin' { handleUri(uri: Uri): ProviderResult; } + /** + * Provider for text based custom editors. + * + * Text based custom editors use a [`TextDocument`](#TextDocument) as their data model. This considerably simplifies + * implementing a custom editor as it allows Theia to handle many common operations such as + * undo and backup. The provider is responsible for synchronizing text changes between the webview and the `TextDocument`. + */ + export interface CustomTextEditorProvider { + + /** + * Resolve a custom editor for a given text resource. + * + * This is called when a user first opens a resource for a `CustomTextEditorProvider`, or if they reopen an + * existing editor using this `CustomTextEditorProvider`. + * + * + * @param document Document for the resource to resolve. + * + * @param webviewPanel The webview panel used to display the editor UI for this resource. + * + * During resolve, the provider must fill in the initial html for the content webview panel and hook up all + * the event listeners on it that it is interested in. The provider can also hold onto the `WebviewPanel` to + * use later for example in a command. See [`WebviewPanel`](#WebviewPanel) for additional details. + * + * @param token A cancellation token that indicates the result is no longer needed. + * + * @return Thenable indicating that the custom editor has been resolved. + */ + resolveCustomTextEditor(document: TextDocument, webviewPanel: WebviewPanel, token: CancellationToken): Thenable | void; + } + + /** + * Represents a custom document used by a [`CustomEditorProvider`](#CustomEditorProvider). + * + * Custom documents are only used within a given `CustomEditorProvider`. The lifecycle of a `CustomDocument` is + * managed by Theia. When no more references remain to a `CustomDocument`, it is disposed of. + */ + interface CustomDocument { + /** + * The associated uri for this document. + */ + readonly uri: Uri; + + /** + * Dispose of the custom document. + * + * This is invoked by Theia when there are no more references to a given `CustomDocument` (for example when + * all editors associated with the document have been closed.) + */ + dispose(): void; + } + + /** + * Event triggered by extensions to signal that an edit has occurred on an [`CustomDocument`](#CustomDocument). + * + * @see [`CustomDocumentProvider.onDidChangeCustomDocument`](#CustomDocumentProvider.onDidChangeCustomDocument). + */ + interface CustomDocumentEditEvent { + + /** + * The document that the edit is for. + */ + readonly document: T; + + /** + * Undo the edit operation. + * + * This is invoked by Theia when the user undoes this edit. To implement `undo`, your + * extension should restore the document and editor to the state they were in just before this + * edit was added to Theia's internal edit stack by `onDidChangeCustomDocument`. + */ + undo(): Thenable | void; + + /** + * Redo the edit operation. + * + * This is invoked by Theia when the user redoes this edit. To implement `redo`, your + * extension should restore the document and editor to the state they were in just after this + * edit was added to Theia's internal edit stack by `onDidChangeCustomDocument`. + */ + redo(): Thenable | void; + + /** + * Display name describing the edit. + * + * This will be shown to users in the UI for undo/redo operations. + */ + readonly label?: string; + } + + /** + * Event triggered by extensions to signal to Theia that the content of a [`CustomDocument`](#CustomDocument) + * has changed. + * + * @see [`CustomDocumentProvider.onDidChangeCustomDocument`](#CustomDocumentProvider.onDidChangeCustomDocument). + */ + interface CustomDocumentContentChangeEvent { + /** + * The document that the change is for. + */ + readonly document: T; + } + + /** + * Additional information about the opening custom document. + */ + interface CustomDocumentOpenContext { + /** + * The id of the backup to restore the document from or `undefined` if there is no backup. + * + * If this is provided, your extension should restore the editor from the backup instead of reading the file + * from the user's workspace. + */ + readonly backupId?: string; + } + + /** + * Provider for readonly custom editors that use a custom document model. + * + * Custom editors use [`CustomDocument`](#CustomDocument) as their document model instead of a [`TextDocument`](#TextDocument). + * + * You should use this type of custom editor when dealing with binary files or more complex scenarios. For simple + * text based documents, use [`CustomTextEditorProvider`](#CustomTextEditorProvider) instead. + * + * @param T Type of the custom document returned by this provider. + */ + export interface CustomReadonlyEditorProvider { + + /** + * Create a new document for a given resource. + * + * `openCustomDocument` is called when the first time an editor for a given resource is opened. The opened + * document is then passed to `resolveCustomEditor` so that the editor can be shown to the user. + * + * Already opened `CustomDocument` are re-used if the user opened additional editors. When all editors for a + * given resource are closed, the `CustomDocument` is disposed of. Opening an editor at this point will + * trigger another call to `openCustomDocument`. + * + * @param uri Uri of the document to open. + * @param openContext Additional information about the opening custom document. + * @param token A cancellation token that indicates the result is no longer needed. + * + * @return The custom document. + */ + openCustomDocument(uri: Uri, openContext: CustomDocumentOpenContext, token: CancellationToken): Thenable | T; + + /** + * Resolve a custom editor for a given resource. + * + * This is called whenever the user opens a new editor for this `CustomEditorProvider`. + * + * @param document Document for the resource being resolved. + * + * @param webviewPanel The webview panel used to display the editor UI for this resource. + * + * During resolve, the provider must fill in the initial html for the content webview panel and hook up all + * the event listeners on it that it is interested in. The provider can also hold onto the `WebviewPanel` to + * use later for example in a command. See [`WebviewPanel`](#WebviewPanel) for additional details. + * + * @param token A cancellation token that indicates the result is no longer needed. + * + * @return Optional thenable indicating that the custom editor has been resolved. + */ + resolveCustomEditor(document: T, webviewPanel: WebviewPanel, token: CancellationToken): Thenable | void; + } + + /** + * A backup for an [`CustomDocument`](#CustomDocument). + */ + interface CustomDocumentBackup { + /** + * Unique identifier for the backup. + * + * This id is passed back to your extension in `openCustomDocument` when opening a custom editor from a backup. + */ + readonly id: string; + + /** + * Delete the current backup. + * + * This is called by VS Code when it is clear the current backup is no longer needed, such as when a new backup + * is made or when the file is saved. + */ + delete(): void; + } + + /** + * Additional information used to implement [`CustomEditableDocument.backup`](#CustomEditableDocument.backup). + */ + interface CustomDocumentBackupContext { + /** + * Suggested file location to write the new backup. + * + * Note that your extension is free to ignore this and use its own strategy for backup. + * + * If the editor is for a resource from the current workspace, `destination` will point to a file inside + * `ExtensionContext.storagePath`. The parent folder of `destination` may not exist, so make sure to created it + * before writing the backup to this location. + */ + readonly destination: Uri; + } + + /** + * Provider for editable custom editors that use a custom document model. + * + * Custom editors use [`CustomDocument`](#CustomDocument) as their document model instead of a [`TextDocument`](#TextDocument). + * This gives extensions full control over actions such as edit, save, and backup. + * + * You should use this type of custom editor when dealing with binary files or more complex scenarios. For simple + * text based documents, use [`CustomTextEditorProvider`](#CustomTextEditorProvider) instead. + * + * @param T Type of the custom document returned by this provider. + */ + export interface CustomEditorProvider extends CustomReadonlyEditorProvider { + /** + * Signal that an edit has occurred inside a custom editor. + * + * This event must be fired by your extension whenever an edit happens in a custom editor. An edit can be + * anything from changing some text, to cropping an image, to reordering a list. Your extension is free to + * define what an edit is and what data is stored on each edit. + * + * Firing `onDidChange` causes Theia to mark the editors as being dirty. This is cleared when the user either + * saves or reverts the file. + * + * Editors that support undo/redo must fire a `CustomDocumentEditEvent` whenever an edit happens. This allows + * users to undo and redo the edit using Theia's standard Theia keyboard shortcuts. Theia will also mark + * the editor as no longer being dirty if the user undoes all edits to the last saved state. + * + * Editors that support editing but cannot use Theia's standard undo/redo mechanism must fire a `CustomDocumentContentChangeEvent`. + * The only way for a user to clear the dirty state of an editor that does not support undo/redo is to either + * `save` or `revert` the file. + * + * An editor should only ever fire `CustomDocumentEditEvent` events, or only ever fire `CustomDocumentContentChangeEvent` events. + */ + readonly onDidChangeCustomDocument: Event> | Event>; + + /** + * Save a custom document. + * + * This method is invoked by Theia when the user saves a custom editor. This can happen when the user + * triggers save while the custom editor is active, by commands such as `save all`, or by auto save if enabled. + * + * To implement `save`, the implementer must persist the custom editor. This usually means writing the + * file data for the custom document to disk. After `save` completes, any associated editor instances will + * no longer be marked as dirty. + * + * @param document Document to save. + * @param cancellation Token that signals the save is no longer required (for example, if another save was triggered). + * + * @return Thenable signaling that saving has completed. + */ + saveCustomDocument(document: T, cancellation: CancellationToken): Thenable; + + /** + * Save a custom document to a different location. + * + * This method is invoked by Theia when the user triggers 'save as' on a custom editor. The implementer must + * persist the custom editor to `destination`. + * + * When the user accepts save as, the current editor is be replaced by an non-dirty editor for the newly saved file. + * + * @param document Document to save. + * @param destination Location to save to. + * @param cancellation Token that signals the save is no longer required. + * + * @return Thenable signaling that saving has completed. + */ + saveCustomDocumentAs(document: T, destination: Uri, cancellation: CancellationToken): Thenable; + + /** + * Revert a custom document to its last saved state. + * + * This method is invoked by Theia when the user triggers `File: Revert File` in a custom editor. (Note that + * this is only used using Theia's `File: Revert File` command and not on a `git revert` of the file). + * + * To implement `revert`, the implementer must make sure all editor instances (webviews) for `document` + * are displaying the document in the same state is saved in. This usually means reloading the file from the + * workspace. + * + * @param document Document to revert. + * @param cancellation Token that signals the revert is no longer required. + * + * @return Thenable signaling that the change has completed. + */ + revertCustomDocument(document: T, cancellation: CancellationToken): Thenable; + + + /** + * Back up a dirty custom document. + * + * Backups are used for hot exit and to prevent data loss. Your `backup` method should persist the resource in + * its current state, i.e. with the edits applied. Most commonly this means saving the resource to disk in + * the `ExtensionContext.storagePath`. When VS Code reloads and your custom editor is opened for a resource, + * your extension should first check to see if any backups exist for the resource. If there is a backup, your + * extension should load the file contents from there instead of from the resource in the workspace. + * + * `backup` is triggered approximately one second after the user stops editing the document. If the user + * rapidly edits the document, `backup` will not be invoked until the editing stops. + * + * `backup` is not invoked when `auto save` is enabled (since auto save already persists the resource). + * + * @param document Document to backup. + * @param context Information that can be used to backup the document. + * @param cancellation Token that signals the current backup since a new backup is coming in. It is up to your + * extension to decided how to respond to cancellation. If for example your extension is backing up a large file + * in an operation that takes time to complete, your extension may decide to finish the ongoing backup rather + * than cancelling it to ensure that VS Code has some valid backup. + */ + backupCustomDocument(document: T, context: CustomDocumentBackupContext, cancellation: CancellationToken): Thenable; + + } + /** * Common namespace for dealing with window and editor, showing messages and user input. */ @@ -3854,6 +4166,44 @@ declare module '@theia/plugin' { */ export function registerWebviewPanelSerializer(viewType: string, serializer: WebviewPanelSerializer): Disposable; + + /** + * Register a provider for custom editors for the `viewType` contributed by the `customEditors` extension point. + * + * When a custom editor is opened, Theia fires an `onCustomEditor:viewType` activation event. Your extension + * must register a [`CustomTextEditorProvider`](#CustomTextEditorProvider), [`CustomReadonlyEditorProvider`](#CustomReadonlyEditorProvider), + * [`CustomEditorProvider`](#CustomEditorProvider)for `viewType` as part of activation. + * + * @param viewType Unique identifier for the custom editor provider. This should match the `viewType` from the + * `customEditors` contribution point. + * @param provider Provider that resolves custom editors. + * @param options Options for the provider. + * + * @return Disposable that unregisters the provider. + */ + export function registerCustomEditorProvider(viewType: string, provider: CustomTextEditorProvider | CustomReadonlyEditorProvider | CustomEditorProvider, options?: { + /** + * Content settings for the webview panels created for this custom editor. + */ + readonly webviewOptions?: WebviewPanelOptions; + + /** + * Only applies to `CustomReadonlyEditorProvider | CustomEditorProvider`. + * + * Indicates that the provider allows multiple editor instances to be open at the same time for + * the same resource. + * + * By default, Theia only allows one editor instance to be open at a time for each resource. If the + * user tries to open a second editor instance for the resource, the first one is instead moved to where + * the second one was to be opened. + * + * When `supportsMultipleEditorsPerDocument` is enabled, users can split and create copies of the custom + * editor. In this case, the custom editor must make sure it can properly synchronize the states of all + * editor instances for a resource so that they are consistent. + */ + readonly supportsMultipleEditorsPerDocument?: boolean; + }): Disposable; + /** * Represents the current window's state. * diff --git a/packages/workspace/src/browser/workspace-commands.ts b/packages/workspace/src/browser/workspace-commands.ts index bdaf25ac6b9eb..92095008d848a 100644 --- a/packages/workspace/src/browser/workspace-commands.ts +++ b/packages/workspace/src/browser/workspace-commands.ts @@ -217,16 +217,7 @@ export class WorkspaceCommandContribution implements CommandContribution { } registerCommands(registry: CommandRegistry): void { - this.openerService.getOpeners().then(openers => { - for (const opener of openers) { - const openWithCommand = WorkspaceCommands.FILE_OPEN_WITH(opener); - registry.registerCommand(openWithCommand, this.newUriAwareCommandHandler({ - execute: uri => opener.open(uri), - isEnabled: uri => opener.canHandle(uri) > 0, - isVisible: uri => opener.canHandle(uri) > 0 && this.areMultipleOpenHandlersPresent(openers, uri) - })); - } - }); + this.registerOpenWith(registry); registry.registerCommand(WorkspaceCommands.NEW_FILE, this.newWorkspaceRootUriAwareCommandHandler({ execute: uri => this.getDirectory(uri).then(parent => { if (parent) { @@ -347,6 +338,24 @@ export class WorkspaceCommandContribution implements CommandContribution { }); } + openers: OpenHandler[]; + protected async registerOpenWith(registry: CommandRegistry): Promise { + if (this.openerService.onDidChangeOpeners) { + this.openerService.onDidChangeOpeners(async e => { + this.openers = await this.openerService.getOpeners(); + }); + } + const openers = await this.openerService.getOpeners(); + for (const opener of openers) { + const openWithCommand = WorkspaceCommands.FILE_OPEN_WITH(opener); + registry.registerCommand(openWithCommand, this.newUriAwareCommandHandler({ + execute: uri => opener.open(uri), + isEnabled: uri => opener.canHandle(uri) > 0, + isVisible: uri => opener.canHandle(uri) > 0 && this.areMultipleOpenHandlersPresent(this.openers, uri) + })); + } + } + protected newUriAwareCommandHandler(handler: UriCommandHandler): UriAwareCommandHandler { return UriAwareCommandHandler.MonoSelect(this.selectionService, handler); }