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/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/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/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/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(); } 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/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/hosted/browser/hosted-plugin.ts b/packages/plugin-ext/src/hosted/browser/hosted-plugin.ts index 913fa538f1a47..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'; @@ -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); @@ -448,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 { 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..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 @@ -14,13 +14,13 @@ // 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, 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 { @@ -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; @@ -45,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; @@ -62,34 +69,112 @@ 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; - 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 isNewWidget = 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) { + isNewWidget = 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); + if (options?.widgetOptions) { + 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]); } } + if (options?.mode === 'activate') { + await this.shell.activateWidget(widget.id); + } else if (options?.mode === 'reveal') { + await this.shell.revealWidget(widget.id); + } + 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 7d277a44565b5..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 @@ -17,29 +17,36 @@ 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'; + static readonly SIDE_BY_SIDE_FACTORY_ID = CustomEditorWidget.FACTORY_ID + '.side-by-side'; 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) @@ -60,21 +67,23 @@ export class CustomEditorWidget extends WebviewWidget implements SaveableSource, } 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 { - await this._modelRef.object.saveCustomEditor(options); + 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 { 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/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, 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..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(); @@ -352,7 +350,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 +542,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/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; } 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)); 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 {