diff --git a/packages/plugin-ext/src/common/collections.ts b/packages/plugin-ext/src/common/collections.ts new file mode 100644 index 0000000000000..1e2576a3f7582 --- /dev/null +++ b/packages/plugin-ext/src/common/collections.ts @@ -0,0 +1,37 @@ +// ***************************************************************************** +// Copyright (C) 2022 TypeFox and others. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0. +// +// This Source Code may also be made available under the following Secondary +// Licenses when the conditions for such availability set forth in the Eclipse +// Public License v. 2.0 are satisfied: GNU General Public License, version 2 +// with the GNU Classpath Exception which is available at +// https://www.gnu.org/software/classpath/license.html. +// +// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 +// ***************************************************************************** + +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +// some code copied and modified from https://github.com/microsoft/vscode/blob/1.71.2/src/vs/base/common/collections.ts + +export function diffSets(before: Set, after: Set): { removed: T[]; added: T[] } { + const removed: T[] = []; + const added: T[] = []; + for (const element of before) { + if (!after.has(element)) { + removed.push(element); + } + } + for (const element of after) { + if (!before.has(element)) { + added.push(element); + } + } + return { removed, added }; +} diff --git a/packages/plugin-ext/src/common/plugin-api-rpc.ts b/packages/plugin-ext/src/common/plugin-api-rpc.ts index 6f8ec9fd68506..024a7d9247b74 100644 --- a/packages/plugin-ext/src/common/plugin-api-rpc.ts +++ b/packages/plugin-ext/src/common/plugin-api-rpc.ts @@ -1919,6 +1919,128 @@ export interface CommentsMain { $onDidCommentThreadsChange(handle: number, event: CommentThreadChangedEvent): void; } +// #region + +export const enum TabInputKind { + UnknownInput, + TextInput, + TextDiffInput, + TextMergeInput, + NotebookInput, + NotebookDiffInput, + CustomEditorInput, + WebviewEditorInput, + TerminalEditorInput, + InteractiveEditorInput, +} + +export interface UnknownInputDto { + kind: TabInputKind.UnknownInput; +} + +export interface TextInputDto { + kind: TabInputKind.TextInput; + uri: UriComponents; +} + +export interface TextDiffInputDto { + kind: TabInputKind.TextDiffInput; + original: UriComponents; + modified: UriComponents; +} + +export interface TextMergeInputDto { + kind: TabInputKind.TextMergeInput; + base: UriComponents; + input1: UriComponents; + input2: UriComponents; + result: UriComponents; +} + +export interface NotebookInputDto { + kind: TabInputKind.NotebookInput; + notebookType: string; + uri: UriComponents; +} + +export interface NotebookDiffInputDto { + kind: TabInputKind.NotebookDiffInput; + notebookType: string; + original: UriComponents; + modified: UriComponents; +} + +export interface CustomInputDto { + kind: TabInputKind.CustomEditorInput; + viewType: string; + uri: UriComponents; +} + +export interface WebviewInputDto { + kind: TabInputKind.WebviewEditorInput; + viewType: string; +} + +export interface InteractiveEditorInputDto { + kind: TabInputKind.InteractiveEditorInput; + uri: UriComponents; + inputBoxUri: UriComponents; +} + +export interface TabInputDto { + kind: TabInputKind.TerminalEditorInput; +} + +export type EditorGroupColumn = number; +export type AnyInputDto = UnknownInputDto | TextInputDto | TextDiffInputDto | TextMergeInputDto | NotebookInputDto | NotebookDiffInputDto | CustomInputDto | WebviewInputDto | InteractiveEditorInputDto | TabInputDto; + +export interface TabGroupDto { + isActive: boolean; + viewColumn: EditorGroupColumn; + tabs: TabDto[]; + groupId: number; +} + +export const enum TabModelOperationKind { + TAB_OPEN, + TAB_CLOSE, + TAB_UPDATE, + TAB_MOVE +} + +export interface TabOperation { + readonly kind: TabModelOperationKind.TAB_OPEN | TabModelOperationKind.TAB_CLOSE | TabModelOperationKind.TAB_UPDATE | TabModelOperationKind.TAB_MOVE; + readonly index: number; + readonly tabDto: TabDto; + readonly groupId: number; + readonly oldIndex?: number; +} + +export interface TabDto { + id: string; + label: string; + input: any; + editorId?: string; + isActive: boolean; + isPinned: boolean; + isPreview: boolean; + isDirty: boolean; +} + +export interface TabsExt { + $acceptEditorTabModel(tabGroups: TabGroupDto[]): void; + $acceptTabGroupUpdate(groupDto: TabGroupDto): void; + $acceptTabOperation(operation: TabOperation): void; +} + +export interface TabsMain { + $moveTab(tabId: string, index: number, viewColumn: EditorGroupColumn, preserveFocus?: boolean): void; + $closeTab(tabIds: string[], preserveFocus?: boolean): Promise; + $closeGroup(groupIds: number[], preservceFocus?: boolean): Promise; +} + +// endregion + export const PLUGIN_RPC_CONTEXT = { AUTHENTICATION_MAIN: >createProxyIdentifier('AuthenticationMain'), COMMAND_REGISTRY_MAIN: >createProxyIdentifier('CommandRegistryMain'), @@ -1952,7 +2074,8 @@ export const PLUGIN_RPC_CONTEXT = { LABEL_SERVICE_MAIN: >createProxyIdentifier('LabelServiceMain'), TIMELINE_MAIN: >createProxyIdentifier('TimelineMain'), THEMING_MAIN: >createProxyIdentifier('ThemingMain'), - COMMENTS_MAIN: >createProxyIdentifier('CommentsMain') + COMMENTS_MAIN: >createProxyIdentifier('CommentsMain'), + TABS_MAIN: >createProxyIdentifier('TabsMain') }; export const MAIN_RPC_CONTEXT = { @@ -1986,7 +2109,8 @@ export const MAIN_RPC_CONTEXT = { LABEL_SERVICE_EXT: createProxyIdentifier('LabelServiceExt'), TIMELINE_EXT: createProxyIdentifier('TimeLineExt'), THEMING_EXT: createProxyIdentifier('ThemingExt'), - COMMENTS_EXT: createProxyIdentifier('CommentsExt') + COMMENTS_EXT: createProxyIdentifier('CommentsExt'), + TABS_EXT: createProxyIdentifier('TabsExt') }; export interface TasksExt { diff --git a/packages/plugin-ext/src/common/types.ts b/packages/plugin-ext/src/common/types.ts index d1c8b4c6722f7..9885fbd32feaa 100644 --- a/packages/plugin-ext/src/common/types.ts +++ b/packages/plugin-ext/src/common/types.ts @@ -116,3 +116,14 @@ export function isUndefined(obj: any): obj is undefined { export function isUndefinedOrNull(obj: any): obj is undefined | null { return isUndefined(obj) || obj === null; // eslint-disable-line no-null/no-null } + +/** + * Asserts that the argument passed in is neither undefined nor null. + */ +export function assertIsDefined(arg: T | null | undefined): T { + if (isUndefinedOrNull(arg)) { + throw new Error('Assertion Failed: argument is undefined or null'); + } + + return arg; +} diff --git a/packages/plugin-ext/src/main/browser/tabs/tabs-main.ts b/packages/plugin-ext/src/main/browser/tabs/tabs-main.ts new file mode 100644 index 0000000000000..7d85c44347aec --- /dev/null +++ b/packages/plugin-ext/src/main/browser/tabs/tabs-main.ts @@ -0,0 +1,42 @@ +// ***************************************************************************** +// Copyright (C) 2022 TypeFox and others. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0. +// +// This Source Code may also be made available under the following Secondary +// Licenses when the conditions for such availability set forth in the Eclipse +// Public License v. 2.0 are satisfied: GNU General Public License, version 2 +// with the GNU Classpath Exception which is available at +// https://www.gnu.org/software/classpath/license.html. +// +// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 +// ***************************************************************************** + +import { interfaces } from '@theia/core/shared/inversify'; + +import { TabsMain } from '../../../common/plugin-api-rpc'; +import { RPCProtocol } from '../../../common/rpc-protocol'; + +export class TabsMainImp implements TabsMain { + + constructor( + rpc: RPCProtocol, + container: interfaces.Container + ) {} + + // #region Messages received from Ext Host + $moveTab(tabId: string, index: number, viewColumn: number, preserveFocus?: boolean): void { + return; + } + + async $closeTab(tabIds: string[], preserveFocus?: boolean): Promise { + return false; + } + + async $closeGroup(groupIds: number[], preserveFocus?: boolean): Promise { + return false; + } + // #endregion +} diff --git a/packages/plugin-ext/src/plugin/plugin-context.ts b/packages/plugin-ext/src/plugin/plugin-context.ts index 260aaf08ac1d3..2eaec99943d93 100644 --- a/packages/plugin-ext/src/plugin/plugin-context.ts +++ b/packages/plugin-ext/src/plugin/plugin-context.ts @@ -175,7 +175,15 @@ import { ExtensionKind, InlineCompletionItem, InlineCompletionList, - InlineCompletionTriggerKind + InlineCompletionTriggerKind, + TextTabInput, + CustomEditorTabInput, + NotebookDiffEditorTabInput, + NotebookEditorTabInput, + TerminalEditorTabInput, + TextDiffTabInput, + TextMergeTabInput, + WebviewEditorTabInput } from './types-impl'; import { AuthenticationExtImpl } from './authentication-ext'; import { SymbolKind } from '../common/plugin-api-rpc-model'; @@ -218,6 +226,7 @@ import { WebviewViewsExtImpl } from './webview-views'; import { PluginPackage } from '../common'; import { Endpoint } from '@theia/core/lib/browser/endpoint'; import { FilePermission } from '@theia/filesystem/lib/common/files'; +import { TabsExtImpl } from './tabs'; export function createAPIFactory( rpc: RPCProtocol, @@ -255,6 +264,7 @@ export function createAPIFactory( const timelineExt = rpc.set(MAIN_RPC_CONTEXT.TIMELINE_EXT, new TimelineExtImpl(rpc, commandRegistry)); const themingExt = rpc.set(MAIN_RPC_CONTEXT.THEMING_EXT, new ThemingExtImpl(rpc)); const commentsExt = rpc.set(MAIN_RPC_CONTEXT.COMMENTS_EXT, new CommentsExtImpl(rpc, commandRegistry, documents)); + const 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 webviewViewsExt = rpc.set(MAIN_RPC_CONTEXT.WEBVIEW_VIEWS_EXT, new WebviewViewsExtImpl(rpc, webviewExt)); rpc.set(MAIN_RPC_CONTEXT.DEBUG_EXT, debugExt); @@ -496,7 +506,7 @@ export function createAPIFactory( onDidChangeWindowState(listener, thisArg?, disposables?): theia.Disposable { return windowStateExt.onDidChangeWindowState(listener, thisArg, disposables); }, - createTerminal(nameOrOptions: theia.TerminalOptions | theia.PseudoTerminalOptions | theia.ExtensionTerminalOptions | (string | undefined), + createTerminal(nameOrOptions: theia.TerminalOptions | theia.ExtensionTerminalOptions | theia.ExtensionTerminalOptions | (string | undefined), shellPath?: string, shellArgs?: string[] | string): theia.Terminal { return terminalExt.createTerminal(nameOrOptions, shellPath, shellArgs); @@ -541,6 +551,9 @@ export function createAPIFactory( }, onDidChangeActiveColorTheme(listener, thisArg?, disposables?) { return themingExt.onDidChangeActiveColorTheme(listener, thisArg, disposables); + }, + get tabGroups(): theia.TabGroups { + return tabsExt.tabGroups; } }; @@ -1262,7 +1275,15 @@ export function createAPIFactory( ExtensionKind, InlineCompletionItem, InlineCompletionList, - InlineCompletionTriggerKind + InlineCompletionTriggerKind, + TabInputText: TextTabInput, + TabInputTextDiff: TextDiffTabInput, + TabInputTextMerge: TextMergeTabInput, + TabInputCustom: CustomEditorTabInput, + TabInputNotebook: NotebookEditorTabInput, + TabInputNotebookDiff: NotebookDiffEditorTabInput, + TabInputWebview: WebviewEditorTabInput, + TabInputTerminal: TerminalEditorTabInput, }; }; } diff --git a/packages/plugin-ext/src/plugin/tabs.ts b/packages/plugin-ext/src/plugin/tabs.ts new file mode 100644 index 0000000000000..bc357ee1f80fe --- /dev/null +++ b/packages/plugin-ext/src/plugin/tabs.ts @@ -0,0 +1,430 @@ +// ***************************************************************************** +// Copyright (C) 2022 TypeFox and others. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0. +// +// This Source Code may also be made available under the following Secondary +// Licenses when the conditions for such availability set forth in the Eclipse +// Public License v. 2.0 are satisfied: GNU General Public License, version 2 +// with the GNU Classpath Exception which is available at +// https://www.gnu.org/software/classpath/license.html. +// +// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 +// ***************************************************************************** + +import * as theia from '@theia/plugin'; +import { Emitter } from '@theia/core'; +import { RPCProtocol } from '../common/rpc-protocol'; +import { PLUGIN_RPC_CONTEXT, TabDto, TabGroupDto, TabInputKind, TabModelOperationKind, TabOperation, TabsExt, TabsMain } from '../common/plugin-api-rpc'; +import { + CustomEditorTabInput, + InteractiveWindowInput, + NotebookDiffEditorTabInput, + NotebookEditorTabInput, + TerminalEditorTabInput, + TextDiffTabInput, + TextMergeTabInput, + TextTabInput, + URI, + WebviewEditorTabInput +} from './types-impl'; +import { assertIsDefined } from '../common/types'; +import { diffSets } from '../common/collections'; +import { ViewColumn } from './type-converters'; + +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +// some code copied and modified from https://github.com/microsoft/vscode/blob/1.71.2/src/vs/workbench/api/common/extHostEditorTabs.ts + +type AnyTabInput = + TextTabInput | + TextDiffTabInput | + CustomEditorTabInput | + NotebookEditorTabInput | + NotebookDiffEditorTabInput | + WebviewEditorTabInput | + TerminalEditorTabInput | + InteractiveWindowInput; + +class TabExt { + private tabApiObject: theia.Tab | undefined; + private tabDto!: TabDto; + private input: AnyTabInput | undefined; + private parentGroup: TabGroupExt; + private readonly activeTabIdGetter: () => string; + + constructor(dto: TabDto, parentGroup: TabGroupExt, activeTabIdGetter: () => string) { + this.activeTabIdGetter = activeTabIdGetter; + this.parentGroup = parentGroup; + this.acceptDtoUpdate(dto); + } + + get apiObject(): theia.Tab { + if (!this.tabApiObject) { + // Don't want to lose reference to parent `this` in the getters + const that = this; + const obj: theia.Tab = { + get isActive(): boolean { + // We use a getter function here to always ensure at most 1 active tab per group and prevent iteration for being required + return that.tabDto.id === that.activeTabIdGetter(); + }, + get label(): string { + return that.tabDto.label; + }, + get input(): AnyTabInput | undefined { + return that.input; + }, + get isDirty(): boolean { + return that.tabDto.isDirty; + }, + get isPinned(): boolean { + return that.tabDto.isPinned; + }, + get isPreview(): boolean { + return that.tabDto.isPreview; + }, + get group(): theia.TabGroup { + return that.parentGroup.apiObject; + } + }; + this.tabApiObject = Object.freeze(obj); + } + return this.tabApiObject; + } + + get tabId(): string { + return this.tabDto.id; + } + + acceptDtoUpdate(tabDto: TabDto): void { + this.tabDto = tabDto; + this.input = this.initInput(); + } + + private initInput(): AnyTabInput | undefined { + switch (this.tabDto.input.kind) { + case TabInputKind.TextInput: + return new TextTabInput(URI.revive(this.tabDto.input.uri)); + case TabInputKind.TextDiffInput: + return new TextDiffTabInput(URI.revive(this.tabDto.input.original), URI.revive(this.tabDto.input.modified)); + case TabInputKind.TextMergeInput: + return new TextMergeTabInput( + URI.revive(this.tabDto.input.base), + URI.revive(this.tabDto.input.input1), + URI.revive(this.tabDto.input.input2), + URI.revive(this.tabDto.input.result)); + case TabInputKind.CustomEditorInput: + return new CustomEditorTabInput(URI.revive(this.tabDto.input.uri), this.tabDto.input.viewType); + case TabInputKind.WebviewEditorInput: + return new WebviewEditorTabInput(this.tabDto.input.viewType); + case TabInputKind.NotebookInput: + return new NotebookEditorTabInput(URI.revive(this.tabDto.input.uri), this.tabDto.input.notebookType); + case TabInputKind.NotebookDiffInput: + return new NotebookDiffEditorTabInput(URI.revive(this.tabDto.input.original), URI.revive(this.tabDto.input.modified), this.tabDto.input.notebookType); + case TabInputKind.TerminalEditorInput: + return new TerminalEditorTabInput(); + case TabInputKind.InteractiveEditorInput: + return new InteractiveWindowInput(URI.revive(this.tabDto.input.uri), URI.revive(this.tabDto.input.inputBoxUri)); + default: + return undefined; + } + } +} + +class TabGroupExt { + + private tabGroupApiObject: theia.TabGroup | undefined; + private tabGroupDto: TabGroupDto; + private tabsArr: TabExt[] = []; + private activeTabId: string = ''; + private activeGroupIdGetter: () => number | undefined; + + constructor(dto: TabGroupDto, activeGroupIdGetter: () => number | undefined) { + this.tabGroupDto = dto; + this.activeGroupIdGetter = activeGroupIdGetter; + // Construct all tabs from the given dto + for (const tabDto of dto.tabs) { + if (tabDto.isActive) { + this.activeTabId = tabDto.id; + } + this.tabsArr.push(new TabExt(tabDto, this, () => this.getActiveTabId())); + } + } + + get apiObject(): theia.TabGroup { + if (!this.tabGroupApiObject) { + // Don't want to lose reference to parent `this` in the getters + const that = this; + const obj: theia.TabGroup = { + get isActive(): boolean { + // We use a getter function here to always ensure at most 1 active group and prevent iteration for being required + return that.tabGroupDto.groupId === that.activeGroupIdGetter(); + }, + get viewColumn(): theia.ViewColumn { + return ViewColumn.to(that.tabGroupDto.viewColumn); + }, + get activeTab(): theia.Tab | undefined { + return that.tabsArr.find(tab => tab.tabId === that.activeTabId)?.apiObject; + }, + get tabs(): Readonly { + return Object.freeze(that.tabsArr.map(tab => tab.apiObject)); + } + }; + this.tabGroupApiObject = Object.freeze(obj); + } + return this.tabGroupApiObject; + } + + get groupId(): number { + return this.tabGroupDto.groupId; + } + + get tabs(): TabExt[] { + return this.tabsArr; + } + + acceptGroupDtoUpdate(dto: TabGroupDto): void { + this.tabGroupDto = dto; + } + + acceptTabOperation(operation: TabOperation): TabExt { + // In the open case we add the tab to the group + if (operation.kind === TabModelOperationKind.TAB_OPEN) { + const tab = new TabExt(operation.tabDto, this, () => this.getActiveTabId()); + // Insert tab at editor index + this.tabsArr.splice(operation.index, 0, tab); + if (operation.tabDto.isActive) { + this.activeTabId = tab.tabId; + } + return tab; + } else if (operation.kind === TabModelOperationKind.TAB_CLOSE) { + const tab = this.tabsArr.splice(operation.index, 1)[0]; + if (!tab) { + throw new Error(`Tab close updated received for index ${operation.index} which does not exist`); + } + if (tab.tabId === this.activeTabId) { + this.activeTabId = ''; + } + return tab; + } else if (operation.kind === TabModelOperationKind.TAB_MOVE) { + if (operation.oldIndex === undefined) { + throw new Error('Invalid old index on move IPC'); + } + // Splice to remove at old index and insert at new index === moving the tab + const tab = this.tabsArr.splice(operation.oldIndex, 1)[0]; + if (!tab) { + throw new Error(`Tab move updated received for index ${operation.oldIndex} which does not exist`); + } + this.tabsArr.splice(operation.index, 0, tab); + return tab; + } + const _tab = this.tabsArr.find(extHostTab => extHostTab.tabId === operation.tabDto.id); + if (!_tab) { + throw new Error('INVALID tab'); + } + if (operation.tabDto.isActive) { + this.activeTabId = operation.tabDto.id; + } else if (this.activeTabId === operation.tabDto.id && !operation.tabDto.isActive) { + // Events aren't guaranteed to be in order so if we receive a dto that matches the active tab id + // but isn't active we mark the active tab id as empty. This prevent onDidActiveTabChange from + // firing incorrectly + this.activeTabId = ''; + } + _tab.acceptDtoUpdate(operation.tabDto); + return _tab; + } + + // Not a getter since it must be a function to be used as a callback for the tabs + getActiveTabId(): string { + return this.activeTabId; + } +} + +export class TabsExtImpl implements TabsExt { + declare readonly _serviceBrand: undefined; + + private readonly proxy: TabsMain; + private readonly onDidChangeTabs = new Emitter(); + private readonly onDidChangeTabGroups = new Emitter(); + + // Have to use ! because this gets initialized via an RPC proxy + private activeGroupId!: number; + + private tabGroupArr: TabGroupExt[] = []; + + private apiObject: theia.TabGroups | undefined; + + constructor(readonly rpc: RPCProtocol) { + this.proxy = rpc.getProxy(PLUGIN_RPC_CONTEXT.TABS_MAIN); + } + + get tabGroups(): theia.TabGroups { + if (!this.apiObject) { + const that = this; + const obj: theia.TabGroups = { + // never changes -> simple value + onDidChangeTabGroups: that.onDidChangeTabGroups.event, + onDidChangeTabs: that.onDidChangeTabs.event, + // dynamic -> getters + get all(): Readonly { + return Object.freeze(that.tabGroupArr.map(group => group.apiObject)); + }, + get activeTabGroup(): theia.TabGroup { + const activeTabGroupId = that.activeGroupId; + const activeTabGroup = assertIsDefined(that.tabGroupArr.find(candidate => candidate.groupId === activeTabGroupId)?.apiObject); + return activeTabGroup; + }, + close: async (tabOrTabGroup: theia.Tab | readonly theia.Tab[] | theia.TabGroup | readonly theia.TabGroup[], preserveFocus?: boolean) => { + const tabsOrTabGroups = Array.isArray(tabOrTabGroup) ? tabOrTabGroup : [tabOrTabGroup]; + if (!tabsOrTabGroups.length) { + return true; + } + // Check which type was passed in and call the appropriate close + // Casting is needed as typescript doesn't seem to infer enough from this + if (isTabGroup(tabsOrTabGroups[0])) { + return this._closeGroups(tabsOrTabGroups as theia.TabGroup[], preserveFocus); + } else { + return this._closeTabs(tabsOrTabGroups as theia.Tab[], preserveFocus); + } + }, + // move: async (tab: theia.Tab, viewColumn: ViewColumn, index: number, preserveFocus?: boolean) => { + // const extHostTab = this._findExtHostTabFromApi(tab); + // if (!extHostTab) { + // throw new Error('Invalid tab'); + // } + // this._proxy.$moveTab(extHostTab.tabId, index, typeConverters.ViewColumn.from(viewColumn), preserveFocus); + // return; + // } + }; + this.apiObject = Object.freeze(obj); + } + return this.apiObject; + } + + $acceptEditorTabModel(tabGroups: TabGroupDto[]): void { + + const groupIdsBefore = new Set(this.tabGroupArr.map(group => group.groupId)); + const groupIdsAfter = new Set(tabGroups.map(dto => dto.groupId)); + const diff = diffSets(groupIdsBefore, groupIdsAfter); + + const closed: theia.TabGroup[] = this.tabGroupArr.filter(group => diff.removed.includes(group.groupId)).map(group => group.apiObject); + const opened: theia.TabGroup[] = []; + const changed: theia.TabGroup[] = []; + + this.tabGroupArr = tabGroups.map(tabGroup => { + const group = new TabGroupExt(tabGroup, () => this.activeGroupId); + if (diff.added.includes(group.groupId)) { + opened.push(group.apiObject); + } else { + changed.push(group.apiObject); + } + return group; + }); + + // Set the active tab group id + const activeTabGroupId = assertIsDefined(tabGroups.find(group => group.isActive === true)?.groupId); + if (activeTabGroupId !== undefined && this.activeGroupId !== activeTabGroupId) { + this.activeGroupId = activeTabGroupId; + } + this.onDidChangeTabGroups.fire(Object.freeze({ opened, closed, changed })); + } + + $acceptTabGroupUpdate(groupDto: TabGroupDto): void { + const group = this.tabGroupArr.find(tabGroup => tabGroup.groupId === groupDto.groupId); + if (!group) { + throw new Error('Update Group IPC call received before group creation.'); + } + group.acceptGroupDtoUpdate(groupDto); + if (groupDto.isActive) { + this.activeGroupId = groupDto.groupId; + } + this.onDidChangeTabGroups.fire(Object.freeze({ changed: [group.apiObject], opened: [], closed: [] })); + } + + $acceptTabOperation(operation: TabOperation): void { + const group = this.tabGroupArr.find(tabGroup => tabGroup.groupId === operation.groupId); + if (!group) { + throw new Error('Update Tabs IPC call received before group creation.'); + } + const tab = group.acceptTabOperation(operation); + + // Construct the tab change event based on the operation + switch (operation.kind) { + case TabModelOperationKind.TAB_OPEN: + this.onDidChangeTabs.fire(Object.freeze({ + opened: [tab.apiObject], + closed: [], + changed: [] + })); + return; + case TabModelOperationKind.TAB_CLOSE: + this.onDidChangeTabs.fire(Object.freeze({ + opened: [], + closed: [tab.apiObject], + changed: [] + })); + return; + case TabModelOperationKind.TAB_MOVE: + case TabModelOperationKind.TAB_UPDATE: + this.onDidChangeTabs.fire(Object.freeze({ + opened: [], + closed: [], + changed: [tab.apiObject] + })); + return; + } + } + + private _findExtHostTabFromApi(apiTab: theia.Tab): TabExt | undefined { + for (const group of this.tabGroupArr) { + for (const tab of group.tabs) { + if (tab.apiObject === apiTab) { + return tab; + } + } + } + return; + } + + private _findExtHostTabGroupFromApi(apiTabGroup: theia.TabGroup): TabGroupExt | undefined { + return this.tabGroupArr.find(candidate => candidate.apiObject === apiTabGroup); + } + + private async _closeTabs(tabs: theia.Tab[], preserveFocus?: boolean): Promise { + const extHostTabIds: string[] = []; + for (const tab of tabs) { + const extHostTab = this._findExtHostTabFromApi(tab); + if (!extHostTab) { + throw new Error('Tab close: Invalid tab not found!'); + } + extHostTabIds.push(extHostTab.tabId); + } + return this.proxy.$closeTab(extHostTabIds, preserveFocus); + } + + private async _closeGroups(groups: theia.TabGroup[], preserverFoucs?: boolean): Promise { + const extHostGroupIds: number[] = []; + for (const group of groups) { + const extHostGroup = this._findExtHostTabGroupFromApi(group); + if (!extHostGroup) { + throw new Error('Group close: Invalid group not found!'); + } + extHostGroupIds.push(extHostGroup.groupId); + } + return this.proxy.$closeGroup(extHostGroupIds, preserverFoucs); + } +} + +// #region Utils +function isTabGroup(obj: unknown): obj is theia.TabGroup { + const tabGroup = obj as theia.TabGroup; + if (tabGroup.tabs !== undefined) { + return true; + } + return false; +} +// #endregion diff --git a/packages/plugin-ext/src/plugin/type-converters.ts b/packages/plugin-ext/src/plugin/type-converters.ts index f87c25527ebcd..820fc4dc81aa0 100644 --- a/packages/plugin-ext/src/plugin/type-converters.ts +++ b/packages/plugin-ext/src/plugin/type-converters.ts @@ -1298,6 +1298,28 @@ export namespace ThemableDecorationAttachmentRenderOptions { } } +export namespace ViewColumn { + export function from(column?: theia.ViewColumn): rpc.EditorGroupColumn { + if (typeof column === 'number' && column >= types.ViewColumn.One) { + return column - 1; // adjust zero index (ViewColumn.ONE => 0) + } + + if (column === types.ViewColumn.Beside) { + return SIDE_GROUP; + } + + return ACTIVE_GROUP; // default is always the active group + } + + export function to(position: rpc.EditorGroupColumn): theia.ViewColumn { + if (typeof position === 'number' && position >= 0) { + return position + 1; // adjust to index (ViewColumn.ONE => 1) + } + + throw new Error('invalid \'EditorGroupColumn\''); + } +} + export function pathOrURIToURI(value: string | URI): URI { if (typeof value === 'undefined') { return value; diff --git a/packages/plugin-ext/src/plugin/types-impl.ts b/packages/plugin-ext/src/plugin/types-impl.ts index 8ee6d9ad6af7a..7faa2151fe740 100644 --- a/packages/plugin-ext/src/plugin/types-impl.ts +++ b/packages/plugin-ext/src/plugin/types-impl.ts @@ -3240,3 +3240,42 @@ export enum InputBoxValidationSeverity { } // #endregion + +// #region Tab Inputs + +export class TextTabInput { + constructor(readonly uri: URI) { } +} + +export class TextDiffTabInput { + constructor(readonly original: URI, readonly modified: URI) { } +} + +export class TextMergeTabInput { + constructor(readonly base: URI, readonly input1: URI, readonly input2: URI, readonly result: URI) { } +} + +export class CustomEditorTabInput { + constructor(readonly uri: URI, readonly viewType: string) { } +} + +export class WebviewEditorTabInput { + constructor(readonly viewType: string) { } +} + +export class NotebookEditorTabInput { + constructor(readonly uri: URI, readonly notebookType: string) { } +} + +export class NotebookDiffEditorTabInput { + constructor(readonly original: URI, readonly modified: URI, readonly notebookType: string) { } +} + +export class TerminalEditorTabInput { + constructor() { } +} +export class InteractiveWindowInput { + constructor(readonly uri: URI, readonly inputBoxUri: URI) { } +} + +// #endregion diff --git a/packages/plugin/src/theia.d.ts b/packages/plugin/src/theia.d.ts index 1c52a360727c2..f23afa620a357 100644 --- a/packages/plugin/src/theia.d.ts +++ b/packages/plugin/src/theia.d.ts @@ -4554,6 +4554,11 @@ export module '@theia/plugin' { */ export namespace window { + /** + * Represents the grid widget within the main editor area + */ + export const tabGroups: TabGroups; + /** * The currently active terminal or undefined. The active terminal is the one * that currently has focus or most recently had focus. @@ -13114,6 +13119,335 @@ export module '@theia/plugin' { export function registerAuthenticationProvider(id: string, label: string, provider: AuthenticationProvider, options?: AuthenticationProviderOptions): Disposable; } + /** + * The tab represents a single text based resource. + */ + export class TabInputText { + /** + * The uri represented by the tab. + * @stubbed + */ + readonly uri: Uri; + /** + * Constructs a text tab input with the given URI. + * @param uri The URI of the tab. + * @stubbed + */ + constructor(uri: Uri); + } + + /** + * The tab represents two text based resources + * being rendered as a diff. + */ + export class TabInputTextDiff { + /** + * The uri of the original text resource. + * @stubbed + */ + readonly original: Uri; + /** + * The uri of the modified text resource. + * @stubbed + */ + readonly modified: Uri; + /** + * Constructs a new text diff tab input with the given URIs. + * @param original The uri of the original text resource. + * @param modified The uri of the modified text resource. + * @stubbed + */ + constructor(original: Uri, modified: Uri); + } + + /** + * The tab represents a custom editor. + */ + export class TabInputCustom { + /** + * The uri that the tab is representing. + * @stubbed + */ + readonly uri: Uri; + /** + * The type of custom editor. + * @stubbed + */ + readonly viewType: string; + /** + * Constructs a custom editor tab input. + * @param uri The uri of the tab. + * @param viewType The viewtype of the custom editor. + * @stubbed + */ + constructor(uri: Uri, viewType: string); + } + + /** + * The tab represents a webview. + */ + export class TabInputWebview { + /** + * The type of webview. Maps to WebviewPanel's viewType + * @stubbed + */ + readonly viewType: string; + /** + * Constructs a webview tab input with the given view type. + * @param viewType The type of webview. Maps to WebviewPanel's viewType + * @stubbed + */ + constructor(viewType: string); + } + + /** + * The tab represents a notebook. + */ + export class TabInputNotebook { + /** + * The uri that the tab is representing. + * @stubbed + */ + readonly uri: Uri; + /** + * The type of notebook. Maps to NotebookDocuments's notebookType + * @stubbed + */ + readonly notebookType: string; + /** + * Constructs a new tab input for a notebook. + * @param uri The uri of the notebook. + * @param notebookType The type of notebook. Maps to NotebookDocuments's notebookType + * @stubbed + */ + constructor(uri: Uri, notebookType: string); + } + + /** + * The tabs represents two notebooks in a diff configuration. + */ + export class TabInputNotebookDiff { + /** + * The uri of the original notebook. + * @stubbed + */ + readonly original: Uri; + /** + * The uri of the modified notebook. + * @stubbed + */ + readonly modified: Uri; + /** + * The type of notebook. Maps to NotebookDocuments's notebookType + * @stubbed + */ + readonly notebookType: string; + /** + * Constructs a notebook diff tab input. + * @param original The uri of the original unmodified notebook. + * @param modified The uri of the modified notebook. + * @param notebookType The type of notebook. Maps to NotebookDocuments's notebookType + * @stubbed + */ + constructor(original: Uri, modified: Uri, notebookType: string); + } + + /** + * The tab represents a terminal in the editor area. + */ + export class TabInputTerminal { + /** + * Constructs a terminal tab input. + * @stubbed + */ + constructor(); + } + + /** + * Represents a tab within a {@link TabGroup group of tabs}. + * Tabs are merely the graphical representation within the editor area. + * A backing editor is not a guarantee. + */ + export interface Tab { + + /** + * The text displayed on the tab. + * @stubbed + */ + readonly label: string; + + /** + * The group which the tab belongs to. + * @stubbed + */ + readonly group: TabGroup; + + /** + * Defines the structure of the tab i.e. text, notebook, custom, etc. + * Resource and other useful properties are defined on the tab kind. + * @stubbed + */ + readonly input: TabInputText | TabInputTextDiff | TabInputCustom | TabInputWebview | TabInputNotebook | TabInputNotebookDiff | TabInputTerminal | unknown; + + /** + * Whether or not the tab is currently active. + * This is dictated by being the selected tab in the group. + * @stubbed + */ + readonly isActive: boolean; + + /** + * Whether or not the dirty indicator is present on the tab. + * @stubbed + */ + readonly isDirty: boolean; + + /** + * Whether or not the tab is pinned (pin icon is present). + * @stubbed + */ + readonly isPinned: boolean; + + /** + * Whether or not the tab is in preview mode. + * @stubbed + */ + readonly isPreview: boolean; + } + + /** + * An event describing change to tabs. + */ + export interface TabChangeEvent { + /** + * The tabs that have been opened. + * @stubbed + */ + readonly opened: readonly Tab[]; + /** + * The tabs that have been closed. + * @stubbed + */ + readonly closed: readonly Tab[]; + /** + * Tabs that have changed, e.g have changed + * their {@link Tab.isActive active} state. + * @stubbed + */ + readonly changed: readonly Tab[]; + } + + /** + * An event describing changes to tab groups. + */ + export interface TabGroupChangeEvent { + /** + * Tab groups that have been opened. + * @stubbed + */ + readonly opened: readonly TabGroup[]; + /** + * Tab groups that have been closed. + * @stubbed + */ + readonly closed: readonly TabGroup[]; + /** + * Tab groups that have changed, e.g have changed + * their {@link TabGroup.isActive active} state. + * @stubbed + */ + readonly changed: readonly TabGroup[]; + } + + /** + * Represents a group of tabs. A tab group itself consists of multiple tabs. + */ + export interface TabGroup { + /** + * Whether or not the group is currently active. + * + * *Note* that only one tab group is active at a time, but that multiple tab + * groups can have an {@link TabGroup.aciveTab active tab}. + * + * @see {@link Tab.isActive} + * @stubbed + */ + readonly isActive: boolean; + + /** + * The view column of the group. + * @stubbed + */ + readonly viewColumn: ViewColumn; + + /** + * The active {@link Tab tab} in the group. This is the tab whose contents are currently + * being rendered. + * + * *Note* that there can be one active tab per group but there can only be one {@link TabGroups.activeTabGroup active group}. + * @stubbed + */ + readonly activeTab: Tab | undefined; + + /** + * The list of tabs contained within the group. + * This can be empty if the group has no tabs open. + * @stubbed + */ + readonly tabs: readonly Tab[]; + } + + /** + * Represents the main editor area which consists of multple groups which contain tabs. + */ + export interface TabGroups { + /** + * All the groups within the group container. + * @stubbed + */ + readonly all: readonly TabGroup[]; + + /** + * The currently active group. + * @stubbed + */ + readonly activeTabGroup: TabGroup; + + /** + * An {@link Event event} which fires when {@link TabGroup tab groups} have changed. + * @stubbed + */ + readonly onDidChangeTabGroups: Event; + + /** + * An {@link Event event} which fires when {@link Tab tabs} have changed. + * @stubbed + */ + readonly onDidChangeTabs: Event; + + /** + * Closes the tab. This makes the tab object invalid and the tab + * should no longer be used for further actions. + * Note: In the case of a dirty tab, a confirmation dialog will be shown which may be cancelled. If cancelled the tab is still valid + * + * @param tab The tab to close. + * @param preserveFocus When `true` focus will remain in its current position. If `false` it will jump to the next tab. + * @returns A promise that resolves to `true` when all tabs have been closed. + * @stubbed + */ + close(tab: Tab | readonly Tab[], preserveFocus?: boolean): Thenable; + + /** + * Closes the tab group. This makes the tab group object invalid and the tab group + * should no longer be used for further actions. + * @param tabGroup The tab group to close. + * @param preserveFocus When `true` focus will remain in its current position. + * @returns A promise that resolves to `true` when all tab groups have been closed. + * @stubbed + */ + close(tabGroup: TabGroup | readonly TabGroup[], preserveFocus?: boolean): Thenable; + } + /** * Represents a notebook editor that is attached to a {@link NotebookDocument notebook}. */