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,