From 1553f70ee2d268ca1e51c8e3b631377c368b963a Mon Sep 17 00:00:00 2001 From: Vladimir Piskarev Date: Mon, 13 May 2024 12:48:53 +0300 Subject: [PATCH 1/7] Fix loading of webview resources that depend on query params Some resource url (notably git) use the query part of the url to store additional information. The query part of the url was incorrectly being dropped while attempting to load these resources inside of webviews. See also https://github.com/microsoft/vscode/commit/48387dfc3d691558404cff1ea2582e3862a40080 --- .../src/main/browser/webview/pre/service-worker.js | 3 ++- packages/plugin-ext/src/main/browser/webview/webview.ts | 9 +++++---- packages/plugin-ext/src/plugin/webviews.ts | 2 +- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/plugin-ext/src/main/browser/webview/pre/service-worker.js b/packages/plugin-ext/src/main/browser/webview/pre/service-worker.js index c39cc3fd92828..e3a1da7cc82f7 100644 --- a/packages/plugin-ext/src/main/browser/webview/pre/service-worker.js +++ b/packages/plugin-ext/src/main/browser/webview/pre/service-worker.js @@ -226,7 +226,8 @@ async function processResourceRequest(event, requestUrl, resourceRoot) { parentClient.postMessage({ channel: 'load-resource', - path: resourcePath + path: resourcePath, + query: requestUrl.search.replace(/^\?/, '') }); return resourceRequestStore.create(webviewId, resourcePath) diff --git a/packages/plugin-ext/src/main/browser/webview/webview.ts b/packages/plugin-ext/src/main/browser/webview/webview.ts index f841c5be90a15..543ad934b88ee 100644 --- a/packages/plugin-ext/src/main/browser/webview/webview.ts +++ b/packages/plugin-ext/src/main/browser/webview/webview.ts @@ -352,7 +352,7 @@ export class WebviewWidget extends BaseWidget implements StatefulWidget, Extract /* no-op: webview loses focus only if another element gains focus in the main window */ })); this.toHide.push(this.on(WebviewMessageChannels.doReload, () => this.reload())); - this.toHide.push(this.on(WebviewMessageChannels.loadResource, (entry: any) => this.loadResource(entry.path))); + this.toHide.push(this.on(WebviewMessageChannels.loadResource, (entry: any) => this.loadResource(entry.path, entry.query))); this.toHide.push(this.on(WebviewMessageChannels.loadLocalhost, (entry: any) => this.loadLocalhost(entry.origin) )); @@ -544,10 +544,11 @@ export class WebviewWidget extends BaseWidget implements StatefulWidget, Extract return undefined; } - protected async loadResource(requestPath: string): Promise { - const normalizedUri = this.normalizeRequestUri(requestPath); + protected async loadResource(requestPath: string, requestQuery: string = ''): Promise { + const normalizedUri = this.normalizeRequestUri(requestPath).withQuery(decodeURIComponent(requestQuery)); // browser cache does not support file scheme, normalize to current endpoint scheme and host - const cacheUrl = new Endpoint({ path: normalizedUri.path.toString() }).getRestUrl().toString(); + // use requestPath rather than normalizedUri.path to preserve the scheme of the requested resource as a path segment + const cacheUrl = new Endpoint({ path: requestPath }).getRestUrl().withQuery(decodeURIComponent(requestQuery)).toString(); try { if (this.contentOptions.localResourceRoots) { diff --git a/packages/plugin-ext/src/plugin/webviews.ts b/packages/plugin-ext/src/plugin/webviews.ts index e9aff70fb9bb8..d910fe8beda02 100644 --- a/packages/plugin-ext/src/plugin/webviews.ts +++ b/packages/plugin-ext/src/plugin/webviews.ts @@ -255,7 +255,7 @@ export class WebviewImpl implements theia.Webview { .replace('{{authority}}', resource.authority) .replace('{{path}}', resource.path.replace(/^\//, '')) .replace('{{uuid}}', this.origin ?? this.viewId); - return URI.parse(uri); + return URI.parse(uri).with({ query: resource.query }); } get cspSource(): string { From 097405c99e64df9c26194e6e2861aee0f1de7e06 Mon Sep 17 00:00:00 2001 From: Vladimir Piskarev Date: Fri, 24 May 2024 23:00:39 +0300 Subject: [PATCH 2/7] Fix `Error: Unknown Webview` messages in the log --- packages/plugin-ext/src/main/browser/webviews-main.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/plugin-ext/src/main/browser/webviews-main.ts b/packages/plugin-ext/src/main/browser/webviews-main.ts index 862010101dbf1..c282979ec12d6 100644 --- a/packages/plugin-ext/src/main/browser/webviews-main.ts +++ b/packages/plugin-ext/src/main/browser/webviews-main.ts @@ -191,7 +191,12 @@ export class WebviewsMainImpl implements WebviewsMain, Disposable { // eslint-disable-next-line @typescript-eslint/no-explicit-any async $postMessage(handle: string, value: any): Promise { - const webview = await this.getWebview(handle); + // Due to async nature of $postMessage, the webview may have been disposed in the meantime. + // Therefore, don't throw an error if the webview is not found, but return false in this case. + const webview = await this.tryGetWebview(handle); + if (!webview) { + return false; + } webview.sendMessage(value); return true; } From 91113b8adf7b0a349fe9c63ebda523638845f894 Mon Sep 17 00:00:00 2001 From: Vladimir Piskarev Date: Fri, 24 May 2024 23:04:02 +0300 Subject: [PATCH 3/7] Fix design and implementation issues surrounding `CustomEditorOpener` This commit ensures that the promise returned by `CustomEditorOpener.open` will only resolve to a properly initialized and opened `CustomEditorWidget`. In particular, it ensures that the widget is opened according to the specified `WidgetOpenerOptions`, including `widgetOptions.ref` and `mode`. Essentially, it revises the work done in #9671 and #10580 to fix #9670 and #10583. --- .../core/src/browser/widget-open-handler.ts | 5 +- .../plugin-ext/src/common/plugin-api-rpc.ts | 3 +- .../custom-editors/custom-editor-opener.tsx | 60 ++++++++------ .../custom-editors/custom-editors-main.ts | 81 +++---------------- .../plugin-custom-editor-registry.ts | 28 ++++--- .../src/main/browser/webview/webview.ts | 2 - .../plugin-ext/src/plugin/custom-editors.ts | 18 ++--- .../plugin-ext/src/plugin/plugin-context.ts | 2 +- 8 files changed, 77 insertions(+), 122 deletions(-) diff --git a/packages/core/src/browser/widget-open-handler.ts b/packages/core/src/browser/widget-open-handler.ts index 7e3e4c37fab07..25802c6359869 100644 --- a/packages/core/src/browser/widget-open-handler.ts +++ b/packages/core/src/browser/widget-open-handler.ts @@ -24,7 +24,10 @@ import { WidgetManager } from './widget-manager'; export type WidgetOpenMode = 'open' | 'reveal' | 'activate'; /** - * `WidgetOpenerOptions` define serializable generic options used by the {@link WidgetOpenHandler}. + * `WidgetOpenerOptions` define generic options used by the {@link WidgetOpenHandler}. + * + * _Note:_ This object may contain references to widgets (e.g. `widgetOptions.ref`); + * these need to be transformed before it can be serialized. */ export interface WidgetOpenerOptions extends OpenerOptions { /** diff --git a/packages/plugin-ext/src/common/plugin-api-rpc.ts b/packages/plugin-ext/src/common/plugin-api-rpc.ts index fedab10b40ec8..0e6c0570041ea 100644 --- a/packages/plugin-ext/src/common/plugin-api-rpc.ts +++ b/packages/plugin-ext/src/common/plugin-api-rpc.ts @@ -1882,7 +1882,7 @@ export interface CustomEditorsExt { newWebviewHandle: string, viewType: string, title: string, - widgetOpenerOptions: object | undefined, + position: number, options: theia.WebviewPanelOptions, cancellation: CancellationToken): Promise; $createCustomDocument(resource: UriComponents, viewType: string, openContext: theia.CustomDocumentOpenContext, cancellation: CancellationToken): Promise<{ editable: boolean }>; @@ -1905,7 +1905,6 @@ 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, widgetOpenerOptions: object | undefined, options: theia.WebviewPanelOptions & theia.WebviewOptions): Promise; $onDidEdit(resource: UriComponents, viewType: string, editId: number, label: string | undefined): void; $onContentChange(resource: UriComponents, viewType: string): void; } 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 index 838c3ad08b162..00532f7a2bc61 100644 --- 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 @@ -14,11 +14,11 @@ // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 // ***************************************************************************** -import { inject } from '@theia/core/shared/inversify'; import URI from '@theia/core/lib/common/uri'; -import { ApplicationShell, OpenHandler, Widget, WidgetManager, WidgetOpenerOptions } from '@theia/core/lib/browser'; +import { ApplicationShell, OpenHandler, WidgetManager, WidgetOpenerOptions } from '@theia/core/lib/browser'; import { CustomEditor, CustomEditorPriority, CustomEditorSelector } from '../../../common'; import { CustomEditorWidget } from './custom-editor-widget'; +import { PluginCustomEditorRegistry } from './plugin-custom-editor-registry'; import { generateUuid } from '@theia/core/lib/common/uuid'; import { Emitter } from '@theia/core'; import { match } from '@theia/core/lib/common/glob'; @@ -33,8 +33,9 @@ export class CustomEditorOpener implements OpenHandler { constructor( private readonly editor: CustomEditor, - @inject(ApplicationShell) protected readonly shell: ApplicationShell, - @inject(WidgetManager) protected readonly widgetManager: WidgetManager + protected readonly shell: ApplicationShell, + protected readonly widgetManager: WidgetManager, + protected readonly editorRegistry: PluginCustomEditorRegistry ) { this.id = CustomEditorOpener.toCustomEditorId(this.editor.viewType); this.label = this.editor.displayName; @@ -62,31 +63,44 @@ export class CustomEditorOpener implements OpenHandler { } protected readonly pendingWidgetPromises = new Map>(); - async open(uri: URI, options?: WidgetOpenerOptions): Promise { + async open(uri: URI, options?: WidgetOpenerOptions): 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 uriString = uri.toString(); - let widgetPromise = this.pendingWidgetPromises.get(uriString); - if (!widgetPromise) { + let shouldNotify = false; + const uriString = uri.toString(); + let widgetPromise = this.pendingWidgetPromises.get(uriString); + if (widgetPromise) { + widget = await widgetPromise; + } else { + const widgets = this.widgetManager.getWidgets(CustomEditorWidget.FACTORY_ID) as CustomEditorWidget[]; + widget = widgets.find(w => w.viewType === this.editor.viewType && w.resource.toString() === uriString); + if (!widget) { + shouldNotify = true; const id = generateUuid(); - widgetPromise = this.widgetManager.getOrCreateWidget(CustomEditorWidget.FACTORY_ID, { id }); + widgetPromise = this.widgetManager.getOrCreateWidget(CustomEditorWidget.FACTORY_ID, { id }).then(async w => { + try { + w.viewType = this.editor.viewType; + w.resource = uri; + await this.editorRegistry.resolveWidget(w); + await this.shell.addWidget(w, options?.widgetOptions); + return w; + } catch (e) { + w.dispose(); + throw e; + } + }).finally(() => this.pendingWidgetPromises.delete(uriString)); this.pendingWidgetPromises.set(uriString, widgetPromise); widget = await widgetPromise; - this.pendingWidgetPromises.delete(uriString); - widget.viewType = this.editor.viewType; - widget.resource = uri; - this.onDidOpenCustomEditorEmitter.fire([widget, options]); } } + const mode = options?.mode ?? 'activate'; + if (mode === 'activate') { + await this.shell.activateWidget(widget.id); + } else if (mode === 'reveal') { + await this.shell.revealWidget(widget.id); + } + if (shouldNotify) { + this.onDidOpenCustomEditorEmitter.fire([widget, options]); + } return widget; } 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 index 95e0dcad4ba39..ef5c2d7d60bf4 100644 --- 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 @@ -24,7 +24,6 @@ import { MAIN_RPC_CONTEXT, CustomEditorsMain, CustomEditorsExt, CustomTextEditor 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 '@theia/core/shared/vscode-uri'; @@ -39,11 +38,9 @@ import { FileService } from '@theia/filesystem/lib/browser/file-service'; import { UndoRedoService } from '@theia/editor/lib/browser/undo-redo-service'; import { WebviewsMainImpl } from '../webviews-main'; import { WidgetManager } from '@theia/core/lib/browser/widget-manager'; -import { ApplicationShell, DefaultUriLabelProviderContribution, Saveable, SaveOptions, WidgetOpenerOptions } from '@theia/core/lib/browser'; -import { WebviewOptions, WebviewPanelOptions, WebviewPanelShowOptions } from '@theia/plugin'; -import { WebviewWidgetIdentifier } from '../webview/webview'; +import { ApplicationShell, LabelProvider, Saveable, SaveOptions } from '@theia/core/lib/browser'; +import { WebviewPanelOptions } from '@theia/plugin'; import { EditorPreferences } from '@theia/editor/lib/browser'; -import { ViewColumn, WebviewPanelTargetArea } from '../../../plugin/types-impl'; const enum CustomEditorModelType { Custom, @@ -58,7 +55,7 @@ export class CustomEditorsMainImpl implements CustomEditorsMain, Disposable { protected readonly customEditorService: CustomEditorService; protected readonly undoRedoService: UndoRedoService; protected readonly customEditorRegistry: PluginCustomEditorRegistry; - protected readonly labelProvider: DefaultUriLabelProviderContribution; + protected readonly labelProvider: LabelProvider; protected readonly widgetManager: WidgetManager; protected readonly editorPreferences: EditorPreferences; private readonly proxy: CustomEditorsExt; @@ -75,7 +72,7 @@ export class CustomEditorsMainImpl implements CustomEditorsMain, Disposable { this.customEditorService = container.get(CustomEditorService); this.undoRedoService = container.get(UndoRedoService); this.customEditorRegistry = container.get(PluginCustomEditorRegistry); - this.labelProvider = container.get(DefaultUriLabelProviderContribution); + this.labelProvider = container.get(LabelProvider); this.editorPreferences = container.get(EditorPreferences); this.widgetManager = container.get(WidgetManager); this.proxy = rpc.getProxy(MAIN_RPC_CONTEXT.CUSTOM_EDITORS_EXT); @@ -111,7 +108,8 @@ export class CustomEditorsMainImpl implements CustomEditorsMain, Disposable { const disposables = new DisposableCollection(); disposables.push( - this.customEditorRegistry.registerResolver(viewType, async (widget, widgetOpenerOptions) => { + this.customEditorRegistry.registerResolver(viewType, async widget => { + const { resource, identifier } = widget; widget.options = options; @@ -144,13 +142,16 @@ export class CustomEditorsMainImpl implements CustomEditorsMain, Disposable { }); } + this.webviewsMain.hookWebview(widget); + widget.title.label = this.labelProvider.getName(resource); + const _cancellationSource = new CancellationTokenSource(); await this.proxy.$resolveWebviewEditor( resource.toComponents(), identifier.id, viewType, - this.labelProvider.getName(resource)!, - widgetOpenerOptions, + widget.title.label, + widget.viewState.position, options, _cancellationSource.token ); @@ -213,66 +214,6 @@ export class CustomEditorsMainImpl implements CustomEditorsMain, Disposable { const model = await this.getCustomEditorModel(resourceComponents, viewType); model.changeContent(); } - - async $createCustomEditorPanel( - panelId: string, - title: string, - widgetOpenerOptions: WidgetOpenerOptions | undefined, - 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, enableForms, localResourceRoots, ...contentOptions } = options; - view.viewColumn = ViewColumn.One; // behaviour might be overridden later using widgetOpenerOptions (if available) - view.options = { enableFindWidget, retainContextWhenHidden }; - view.setContentOptions({ - allowScripts: enableScripts, - allowForms: enableForms, - localResourceRoots: localResourceRoots && localResourceRoots.map(root => root.toString()), - ...contentOptions, - ...view.contentOptions - }); - if (view.isAttached) { - if (view.isVisible) { - this.shell.revealWidget(view.id); - } - return; - } - const showOptions: WebviewPanelShowOptions = { - preserveFocus: true - }; - - if (widgetOpenerOptions) { - if (widgetOpenerOptions.mode === 'reveal') { - showOptions.preserveFocus = false; - } - - if (widgetOpenerOptions.widgetOptions) { - let area: WebviewPanelTargetArea; - switch (widgetOpenerOptions.widgetOptions.area) { - case 'main': - area = WebviewPanelTargetArea.Main; - case 'left': - area = WebviewPanelTargetArea.Left; - case 'right': - area = WebviewPanelTargetArea.Right; - case 'bottom': - area = WebviewPanelTargetArea.Bottom; - default: // includes 'top' and 'secondaryWindow' - area = WebviewPanelTargetArea.Main; - } - showOptions.area = area; - - if (widgetOpenerOptions.widgetOptions.mode === 'split-right' || - widgetOpenerOptions.widgetOptions.mode === 'open-to-right') { - showOptions.viewColumn = ViewColumn.Beside; - } - } - } - - this.webviewsMain.addOrReattachWidget(view, showOptions); - } } export interface CustomEditorModel extends Saveable, Disposable { 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 index 50bfdb287f8a0..7250e3f4c0908 100644 --- 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 @@ -17,16 +17,17 @@ import { injectable, inject, postConstruct } from '@theia/core/shared/inversify'; import { CustomEditor, DeployedPlugin } from '../../../common'; import { Disposable, DisposableCollection } from '@theia/core/lib/common/disposable'; +import { Deferred } from '@theia/core/lib/common/promise-util'; import { CustomEditorOpener } from './custom-editor-opener'; import { Emitter } from '@theia/core'; -import { ApplicationShell, DefaultOpenerService, OpenWithService, WidgetManager, WidgetOpenerOptions } from '@theia/core/lib/browser'; +import { ApplicationShell, DefaultOpenerService, OpenWithService, 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 pendingEditors = new Map, disposable: Disposable }>(); + private readonly resolvers = new Map Promise>(); private readonly onWillOpenCustomEditorEmitter = new Emitter(); readonly onWillOpenCustomEditor = this.onWillOpenCustomEditorEmitter.event; @@ -74,7 +75,8 @@ export class PluginCustomEditorRegistry { const editorOpenHandler = new CustomEditorOpener( editor, this.shell, - this.widgetManager + this.widgetManager, + this ); toDispose.push(this.defaultOpenerService.addHandler(editorOpenHandler)); toDispose.push( @@ -86,30 +88,30 @@ export class PluginCustomEditorRegistry { open: uri => editorOpenHandler.open(uri) }) ); - toDispose.push( - editorOpenHandler.onDidOpenCustomEditor(event => this.resolveWidget(event[0], event[1])) - ); return toDispose; } - resolveWidget = (widget: CustomEditorWidget, options?: WidgetOpenerOptions) => { + async resolveWidget(widget: CustomEditorWidget): Promise { const resolver = this.resolvers.get(widget.viewType); if (resolver) { - resolver(widget, options); + await resolver(widget); } else { - this.pendingEditors.add(widget); + const deferred = new Deferred(); + const disposable = widget.onDidDispose(() => this.pendingEditors.delete(widget)); + this.pendingEditors.set(widget, { deferred, disposable }); this.onWillOpenCustomEditorEmitter.fire(widget.viewType); + return deferred.promise; } }; - registerResolver(viewType: string, resolver: (widget: CustomEditorWidget, options?: WidgetOpenerOptions) => void): Disposable { + registerResolver(viewType: string, resolver: (widget: CustomEditorWidget) => Promise): Disposable { if (this.resolvers.has(viewType)) { throw new Error(`Resolver for ${viewType} already registered`); } - for (const editorWidget of this.pendingEditors) { + for (const [editorWidget, { deferred, disposable }] of this.pendingEditors.entries()) { if (editorWidget.viewType === viewType) { - resolver(editorWidget); + resolver(editorWidget).then(() => deferred.resolve(), err => deferred.reject(err)).finally(() => disposable.dispose()); this.pendingEditors.delete(editorWidget); } } diff --git a/packages/plugin-ext/src/main/browser/webview/webview.ts b/packages/plugin-ext/src/main/browser/webview/webview.ts index 543ad934b88ee..9ed8585bfc91d 100644 --- a/packages/plugin-ext/src/main/browser/webview/webview.ts +++ b/packages/plugin-ext/src/main/browser/webview/webview.ts @@ -48,7 +48,6 @@ import { isFirefox } from '@theia/core/lib/browser/browser'; 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'; import { ExtractableWidget } from '@theia/core/lib/browser/widgets/extractable-widget'; import { BadgeWidget } from '@theia/core/lib/browser/view-container'; import { MenuPath } from '@theia/core'; @@ -185,7 +184,6 @@ export class WebviewWidget extends BaseWidget implements StatefulWidget, Extract } viewType: string; - viewColumn: ViewColumn; options: WebviewPanelOptions = {}; protected ready = new Deferred(); diff --git a/packages/plugin-ext/src/plugin/custom-editors.ts b/packages/plugin-ext/src/plugin/custom-editors.ts index 4ec88ce886fe9..700151a37303d 100644 --- a/packages/plugin-ext/src/plugin/custom-editors.ts +++ b/packages/plugin-ext/src/plugin/custom-editors.ts @@ -25,11 +25,11 @@ import { RPCProtocol } from '../common/rpc-protocol'; import { Disposable, URI } from './types-impl'; import { UriComponents } from '../common/uri-components'; import { DocumentsExtImpl } from './documents'; -import { WebviewImpl, WebviewsExtImpl } from './webviews'; +import { WebviewsExtImpl } from './webviews'; import { CancellationToken, CancellationTokenSource } from '@theia/core/lib/common/cancellation'; import { DisposableCollection } from '@theia/core/lib/common/disposable'; -import { WorkspaceExtImpl } from './workspace'; import { Cache } from '../common/cache'; +import * as Converters from './type-converters'; export class CustomEditorsExtImpl implements CustomEditorsExt { private readonly proxy: CustomEditorsMain; @@ -38,8 +38,7 @@ export class CustomEditorsExtImpl implements CustomEditorsExt { constructor(rpc: RPCProtocol, private readonly documentExt: DocumentsExtImpl, - private readonly webviewExt: WebviewsExtImpl, - private readonly workspace: WorkspaceExtImpl) { + private readonly webviewExt: WebviewsExtImpl) { this.proxy = rpc.getProxy(PLUGIN_RPC_CONTEXT.CUSTOM_EDITORS_MAIN); } @@ -116,22 +115,21 @@ export class CustomEditorsExtImpl implements CustomEditorsExt { document.dispose(); } - async $resolveWebviewEditor( + async $resolveWebviewEditor( resource: UriComponents, handler: string, viewType: string, title: string, - widgetOpenerOptions: object | undefined, - options: theia.WebviewPanelOptions & theia.WebviewOptions, + position: number, + options: theia.WebviewPanelOptions, cancellation: CancellationToken ): Promise { const entry = this.editorProviders.get(viewType); if (!entry) { throw new Error(`No provider found for '${viewType}'`); } - const panel = this.webviewExt.createWebviewPanel(viewType, title, {}, options, entry.plugin, handler, false); - const webviewOptions = WebviewImpl.toWebviewOptions(options, this.workspace, entry.plugin); - await this.proxy.$createCustomEditorPanel(handler, title, widgetOpenerOptions, webviewOptions); + const viewColumn = Converters.toViewColumn(position); + const panel = this.webviewExt.createWebviewPanel(viewType, title, { viewColumn }, options, entry.plugin, handler, false); const revivedResource = URI.revive(resource); diff --git a/packages/plugin-ext/src/plugin/plugin-context.ts b/packages/plugin-ext/src/plugin/plugin-context.ts index 1a66df549a33c..d28ddecd0d203 100644 --- a/packages/plugin-ext/src/plugin/plugin-context.ts +++ b/packages/plugin-ext/src/plugin/plugin-context.ts @@ -319,7 +319,7 @@ export function createAPIFactory( 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 tabsExt = rpc.set(MAIN_RPC_CONTEXT.TABS_EXT, new TabsExtImpl(rpc)); - const customEditorExt = rpc.set(MAIN_RPC_CONTEXT.CUSTOM_EDITORS_EXT, new CustomEditorsExtImpl(rpc, documents, webviewExt, workspaceExt)); + const customEditorExt = rpc.set(MAIN_RPC_CONTEXT.CUSTOM_EDITORS_EXT, new CustomEditorsExtImpl(rpc, documents, webviewExt)); const webviewViewsExt = rpc.set(MAIN_RPC_CONTEXT.WEBVIEW_VIEWS_EXT, new WebviewViewsExtImpl(rpc, webviewExt)); const telemetryExt = rpc.set(MAIN_RPC_CONTEXT.TELEMETRY_EXT, new TelemetryExtImpl()); const testingExt = rpc.set(MAIN_RPC_CONTEXT.TESTING_EXT, new TestingExtImpl(rpc, commandRegistry)); From 14719f83a3e84b615b44b7b5b0757c3b6d3aa2c5 Mon Sep 17 00:00:00 2001 From: Vladimir Piskarev Date: Wed, 26 Jun 2024 19:04:15 +0300 Subject: [PATCH 4/7] Restore custom editors as part of layout Fixes an incorrect assumption that a custom editor cannot be restored if no `WebviewPanelSerializer` is registered for its view type. (Actually, custom editors are created and restored using a custom editor provider.) Also, ensures that `CustomEditorWidget.modelRef` satisfies the shape for the `CustomEditorWidget` defined in `editor.ts` and cannot return `undefined`. (However, `CustomEditorWidget.modelRef.object` can be `undefined` until the custom editor is resolved.) Fixes #10787 --- .../src/browser/monaco-editor-service.ts | 2 +- .../src/hosted/browser/hosted-plugin.ts | 3 +- .../custom-editors/custom-editor-widget.ts | 28 +++++++++++++------ 3 files changed, 22 insertions(+), 11 deletions(-) diff --git a/packages/monaco/src/browser/monaco-editor-service.ts b/packages/monaco/src/browser/monaco-editor-service.ts index 0138443dabcf3..98d459616aeba 100644 --- a/packages/monaco/src/browser/monaco-editor-service.ts +++ b/packages/monaco/src/browser/monaco-editor-service.ts @@ -67,7 +67,7 @@ export class MonacoEditorService extends StandaloneCodeEditorService { 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) { + if (model?.editorTextModel instanceof MonacoEditorModel) { editor = MonacoEditor.findByDocument(this.editors, model.editorTextModel)[0]; } } diff --git a/packages/plugin-ext/src/hosted/browser/hosted-plugin.ts b/packages/plugin-ext/src/hosted/browser/hosted-plugin.ts index 913fa538f1a47..8e38f165ea4fa 100644 --- a/packages/plugin-ext/src/hosted/browser/hosted-plugin.ts +++ b/packages/plugin-ext/src/hosted/browser/hosted-plugin.ts @@ -209,7 +209,8 @@ export class HostedPluginSupport extends AbstractHostedPluginSupport this.activateByNotebookRenderer(rendererId)); this.widgets.onDidCreateWidget(({ factoryId, widget }) => { - if ((factoryId === WebviewWidget.FACTORY_ID || factoryId === CustomEditorWidget.FACTORY_ID) && widget instanceof WebviewWidget) { + // note: state restoration of custom editors is handled in `PluginCustomEditorRegistry.init` + if (factoryId === WebviewWidget.FACTORY_ID && widget instanceof WebviewWidget) { const storeState = widget.storeState.bind(widget); const restoreState = widget.restoreState.bind(widget); 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 index 7d277a44565b5..2e60988f4dc2f 100644 --- 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 @@ -17,29 +17,35 @@ import { injectable, inject, postConstruct } from '@theia/core/shared/inversify'; import URI from '@theia/core/lib/common/uri'; import { FileOperation } from '@theia/filesystem/lib/common/files'; -import { ApplicationShell, NavigatableWidget, Saveable, SaveableSource, SaveOptions } from '@theia/core/lib/browser'; +import { ApplicationShell, DelegatingSaveable, NavigatableWidget, Saveable, SaveableSource, SaveOptions } from '@theia/core/lib/browser'; import { SaveableService } from '@theia/core/lib/browser/saveable-service'; import { Reference } from '@theia/core/lib/common/reference'; import { WebviewWidget } from '../webview/webview'; import { CustomEditorModel } from './custom-editors-main'; +import { CustomEditorWidget as CustomEditorWidgetShape } from '@theia/editor/lib/browser'; @injectable() -export class CustomEditorWidget extends WebviewWidget implements SaveableSource, NavigatableWidget { +export class CustomEditorWidget extends WebviewWidget implements CustomEditorWidgetShape, SaveableSource, NavigatableWidget { static override FACTORY_ID = 'plugin-custom-editor'; override id: string; resource: URI; - protected _modelRef: Reference; - get modelRef(): Reference { + protected _modelRef: Reference = { object: undefined, dispose: () => { } }; + get modelRef(): Reference { return this._modelRef; } set modelRef(modelRef: Reference) { + this._modelRef.dispose(); this._modelRef = modelRef; + this.delegatingSaveable.delegate = modelRef.object; this.doUpdateContent(); } + + // ensures that saveable is available even if modelRef.object is undefined + protected readonly delegatingSaveable = new DelegatingSaveable(); get saveable(): Saveable { - return this._modelRef.object; + return this.delegatingSaveable; } @inject(ApplicationShell) @@ -68,13 +74,17 @@ export class CustomEditorWidget extends WebviewWidget implements SaveableSource, } async save(options?: SaveOptions): Promise { - await this._modelRef.object.saveCustomEditor(options); + if (this._modelRef.object) { + 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; + if (this._modelRef.object) { + const result = await this._modelRef.object.saveCustomEditorAs(source, target, options); + this.doMove(target); + return result; + } } getResourceUri(): URI | undefined { From c8d1b950b445c314dd1b743f5344fb887be690e6 Mon Sep 17 00:00:00 2001 From: Vladimir Piskarev Date: Tue, 9 Jul 2024 21:06:33 +0300 Subject: [PATCH 5/7] Fix a race condition when file system provider is activated When file system provider is activated, wait until it is registered. --- packages/core/src/common/event.ts | 6 ++++++ packages/filesystem/src/browser/file-service.ts | 4 ++++ .../plugin-ext/src/hosted/browser/hosted-plugin.ts | 11 ++++++++--- 3 files changed, 18 insertions(+), 3 deletions(-) diff --git a/packages/core/src/common/event.ts b/packages/core/src/common/event.ts index 00d6e0d00eab5..94912d7616455 100644 --- a/packages/core/src/common/event.ts +++ b/packages/core/src/common/event.ts @@ -89,6 +89,12 @@ export namespace Event { return new Promise(resolve => once(event)(resolve)); } + export function filter(event: Event, predicate: (e: T) => unknown): Event; + export function filter(event: Event, predicate: (e: T) => e is S): Event; + export function filter(event: Event, predicate: (e: T) => unknown): Event { + return (listener, thisArg, disposables) => event(e => predicate(e) && listener.call(thisArg, e), undefined, disposables); + } + /** * Given an event and a `map` function, returns another event which maps each element * through the mapping function. diff --git a/packages/filesystem/src/browser/file-service.ts b/packages/filesystem/src/browser/file-service.ts index f237cd7560bc5..3945bf64787ee 100644 --- a/packages/filesystem/src/browser/file-service.ts +++ b/packages/filesystem/src/browser/file-service.ts @@ -419,6 +419,10 @@ export class FileService { return activation; } + hasProvider(scheme: string): boolean { + return this.providers.has(scheme); + } + /** * Tests if the service (i.e. any of its registered {@link FileSystemProvider}s) can handle the given resource. * @param resource `URI` of the resource to test. diff --git a/packages/plugin-ext/src/hosted/browser/hosted-plugin.ts b/packages/plugin-ext/src/hosted/browser/hosted-plugin.ts index 8e38f165ea4fa..43fadf44f7e3a 100644 --- a/packages/plugin-ext/src/hosted/browser/hosted-plugin.ts +++ b/packages/plugin-ext/src/hosted/browser/hosted-plugin.ts @@ -40,10 +40,10 @@ import { WorkspaceService } from '@theia/workspace/lib/browser'; import { PluginContributionHandler } from '../../main/browser/plugin-contribution-handler'; import { getQueryParameters } from '../../main/browser/env-main'; import { getPreferences } from '../../main/browser/preference-registry-main'; -import { Deferred } from '@theia/core/lib/common/promise-util'; +import { Deferred, waitForEvent } from '@theia/core/lib/common/promise-util'; import { DebugSessionManager } from '@theia/debug/lib/browser/debug-session-manager'; import { DebugConfigurationManager } from '@theia/debug/lib/browser/debug-configuration-manager'; -import { WaitUntilEvent } from '@theia/core/lib/common/event'; +import { Event, WaitUntilEvent } from '@theia/core/lib/common/event'; import { FileSearchService } from '@theia/file-search/lib/common/file-search-service'; import { FrontendApplicationStateService } from '@theia/core/lib/browser/frontend-application-state'; import { PluginViewRegistry } from '../../main/browser/view/plugin-view-registry'; @@ -449,7 +449,12 @@ export class HostedPluginSupport extends AbstractHostedPluginSupport { + if (!this.fileService.hasProvider(event.scheme)) { + return waitForEvent(Event.filter(this.fileService.onDidChangeFileSystemProviderRegistrations, + ({ added, scheme }) => added && scheme === event.scheme), 3000); + } + })); } protected ensureCommandHandlerRegistration(event: WillExecuteCommandEvent): void { From 5392e2dd5d7f621b2c15a77994c1c141348ef0a8 Mon Sep 17 00:00:00 2001 From: Vladimir Piskarev Date: Sun, 7 Jul 2024 19:36:24 +0300 Subject: [PATCH 6/7] git: add support for custom editors * Uses `OpenerService` instead of `EditorManager` to open editors * Contributes a `FileSystemProvider` for git-resources * Fixes an issue with getting blob contents --- packages/git/src/browser/git-contribution.ts | 9 +- .../browser/git-file-service-contribution.ts | 33 ++++++++ .../src/browser/git-file-system-provider.ts | 84 +++++++++++++++++++ .../git/src/browser/git-frontend-module.ts | 7 ++ .../browser/git-repository-provider.spec.ts | 3 +- packages/git/src/browser/git-resource.ts | 15 ++++ .../git/src/browser/git-scm-provider.spec.ts | 5 +- packages/git/src/browser/git-scm-provider.ts | 19 +++-- packages/git/src/node/dugite-git.ts | 6 +- 9 files changed, 170 insertions(+), 11 deletions(-) create mode 100644 packages/git/src/browser/git-file-service-contribution.ts create mode 100644 packages/git/src/browser/git-file-system-provider.ts diff --git a/packages/git/src/browser/git-contribution.ts b/packages/git/src/browser/git-contribution.ts index de36bd4d7788a..ad01af1e2567f 100644 --- a/packages/git/src/browser/git-contribution.ts +++ b/packages/git/src/browser/git-contribution.ts @@ -23,9 +23,10 @@ import { MenuAction, MenuContribution, MenuModelRegistry, + MessageService, Mutable } from '@theia/core'; -import { codicon, DiffUris, Widget } from '@theia/core/lib/browser'; +import { codicon, DiffUris, Widget, open, OpenerService } from '@theia/core/lib/browser'; import { TabBarToolbarContribution, TabBarToolbarItem, @@ -281,6 +282,8 @@ export class GitContribution implements CommandContribution, MenuContribution, T protected toDispose = new DisposableCollection(); + @inject(OpenerService) protected openerService: OpenerService; + @inject(MessageService) protected messageService: MessageService; @inject(EditorManager) protected readonly editorManager: EditorManager; @inject(GitQuickOpenService) protected readonly quickOpenService: GitQuickOpenService; @inject(GitRepositoryTracker) protected readonly repositoryTracker: GitRepositoryTracker; @@ -562,7 +565,9 @@ export class GitContribution implements CommandContribution, MenuContribution, T registry.registerCommand(GIT_COMMANDS.OPEN_CHANGED_FILE, { execute: (...arg: ScmResource[]) => { for (const resource of arg) { - this.editorManager.open(resource.sourceUri, { mode: 'reveal' }); + open(this.openerService, resource.sourceUri, { mode: 'reveal' }).catch(e => { + this.messageService.error(e.message); + }); } } }); diff --git a/packages/git/src/browser/git-file-service-contribution.ts b/packages/git/src/browser/git-file-service-contribution.ts new file mode 100644 index 0000000000000..7719eca95a4c7 --- /dev/null +++ b/packages/git/src/browser/git-file-service-contribution.ts @@ -0,0 +1,33 @@ +// ***************************************************************************** +// Copyright (C) 2024 1C-Soft LLC and others. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0. +// +// This Source Code may also be made available under the following Secondary +// Licenses when the conditions for such availability set forth in the Eclipse +// Public License v. 2.0 are satisfied: GNU General Public License, version 2 +// with the GNU Classpath Exception which is available at +// https://www.gnu.org/software/classpath/license.html. +// +// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 +// ***************************************************************************** + +import { interfaces } from '@theia/core/shared/inversify'; +import { FileService, FileServiceContribution } from '@theia/filesystem/lib/browser/file-service'; +import { GitFileSystemProvider } from './git-file-system-provider'; +import { GIT_RESOURCE_SCHEME } from './git-resource'; + +export class GitFileServiceContribution implements FileServiceContribution { + + constructor(protected readonly container: interfaces.Container) { } + + registerFileSystemProviders(service: FileService): void { + service.onWillActivateFileSystemProvider(event => { + if (event.scheme === GIT_RESOURCE_SCHEME) { + service.registerProvider(GIT_RESOURCE_SCHEME, this.container.get(GitFileSystemProvider)); + } + }); + } +} diff --git a/packages/git/src/browser/git-file-system-provider.ts b/packages/git/src/browser/git-file-system-provider.ts new file mode 100644 index 0000000000000..113aeee6d16c7 --- /dev/null +++ b/packages/git/src/browser/git-file-system-provider.ts @@ -0,0 +1,84 @@ +// ***************************************************************************** +// Copyright (C) 2024 1C-Soft LLC and others. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0. +// +// This Source Code may also be made available under the following Secondary +// Licenses when the conditions for such availability set forth in the Eclipse +// Public License v. 2.0 are satisfied: GNU General Public License, version 2 +// with the GNU Classpath Exception which is available at +// https://www.gnu.org/software/classpath/license.html. +// +// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 +// ***************************************************************************** + +import { inject, injectable } from '@theia/core/shared/inversify'; +import { Event, URI, Disposable } from '@theia/core'; +import { + FileChange, + FileDeleteOptions, + FileOverwriteOptions, + FileSystemProvider, + FileSystemProviderCapabilities, + FileType, + FileWriteOptions, + Stat, + WatchOptions +} from '@theia/filesystem/lib/common/files'; +import { GitResourceResolver } from './git-resource-resolver'; +import { EncodingService } from '@theia/core/lib/common/encoding-service'; + +@injectable() +export class GitFileSystemProvider implements FileSystemProvider { + + readonly capabilities = FileSystemProviderCapabilities.Readonly | + FileSystemProviderCapabilities.FileReadWrite | FileSystemProviderCapabilities.PathCaseSensitive; + + readonly onDidChangeCapabilities: Event = Event.None; + readonly onDidChangeFile: Event = Event.None; + readonly onFileWatchError: Event = Event.None; + + @inject(GitResourceResolver) + protected readonly resourceResolver: GitResourceResolver; + + @inject(EncodingService) + protected readonly encodingService: EncodingService; + + watch(resource: URI, opts: WatchOptions): Disposable { + return Disposable.NULL; + } + + async stat(resource: URI): Promise { + const gitResource = await this.resourceResolver.getResource(resource); + const size = await gitResource.getSize(); + return { type: FileType.File, mtime: 0, ctime: 0, size }; + } + + async readFile(resource: URI): Promise { + const gitResource = await this.resourceResolver.getResource(resource); + const contents = await gitResource.readContents({ encoding: 'binary' }); + return this.encodingService.encode(contents, { encoding: 'binary', hasBOM: false }).buffer; + } + + writeFile(resource: URI, content: Uint8Array, opts: FileWriteOptions): Promise { + throw new Error('Method not implemented.'); + } + + mkdir(resource: URI): Promise { + throw new Error('Method not implemented.'); + } + + readdir(resource: URI): Promise<[string, FileType][]> { + throw new Error('Method not implemented.'); + } + + delete(resource: URI, opts: FileDeleteOptions): Promise { + throw new Error('Method not implemented.'); + } + + rename(from: URI, to: URI, opts: FileOverwriteOptions): Promise { + throw new Error('Method not implemented.'); + } +} diff --git a/packages/git/src/browser/git-frontend-module.ts b/packages/git/src/browser/git-frontend-module.ts index 132f15aa2b745..3c10f70a8b732 100644 --- a/packages/git/src/browser/git-frontend-module.ts +++ b/packages/git/src/browser/git-frontend-module.ts @@ -43,6 +43,9 @@ import { ScmHistorySupport } from '@theia/scm-extra/lib/browser/history/scm-hist import { ScmHistoryProvider } from '@theia/scm-extra/lib/browser/history'; import { GitHistorySupport } from './history/git-history-support'; import { GitDecorationProvider } from './git-decoration-provider'; +import { GitFileSystemProvider } from './git-file-system-provider'; +import { GitFileServiceContribution } from './git-file-service-contribution'; +import { FileServiceContribution } from '@theia/filesystem/lib/browser/file-service'; export default new ContainerModule(bind => { bindGitPreferences(bind); @@ -75,6 +78,10 @@ export default new ContainerModule(bind => { bind(GitSyncService).toSelf().inSingletonScope(); bind(GitErrorHandler).toSelf().inSingletonScope(); + + bind(GitFileSystemProvider).toSelf().inSingletonScope(); + bind(GitFileServiceContribution).toDynamicValue(ctx => new GitFileServiceContribution(ctx.container)).inSingletonScope(); + bind(FileServiceContribution).toService(GitFileServiceContribution); }); export function createGitScmProviderFactory(ctx: interfaces.Context): GitScmProvider.Factory { diff --git a/packages/git/src/browser/git-repository-provider.spec.ts b/packages/git/src/browser/git-repository-provider.spec.ts index 01bd5ba328b08..3c940636e6a19 100644 --- a/packages/git/src/browser/git-repository-provider.spec.ts +++ b/packages/git/src/browser/git-repository-provider.spec.ts @@ -26,7 +26,7 @@ import { DugiteGit } from '../node/dugite-git'; import { WorkspaceService } from '@theia/workspace/lib/browser/workspace-service'; import { FileStat, FileChangesEvent } from '@theia/filesystem/lib/common/files'; import { Emitter, CommandService, Disposable } from '@theia/core'; -import { LocalStorageService, StorageService, LabelProvider } from '@theia/core/lib/browser'; +import { LocalStorageService, StorageService, LabelProvider, OpenerService } from '@theia/core/lib/browser'; import { GitRepositoryProvider } from './git-repository-provider'; import * as sinon from 'sinon'; import * as chai from 'chai'; @@ -97,6 +97,7 @@ describe('GitRepositoryProvider', () => { testContainer.bind(ScmContextKeyService).toSelf().inSingletonScope(); testContainer.bind(ContextKeyService).to(ContextKeyServiceDummyImpl).inSingletonScope(); testContainer.bind(GitCommitMessageValidator).toSelf().inSingletonScope(); + testContainer.bind(OpenerService).toConstantValue({}); testContainer.bind(EditorManager).toConstantValue({}); testContainer.bind(GitErrorHandler).toConstantValue({}); testContainer.bind(CommandService).toConstantValue({}); diff --git a/packages/git/src/browser/git-resource.ts b/packages/git/src/browser/git-resource.ts index 7389da945c13a..3d74f3dfb28d7 100644 --- a/packages/git/src/browser/git-resource.ts +++ b/packages/git/src/browser/git-resource.ts @@ -36,5 +36,20 @@ export class GitResource implements Resource { return ''; } + async getSize(): Promise { + if (this.repository) { + const path = Repository.relativePath(this.repository, this.uri.withScheme('file'))?.toString(); + if (path) { + const commitish = this.uri.query || 'index'; + const args = commitish !== 'index' ? ['ls-tree', '--format=%(objectsize)', commitish, path] : ['ls-files', '--format=%(objectsize)', '--', path]; + const size = (await this.git.exec(this.repository, args)).stdout.split('\n').filter(line => !!line.trim())[0]; + if (size) { + return parseInt(size); + } + } + } + return 0; + } + dispose(): void { } } diff --git a/packages/git/src/browser/git-scm-provider.spec.ts b/packages/git/src/browser/git-scm-provider.spec.ts index 4458af4af177a..a3255b6b63ffb 100644 --- a/packages/git/src/browser/git-scm-provider.spec.ts +++ b/packages/git/src/browser/git-scm-provider.spec.ts @@ -21,7 +21,7 @@ import { FrontendApplicationConfigProvider } from '@theia/core/lib/browser/front FrontendApplicationConfigProvider.set({}); import { CommandService, Disposable, ILogger, MessageService } from '@theia/core'; -import { LabelProvider } from '@theia/core/lib/browser'; +import { LabelProvider, OpenerService } from '@theia/core/lib/browser'; import { FileUri } from '@theia/core/lib/node'; import { Container } from '@theia/core/shared/inversify'; import { EditorManager } from '@theia/editor/lib/browser'; @@ -46,6 +46,7 @@ disableJSDOM(); describe('GitScmProvider', () => { let testContainer: Container; + let mockOpenerService: OpenerService; let mockEditorManager: EditorManager; let mockGitErrorHandler: GitErrorHandler; let mockFileService: FileService; @@ -65,6 +66,7 @@ describe('GitScmProvider', () => { }); beforeEach(async () => { + mockOpenerService = {} as OpenerService; mockEditorManager = sinon.createStubInstance(EditorManager); mockGitErrorHandler = sinon.createStubInstance(GitErrorHandler); mockFileService = sinon.createStubInstance(FileService); @@ -73,6 +75,7 @@ describe('GitScmProvider', () => { mockLabelProvider = sinon.createStubInstance(LabelProvider); testContainer = new Container(); + testContainer.bind(OpenerService).toConstantValue(mockOpenerService); testContainer.bind(EditorManager).toConstantValue(mockEditorManager); testContainer.bind(GitErrorHandler).toConstantValue(mockGitErrorHandler); testContainer.bind(FileService).toConstantValue(mockFileService); diff --git a/packages/git/src/browser/git-scm-provider.ts b/packages/git/src/browser/git-scm-provider.ts index 0eb2d33c95096..c2b51109c6b70 100644 --- a/packages/git/src/browser/git-scm-provider.ts +++ b/packages/git/src/browser/git-scm-provider.ts @@ -16,6 +16,7 @@ import { injectable, inject, postConstruct } from '@theia/core/shared/inversify'; import URI from '@theia/core/lib/common/uri'; +import { open, OpenerService } from '@theia/core/lib/browser'; import { DiffUris } from '@theia/core/lib/browser/diff-uris'; import { Emitter } from '@theia/core'; import { DisposableCollection } from '@theia/core/lib/common/disposable'; @@ -64,6 +65,9 @@ export class GitScmProvider implements ScmProvider { this.onDidChangeStatusBarCommandsEmitter ); + @inject(OpenerService) + protected openerService: OpenerService; + @inject(EditorManager) protected readonly editorManager: EditorManager; @@ -223,9 +227,12 @@ export class GitScmProvider implements ScmProvider { async open(change: GitFileChange, options?: EditorOpenerOptions): Promise { const uriToOpen = this.getUriToOpen(change); - await this.editorManager.open(uriToOpen, options); + await open(this.openerService, uriToOpen, options); } + // note: the implementation has to ensure that `GIT_RESOURCE_SCHEME` URIs it returns either directly or within a diff-URI always have a query; + // as an example of an issue that can otherwise arise, the VS Code `media-preview` plugin is known to mangle resource URIs without the query: + // https://github.com/microsoft/vscode/blob/6eaf6487a4d8301b981036bfa53976546eb6694f/extensions/media-preview/src/imagePreview/index.ts#L205-L209 getUriToOpen(change: GitFileChange): URI { const changeUri: URI = new URI(change.uri); const fromFileUri = change.oldUri ? new URI(change.oldUri) : changeUri; // set oldUri on renamed and copied @@ -233,14 +240,14 @@ export class GitScmProvider implements ScmProvider { if (change.staged) { return changeUri.withScheme(GIT_RESOURCE_SCHEME).withQuery('HEAD'); } else { - return changeUri.withScheme(GIT_RESOURCE_SCHEME); + return changeUri.withScheme(GIT_RESOURCE_SCHEME).withQuery('index'); } } if (change.status !== GitFileStatus.New) { if (change.staged) { return DiffUris.encode( fromFileUri.withScheme(GIT_RESOURCE_SCHEME).withQuery('HEAD'), - changeUri.withScheme(GIT_RESOURCE_SCHEME), + changeUri.withScheme(GIT_RESOURCE_SCHEME).withQuery('index'), nls.localize( 'theia/git/tabTitleIndex', '{0} (Index)', @@ -249,7 +256,7 @@ export class GitScmProvider implements ScmProvider { } if (this.stagedChanges.find(c => c.uri === change.uri)) { return DiffUris.encode( - fromFileUri.withScheme(GIT_RESOURCE_SCHEME), + fromFileUri.withScheme(GIT_RESOURCE_SCHEME).withQuery('index'), changeUri, nls.localize( 'theia/git/tabTitleWorkingTree', @@ -270,11 +277,11 @@ export class GitScmProvider implements ScmProvider { )); } if (change.staged) { - return changeUri.withScheme(GIT_RESOURCE_SCHEME); + return changeUri.withScheme(GIT_RESOURCE_SCHEME).withQuery('index'); } if (this.stagedChanges.find(c => c.uri === change.uri)) { return DiffUris.encode( - changeUri.withScheme(GIT_RESOURCE_SCHEME), + changeUri.withScheme(GIT_RESOURCE_SCHEME).withQuery('index'), changeUri, nls.localize( 'theia/git/tabTitleWorkingTree', diff --git a/packages/git/src/node/dugite-git.ts b/packages/git/src/node/dugite-git.ts index 857c757b12ba6..8fe73bc453039 100644 --- a/packages/git/src/node/dugite-git.ts +++ b/packages/git/src/node/dugite-git.ts @@ -48,6 +48,8 @@ import { GitExecProvider } from './git-exec-provider'; import { GitEnvProvider } from './env/git-env-provider'; import { GitInit } from './init/git-init'; +import upath = require('upath'); + /** * Parsing and converting raw Git output into Git model instances. */ @@ -548,7 +550,9 @@ export class DugiteGit implements Git { const path = this.getFsPath(uri); const [exec, env] = await Promise.all([this.execProvider.exec(), this.gitEnv.promise]); if (encoding === 'binary') { - return (await getBlobContents(repositoryPath, commitish, path, { exec, env })).toString(); + // note: contrary to what its jsdoc says, getBlobContents expects a (normalized) relative path + const relativePath = upath.normalizeSafe(Path.relative(repositoryPath, path)); + return (await getBlobContents(repositoryPath, commitish, relativePath, { exec, env })).toString('binary'); } return (await getTextContents(repositoryPath, commitish, path, { exec, env })).toString(); } From 90e693207fcc13fc233522f82deede8f852de61d Mon Sep 17 00:00:00 2001 From: Vladimir Piskarev Date: Sun, 7 Jul 2024 21:19:52 +0300 Subject: [PATCH 7/7] custom editor: open a diff-uri in a side-by-side editor `CustomEditorOpener` is now able to open a diff-uri in a side-by-side editor, which contains the corresponding `CustomEditor`s. Fixes #9079 --- packages/core/src/browser/saveable.ts | 66 ++++++- packages/core/src/browser/style/index.css | 1 + .../core/src/browser/style/split-widget.css | 38 ++++ packages/core/src/browser/widgets/index.ts | 1 + .../core/src/browser/widgets/split-widget.ts | 163 ++++++++++++++++++ .../custom-editors/custom-editor-opener.tsx | 93 ++++++++-- .../custom-editors/custom-editor-widget.ts | 9 +- .../browser/plugin-ext-frontend-module.ts | 25 ++- 8 files changed, 376 insertions(+), 20 deletions(-) create mode 100644 packages/core/src/browser/style/split-widget.css create mode 100644 packages/core/src/browser/widgets/split-widget.ts diff --git a/packages/core/src/browser/saveable.ts b/packages/core/src/browser/saveable.ts index ecb4728a1c955..e1a413b3fab0e 100644 --- a/packages/core/src/browser/saveable.ts +++ b/packages/core/src/browser/saveable.ts @@ -21,7 +21,7 @@ import { MaybePromise } from '../common/types'; import { Key } from './keyboard/keys'; import { AbstractDialog } from './dialogs'; import { nls } from '../common/nls'; -import { DisposableCollection, isObject } from '../common'; +import { Disposable, DisposableCollection, isObject } from '../common'; import { BinaryBuffer } from '../common/buffer'; export type AutoSaveMode = 'off' | 'afterDelay' | 'onFocusChange' | 'onWindowChange'; @@ -112,6 +112,70 @@ export class DelegatingSaveable implements Saveable { } +export class CompositeSaveable implements Saveable { + protected isDirty = false; + protected readonly onDirtyChangedEmitter = new Emitter(); + protected readonly onContentChangedEmitter = new Emitter(); + protected readonly toDispose = new DisposableCollection(this.onDirtyChangedEmitter, this.onContentChangedEmitter); + protected readonly saveablesMap = new Map(); + + get dirty(): boolean { + return this.isDirty; + } + + get onDirtyChanged(): Event { + return this.onDirtyChangedEmitter.event; + } + + get onContentChanged(): Event { + return this.onContentChangedEmitter.event; + } + + async save(options?: SaveOptions): Promise { + await Promise.all(this.saveables.map(saveable => saveable.save(options))); + } + + get saveables(): readonly Saveable[] { + return Array.from(this.saveablesMap.keys()); + } + + add(saveable: Saveable): void { + if (this.saveablesMap.has(saveable)) { + return; + } + const toDispose = new DisposableCollection(); + this.toDispose.push(toDispose); + this.saveablesMap.set(saveable, toDispose); + toDispose.push(Disposable.create(() => { + this.saveablesMap.delete(saveable); + })); + toDispose.push(saveable.onDirtyChanged(() => { + const wasDirty = this.isDirty; + this.isDirty = this.saveables.some(s => s.dirty); + if (this.isDirty !== wasDirty) { + this.onDirtyChangedEmitter.fire(); + } + })); + toDispose.push(saveable.onContentChanged(() => { + this.onContentChangedEmitter.fire(); + })); + if (saveable.dirty && !this.isDirty) { + this.isDirty = true; + this.onDirtyChangedEmitter.fire(); + } + } + + remove(saveable: Saveable): boolean { + const toDispose = this.saveablesMap.get(saveable); + toDispose?.dispose(); + return !!toDispose; + } + + dispose(): void { + this.toDispose.dispose(); + } +} + export namespace Saveable { export interface RevertOptions { /** diff --git a/packages/core/src/browser/style/index.css b/packages/core/src/browser/style/index.css index 137b5bfb7d4d5..3a69dc7c2c1c6 100644 --- a/packages/core/src/browser/style/index.css +++ b/packages/core/src/browser/style/index.css @@ -350,3 +350,4 @@ button.secondary[disabled], @import "./progress-bar.css"; @import "./breadcrumbs.css"; @import "./tooltip.css"; +@import "./split-widget.css"; diff --git a/packages/core/src/browser/style/split-widget.css b/packages/core/src/browser/style/split-widget.css new file mode 100644 index 0000000000000..2b18734fb2bea --- /dev/null +++ b/packages/core/src/browser/style/split-widget.css @@ -0,0 +1,38 @@ +/******************************************************************************** + * Copyright (C) 2024 1C-Soft LLC and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 + ********************************************************************************/ + +.theia-split-widget > .p-SplitPanel { + height: 100%; + width: 100%; + outline: none; +} + +.theia-split-widget > .p-SplitPanel > .p-SplitPanel-child { + min-width: 50px; + min-height: var(--theia-content-line-height); +} + +.theia-split-widget > .p-SplitPanel > .p-SplitPanel-handle { + box-sizing: border-box; +} + +.theia-split-widget > .p-SplitPanel[data-orientation="horizontal"] > .p-SplitPanel-handle { + border-left: var(--theia-border-width) solid var(--theia-sideBarSectionHeader-border); +} + +.theia-split-widget > .p-SplitPanel[data-orientation="vertical"] > .p-SplitPanel-handle { + border-top: var(--theia-border-width) solid var(--theia-sideBarSectionHeader-border); +} diff --git a/packages/core/src/browser/widgets/index.ts b/packages/core/src/browser/widgets/index.ts index a8539dea88602..48ef9cf5ca42e 100644 --- a/packages/core/src/browser/widgets/index.ts +++ b/packages/core/src/browser/widgets/index.ts @@ -18,3 +18,4 @@ export * from './widget'; export * from './react-renderer'; export * from './react-widget'; export * from './extractable-widget'; +export * from './split-widget'; diff --git a/packages/core/src/browser/widgets/split-widget.ts b/packages/core/src/browser/widgets/split-widget.ts new file mode 100644 index 0000000000000..0c1e5dd3d4269 --- /dev/null +++ b/packages/core/src/browser/widgets/split-widget.ts @@ -0,0 +1,163 @@ +// ***************************************************************************** +// Copyright (C) 2024 1C-Soft LLC and others. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0. +// +// This Source Code may also be made available under the following Secondary +// Licenses when the conditions for such availability set forth in the Eclipse +// Public License v. 2.0 are satisfied: GNU General Public License, version 2 +// with the GNU Classpath Exception which is available at +// https://www.gnu.org/software/classpath/license.html. +// +// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 +// ***************************************************************************** + +import { Emitter } from 'vscode-languageserver-protocol'; +import { ApplicationShell, StatefulWidget } from '../shell'; +import { BaseWidget, Message, PanelLayout, SplitPanel, Widget } from './widget'; +import { CompositeSaveable, Saveable, SaveableSource } from '../saveable'; +import { Navigatable } from '../navigatable-types'; +import { URI } from '../../common'; + +/** + * A widget containing a number of panes in a split layout. + */ +export class SplitWidget extends BaseWidget implements ApplicationShell.TrackableWidgetProvider, SaveableSource, Navigatable, StatefulWidget { + + protected readonly splitPanel: SplitPanel; + + protected readonly onDidChangeTrackableWidgetsEmitter = new Emitter(); + readonly onDidChangeTrackableWidgets = this.onDidChangeTrackableWidgetsEmitter.event; + + protected readonly compositeSaveable = new CompositeSaveable(); + + protected navigatable?: Navigatable; + + constructor(options?: SplitPanel.IOptions & { navigatable?: Navigatable }) { + super(); + + this.toDispose.pushAll([this.onDidChangeTrackableWidgetsEmitter]); + + this.addClass('theia-split-widget'); + + const layout = new PanelLayout(); + this.layout = layout; + const that = this; + this.splitPanel = new class extends SplitPanel { + + protected override onChildAdded(msg: Widget.ChildMessage): void { + super.onChildAdded(msg); + that.onPaneAdded(msg.child); + } + + protected override onChildRemoved(msg: Widget.ChildMessage): void { + super.onChildRemoved(msg); + that.onPaneRemoved(msg.child); + } + }({ + spacing: 1, // --theia-border-width + ...options + }); + this.splitPanel.node.tabIndex = -1; + layout.addWidget(this.splitPanel); + + this.navigatable = options?.navigatable; + } + + get orientation(): SplitPanel.Orientation { + return this.splitPanel.orientation; + } + + set orientation(value: SplitPanel.Orientation) { + this.splitPanel.orientation = value; + } + + relativeSizes(): number[] { + return this.splitPanel.relativeSizes(); + } + + setRelativeSizes(sizes: number[]): void { + this.splitPanel.setRelativeSizes(sizes); + } + + get handles(): readonly HTMLDivElement[] { + return this.splitPanel.handles; + } + + get saveable(): Saveable { + return this.compositeSaveable; + } + + getResourceUri(): URI | undefined { + return this.navigatable?.getResourceUri(); + } + + createMoveToUri(resourceUri: URI): URI | undefined { + return this.navigatable?.createMoveToUri(resourceUri); + } + + storeState(): SplitWidget.State { + return { orientation: this.orientation, widgets: this.panes, relativeSizes: this.relativeSizes() }; + } + + restoreState(oldState: SplitWidget.State): void { + const { orientation, widgets, relativeSizes } = oldState; + if (orientation) { + this.orientation = orientation; + } + for (const widget of widgets) { + this.addPane(widget); + } + if (relativeSizes) { + this.setRelativeSizes(relativeSizes); + } + } + + get panes(): readonly Widget[] { + return this.splitPanel.widgets; + } + + getTrackableWidgets(): Widget[] { + return [...this.panes]; + } + + protected fireDidChangeTrackableWidgets(): void { + this.onDidChangeTrackableWidgetsEmitter.fire(this.getTrackableWidgets()); + } + + addPane(pane: Widget): void { + this.splitPanel.addWidget(pane); + } + + insertPane(index: number, pane: Widget): void { + this.splitPanel.insertWidget(index, pane); + } + + protected onPaneAdded(pane: Widget): void { + if (Saveable.isSource(pane)) { + this.compositeSaveable.add(pane.saveable); + } + this.fireDidChangeTrackableWidgets(); + } + + protected onPaneRemoved(pane: Widget): void { + if (Saveable.isSource(pane)) { + this.compositeSaveable.remove(pane.saveable); + } + this.fireDidChangeTrackableWidgets(); + } + + protected override onActivateRequest(msg: Message): void { + this.splitPanel.node.focus(); + } +} + +export namespace SplitWidget { + export interface State { + orientation?: SplitPanel.Orientation; + widgets: readonly Widget[]; // note: don't rename this property; it has special meaning for `ShellLayoutRestorer` + relativeSizes?: number[]; + } +} 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 index 00532f7a2bc61..3671fd12b2687 100644 --- 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 @@ -15,12 +15,12 @@ // ***************************************************************************** import URI from '@theia/core/lib/common/uri'; -import { ApplicationShell, OpenHandler, WidgetManager, WidgetOpenerOptions } from '@theia/core/lib/browser'; +import { ApplicationShell, DiffUris, OpenHandler, SplitWidget, Widget, WidgetManager, WidgetOpenerOptions } from '@theia/core/lib/browser'; import { CustomEditor, CustomEditorPriority, CustomEditorSelector } from '../../../common'; import { CustomEditorWidget } from './custom-editor-widget'; import { PluginCustomEditorRegistry } from './plugin-custom-editor-registry'; import { generateUuid } from '@theia/core/lib/common/uuid'; -import { Emitter } from '@theia/core'; +import { DisposableCollection, Emitter } from '@theia/core'; import { match } from '@theia/core/lib/common/glob'; export class CustomEditorOpener implements OpenHandler { @@ -46,7 +46,13 @@ export class CustomEditorOpener implements OpenHandler { } canHandle(uri: URI): number { - if (this.matches(this.editor.selector, uri)) { + const { selector } = this.editor; + if (DiffUris.isDiffUri(uri)) { + const [left, right] = DiffUris.decode(uri); + if (this.matches(selector, right) && this.matches(selector, left)) { + return this.getPriority(); + } + } else if (this.matches(selector, uri)) { return this.getPriority(); } return 0; @@ -63,9 +69,9 @@ export class CustomEditorOpener implements OpenHandler { } protected readonly pendingWidgetPromises = new Map>(); - async open(uri: URI, options?: WidgetOpenerOptions): Promise { + protected async openCustomEditor(uri: URI, options?: WidgetOpenerOptions): Promise { let widget: CustomEditorWidget | undefined; - let shouldNotify = false; + let isNewWidget = false; const uriString = uri.toString(); let widgetPromise = this.pendingWidgetPromises.get(uriString); if (widgetPromise) { @@ -74,14 +80,16 @@ export class CustomEditorOpener implements OpenHandler { const widgets = this.widgetManager.getWidgets(CustomEditorWidget.FACTORY_ID) as CustomEditorWidget[]; widget = widgets.find(w => w.viewType === this.editor.viewType && w.resource.toString() === uriString); if (!widget) { - shouldNotify = true; + isNewWidget = true; const id = generateUuid(); widgetPromise = this.widgetManager.getOrCreateWidget(CustomEditorWidget.FACTORY_ID, { id }).then(async w => { try { w.viewType = this.editor.viewType; w.resource = uri; await this.editorRegistry.resolveWidget(w); - await this.shell.addWidget(w, options?.widgetOptions); + if (options?.widgetOptions) { + await this.shell.addWidget(w, options.widgetOptions); + } return w; } catch (e) { w.dispose(); @@ -92,18 +100,81 @@ export class CustomEditorOpener implements OpenHandler { widget = await widgetPromise; } } - const mode = options?.mode ?? 'activate'; - if (mode === 'activate') { + if (options?.mode === 'activate') { await this.shell.activateWidget(widget.id); - } else if (mode === 'reveal') { + } else if (options?.mode === 'reveal') { await this.shell.revealWidget(widget.id); } - if (shouldNotify) { + if (isNewWidget) { this.onDidOpenCustomEditorEmitter.fire([widget, options]); } return widget; } + protected async openSideBySide(uri: URI, options?: WidgetOpenerOptions): Promise { + const [leftUri, rightUri] = DiffUris.decode(uri); + const widget = await this.widgetManager.getOrCreateWidget( + CustomEditorWidget.SIDE_BY_SIDE_FACTORY_ID, { uri: uri.toString(), viewType: this.editor.viewType }); + if (!widget.panes.length) { // a new widget + const trackedDisposables = new DisposableCollection(widget); + try { + const createPane = async (paneUri: URI) => { + let pane = await this.openCustomEditor(paneUri); + if (pane.isAttached) { + await this.shell.closeWidget(pane.id); + if (!pane.isDisposed) { // user canceled + return undefined; + } + pane = await this.openCustomEditor(paneUri); + } + return pane; + }; + + const rightPane = await createPane(rightUri); + if (!rightPane) { + trackedDisposables.dispose(); + return undefined; + } + trackedDisposables.push(rightPane); + + const leftPane = await createPane(leftUri); + if (!leftPane) { + trackedDisposables.dispose(); + return undefined; + } + trackedDisposables.push(leftPane); + + widget.addPane(leftPane); + widget.addPane(rightPane); + + // dispose the widget if either of its panes gets externally disposed + leftPane.disposed.connect(() => widget.dispose()); + rightPane.disposed.connect(() => widget.dispose()); + + if (options?.widgetOptions) { + await this.shell.addWidget(widget, options.widgetOptions); + } + } catch (e) { + trackedDisposables.dispose(); + console.error(e); + throw e; + } + } + if (options?.mode === 'activate') { + await this.shell.activateWidget(widget.id); + } else if (options?.mode === 'reveal') { + await this.shell.revealWidget(widget.id); + } + return widget; + } + + async open(uri: URI, options?: WidgetOpenerOptions): Promise { + options = { ...options }; + options.mode ??= 'activate'; + options.widgetOptions ??= { area: 'main' }; + return DiffUris.isDiffUri(uri) ? this.openSideBySide(uri, options) : this.openCustomEditor(uri, options); + } + matches(selectors: CustomEditorSelector[], resource: URI): boolean { return selectors.some(selector => this.selectorMatches(selector, resource)); } 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 index 2e60988f4dc2f..ac804199fc89b 100644 --- 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 @@ -27,6 +27,7 @@ import { CustomEditorWidget as CustomEditorWidgetShape } from '@theia/editor/lib @injectable() export class CustomEditorWidget extends WebviewWidget implements CustomEditorWidgetShape, SaveableSource, NavigatableWidget { static override FACTORY_ID = 'plugin-custom-editor'; + static readonly SIDE_BY_SIDE_FACTORY_ID = CustomEditorWidget.FACTORY_ID + '.side-by-side'; override id: string; resource: URI; @@ -66,17 +67,15 @@ export class CustomEditorWidget extends WebviewWidget implements CustomEditorWid } undo(): void { - this._modelRef.object.undo(); + this._modelRef.object?.undo(); } redo(): void { - this._modelRef.object.redo(); + this._modelRef.object?.redo(); } async save(options?: SaveOptions): Promise { - if (this._modelRef.object) { - await this._modelRef.object.saveCustomEditor(options); - } + await this._modelRef.object?.saveCustomEditor(options); } async saveAs(source: URI, target: URI, options?: SaveOptions): Promise { 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 7323df220e868..68874ede43d97 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 @@ -21,10 +21,10 @@ import '../../../src/main/browser/style/comments.css'; import { ContainerModule } from '@theia/core/shared/inversify'; import { FrontendApplicationContribution, WidgetFactory, bindViewContribution, - ViewContainerIdentifier, ViewContainer, createTreeContainer, TreeWidget, LabelProviderContribution, - UndoRedoHandler + ViewContainerIdentifier, ViewContainer, createTreeContainer, TreeWidget, LabelProviderContribution, LabelProvider, + UndoRedoHandler, DiffUris, Navigatable, SplitWidget } from '@theia/core/lib/browser'; -import { MaybePromise, CommandContribution, ResourceResolver, bindContributionProvider } from '@theia/core/lib/common'; +import { MaybePromise, CommandContribution, ResourceResolver, bindContributionProvider, URI, generateUuid } from '@theia/core/lib/common'; import { WebSocketConnectionProvider } from '@theia/core/lib/browser/messaging'; import { HostedPluginSupport } from '../../hosted/browser/hosted-plugin'; import { HostedPluginWatcher } from '../../hosted/browser/hosted-plugin-watcher'; @@ -200,6 +200,25 @@ export default new ContainerModule((bind, unbind, isBound, rebind) => { bind(CustomEditorUndoRedoHandler).toSelf().inSingletonScope(); bind(UndoRedoHandler).toService(CustomEditorUndoRedoHandler); + bind(WidgetFactory).toDynamicValue(ctx => ({ + id: CustomEditorWidget.SIDE_BY_SIDE_FACTORY_ID, + createWidget: (arg: { uri: string, viewType: string }) => { + const uri = new URI(arg.uri); + const [leftUri, rightUri] = DiffUris.decode(uri); + const navigatable: Navigatable = { + getResourceUri: () => rightUri, + createMoveToUri: resourceUri => DiffUris.encode(leftUri, rightUri.withPath(resourceUri.path)) + }; + const widget = new SplitWidget({ navigatable }); + widget.id = arg.viewType + '.side-by-side:' + generateUuid(); + const labelProvider = ctx.container.get(LabelProvider); + widget.title.label = labelProvider.getName(uri); + widget.title.iconClass = labelProvider.getIcon(uri); + widget.title.closable = true; + return widget; + } + })).inSingletonScope(); + bind(PluginViewWidget).toSelf(); bind(WidgetFactory).toDynamicValue(({ container }) => ({ id: PLUGIN_VIEW_FACTORY_ID,