Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Tab API implementation #12109

Merged
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions packages/core/src/browser/shell/application-shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,9 @@ export class DockPanelRenderer implements DockLayout.IRenderer {

readonly tabBarClasses: string[] = [];

private readonly _onDidCreateTabBar = new Emitter<TabBar<Widget>>();
readonly onDidCreateTabBar = this._onDidCreateTabBar.event;

constructor(
@inject(TabBarRendererFactory) protected readonly tabBarRendererFactory: TabBarRendererFactory,
@inject(TabBarToolbarRegistry) protected readonly tabBarToolbarRegistry: TabBarToolbarRegistry,
Expand All @@ -115,6 +118,7 @@ export class DockPanelRenderer implements DockLayout.IRenderer {
tabBar.disposed.connect(() => renderer.dispose());
renderer.contextMenuPath = SHELL_TABBAR_CONTEXT_MENU;
tabBar.currentChanged.connect(this.onCurrentTabChanged, this);
this._onDidCreateTabBar.fire(tabBar);
return tabBar;
}

Expand Down Expand Up @@ -221,6 +225,11 @@ export class ApplicationShell extends Widget {
@inject(TheiaDockPanel.Factory)
protected readonly dockPanelFactory: TheiaDockPanel.Factory;

private _mainPanelRenderer: DockPanelRenderer;
get mainPanelRenderer(): DockPanelRenderer {
return this._mainPanelRenderer;
}

/**
* Construct a new application shell.
*/
Expand Down Expand Up @@ -496,6 +505,7 @@ export class ApplicationShell extends Widget {
const renderer = this.dockPanelRendererFactory();
renderer.tabBarClasses.push(MAIN_BOTTOM_AREA_CLASS);
renderer.tabBarClasses.push(MAIN_AREA_CLASS);
this._mainPanelRenderer = renderer;
const dockPanel = this.dockPanelFactory({
mode: 'multiple-document',
renderer,
Expand Down
1 change: 1 addition & 0 deletions packages/plugin-ext/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"@theia/core": "1.34.0",
"@theia/debug": "1.34.0",
"@theia/editor": "1.34.0",
"@theia/editor-preview": "1.34.0",
"@theia/file-search": "1.34.0",
"@theia/filesystem": "1.34.0",
"@theia/markers": "1.34.0",
Expand Down
2 changes: 1 addition & 1 deletion packages/plugin-ext/src/common/plugin-api-rpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2037,7 +2037,7 @@ export interface TabOperation {
export interface TabDto {
id: string;
label: string;
input: any;
input: AnyInputDto;
editorId?: string;
isActive: boolean;
isPinned: boolean;
Expand Down
4 changes: 4 additions & 0 deletions packages/plugin-ext/src/main/browser/main-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ import { WebviewViewsMainImpl } from './webview-views/webview-views-main';
import { MonacoLanguages } from '@theia/monaco/lib/browser/monaco-languages';
import { UntitledResourceResolver } from '@theia/core/lib/common/resource';
import { ThemeService } from '@theia/core/lib/browser/theming';
import { TabsMainImpl } from './tabs/tabs-main';

export function setUpPluginApi(rpc: RPCProtocol, container: interfaces.Container): void {
const authenticationMain = new AuthenticationMainImpl(rpc, container);
Expand Down Expand Up @@ -180,4 +181,7 @@ export function setUpPluginApi(rpc: RPCProtocol, container: interfaces.Container

const commentsMain = new CommentsMainImp(rpc, container);
rpc.set(PLUGIN_RPC_CONTEXT.COMMENTS_MAIN, commentsMain);

const tabsMain = new TabsMainImpl(rpc, container);
rpc.set(PLUGIN_RPC_CONTEXT.TABS_MAIN, tabsMain);
}
285 changes: 279 additions & 6 deletions packages/plugin-ext/src/main/browser/tabs/tabs-main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,28 +15,301 @@
// *****************************************************************************

import { interfaces } from '@theia/core/shared/inversify';

import { TabsMain } from '../../../common/plugin-api-rpc';
import { ApplicationShell, PINNED_CLASS, Saveable, TabBar, Title, ViewContainer, Widget } from '@theia/core/lib/browser';
import { AnyInputDto, MAIN_RPC_CONTEXT, TabDto, TabGroupDto, TabInputKind, TabModelOperationKind, TabsExt, TabsMain } from '../../../common/plugin-api-rpc';
import { RPCProtocol } from '../../../common/rpc-protocol';
import { EditorPreviewWidget } from '@theia/editor-preview/lib/browser/editor-preview-widget';
import { Disposable } from '@theia/core/shared/vscode-languageserver-protocol';
import { MonacoDiffEditor } from '@theia/monaco/lib/browser/monaco-diff-editor';
import { toUriComponents } from '../hierarchy/hierarchy-types-converters';
import { TerminalWidget } from '@theia/terminal/lib/browser/base/terminal-widget';

interface TabInfo {
tab: TabDto;
tabIndex: number;
group: TabGroupDto;
}

export class TabsMainImpl implements TabsMain, Disposable {

private readonly proxy: TabsExt;
private tabGroupModel = new Map<TabBar<Widget>, TabGroupDto>();
private tabInfoLookup = new Map<Title<Widget>, TabInfo>();

private applicationShell: ApplicationShell;

private disposableTabBarListeners: Disposable[] = [];
msujew marked this conversation as resolved.
Show resolved Hide resolved
private toDisposeOnDestroy: Disposable[] = [];

export class TabsMainImp implements TabsMain {
private groupIdCounter = 0;
private currentActiveGroup: TabGroupDto;

private tabGroupChanged: boolean = false;

constructor(
rpc: RPCProtocol,
container: interfaces.Container
) {}
) {
this.proxy = rpc.getProxy(MAIN_RPC_CONTEXT.TABS_EXT);

this.applicationShell = container.get(ApplicationShell);
this.createTabsModel();

const tabBars = this.applicationShell.mainPanel.tabBars();
for (let tabBar; tabBar = tabBars.next();) {
this.attachListenersToTabBar(tabBar);
}

this.toDisposeOnDestroy.push(
this.applicationShell.mainPanelRenderer.onDidCreateTabBar(tabBar => {
this.attachListenersToTabBar(tabBar);
this.onTabGroupCreated(tabBar);
})
);

this.connectToSignal(this.toDisposeOnDestroy, this.applicationShell.mainPanel.widgetAdded, (mainPanel, widget) => {
if (this.tabGroupChanged || this.tabGroupModel.size === 0) {
this.tabGroupChanged = false;
this.createTabsModel();
} else {
const tabBar = mainPanel.findTabBar(widget.title)!;
const oldTabInfo = this.tabInfoLookup.get(widget.title);
tsmaeder marked this conversation as resolved.
Show resolved Hide resolved
const group = this.tabGroupModel.get(tabBar);
if (group !== oldTabInfo?.group) {
if (oldTabInfo) {
this.onTabClosed(oldTabInfo, widget.title);
}

this.onTabCreated(tabBar, { index: tabBar.titles.indexOf(widget.title), title: widget.title });
}
}
});

this.connectToSignal(this.toDisposeOnDestroy, this.applicationShell.mainPanel.widgetRemoved, (mainPanel, widget) => {
if (!(widget instanceof TabBar)) {
const tabInfo = this.getOrRebuildModel(this.tabInfoLookup, widget.title)!;
this.onTabClosed(tabInfo, widget.title);
if (this.tabGroupChanged) {
this.tabGroupChanged = false;
this.createTabsModel();
}
}
});
}

protected createTabsModel(): void {
const newTabGroupModel = new Map<TabBar<Widget>, TabGroupDto>();
this.tabInfoLookup.clear();
this.disposableTabBarListeners.forEach(disposable => disposable.dispose());
this.applicationShell.mainAreaTabBars.forEach(tabBar => {
this.attachListenersToTabBar(tabBar);

const groupDto = this.createTabGroupDto(tabBar);
tabBar.titles.forEach((title, index) => this.tabInfoLookup.set(title, { group: groupDto, tab: groupDto.tabs[index], tabIndex: index }));
newTabGroupModel.set(tabBar, groupDto);
});
if (newTabGroupModel.size > 0 && Array.from(newTabGroupModel.values()).indexOf(this.currentActiveGroup) < 0) {
this.currentActiveGroup = this.tabInfoLookup.get(this.applicationShell.mainPanel.currentTitle!)?.group ?? newTabGroupModel.values().next().value;
this.currentActiveGroup.isActive = true;
}
this.tabGroupModel = newTabGroupModel;
this.proxy.$acceptEditorTabModel(Array.from(this.tabGroupModel.values()));
}

protected createTabDto(tabTitle: Title<Widget>, groupId: number): TabDto {
const widget = tabTitle.owner;
return {
id: this.generateTabId(tabTitle, groupId),
label: tabTitle.label,
input: this.evaluateTabDtoInput(widget),
isActive: tabTitle.owner.isVisible,
isPinned: tabTitle.className.includes(PINNED_CLASS),
isDirty: Saveable.isDirty(widget),
isPreview: widget instanceof EditorPreviewWidget && widget.isPreview
};
}

protected generateTabId(tabTitle: Title<Widget>, groupId: number): string {
msujew marked this conversation as resolved.
Show resolved Hide resolved
return `${groupId}~${tabTitle.owner.id}`;
}

protected createTabGroupDto(tabBar: TabBar<Widget>): TabGroupDto {
const oldDto = this.tabGroupModel.get(tabBar);
const groupId = oldDto ? oldDto.groupId : this.groupIdCounter++;
const tabs: TabDto[] = tabBar.titles.map(title => {
const tabDto = this.createTabDto(title, groupId);
return tabDto;
});
const viewColumn = 1;
return {
groupId, tabs, isActive: false, viewColumn
};
}

protected attachListenersToTabBar(tabBar: TabBar<Widget> | undefined): void {
if (!tabBar) {
return;
}
tabBar.titles.forEach(title => {
this.connectToSignal(this.disposableTabBarListeners, title.changed, this.onTabTitleChanged);
});

this.connectToSignal(this.disposableTabBarListeners, tabBar.tabMoved, this.onTabMoved);
this.connectToSignal(this.disposableTabBarListeners, tabBar.disposed, this.onTabGroupClosed);
}

protected evaluateTabDtoInput(widget: Widget): AnyInputDto {
if (widget instanceof EditorPreviewWidget) {
if (widget.editor instanceof MonacoDiffEditor) {
return {
kind: TabInputKind.TextDiffInput,
original: toUriComponents(widget.editor.originalModel.uri),
modified: toUriComponents(widget.editor.modifiedModel.uri)
};
} else {
return {
kind: TabInputKind.TextInput,
uri: toUriComponents(widget.editor.uri.toString())
};
}
// TODO notebook support when implemented
} else if (widget instanceof ViewContainer) {
return {
kind: TabInputKind.WebviewEditorInput,
viewType: widget.id
};
} else if (widget instanceof TerminalWidget) {
return {
kind: TabInputKind.TerminalEditorInput
};
}

return { kind: TabInputKind.UnknownInput };
}

protected connectToSignal<T>(disposableList: Disposable[], signal: { connect(listener: T, context: unknown): void, disconnect(listener: T): void }, listener: T): void {
signal.connect(listener, this);
disposableList.push(Disposable.create(() => signal.disconnect(listener)));
}

protected tabDtosEqual(a: TabDto, b: TabDto): boolean {
return a.isActive === b.isActive &&
a.isDirty === b.isDirty &&
a.isPinned === b.isPinned &&
a.isPreview === b.isPreview &&
a.id === b.id;
}

protected getOrRebuildModel<T, R>(map: Map<T, R>, key: T): R {
// something broke so we rebuild the model
let item = map.get(key);
msujew marked this conversation as resolved.
Show resolved Hide resolved
if (!item) {
this.createTabsModel();
item = map.get(key)!;
}
return item;
}

// #region event listeners
private onTabCreated(tabBar: TabBar<Widget>, args: TabBar.ITabActivateRequestedArgs<Widget>): void {
const group = this.getOrRebuildModel(this.tabGroupModel, tabBar);
this.connectToSignal(this.disposableTabBarListeners, args.title.changed, this.onTabTitleChanged);
const tabDto = this.createTabDto(args.title, group.groupId);
this.tabInfoLookup.set(args.title, { group, tab: tabDto, tabIndex: args.index });
group.tabs.splice(args.index, 0, tabDto);
this.proxy.$acceptTabOperation({
kind: TabModelOperationKind.TAB_OPEN,
index: args.index,
tabDto,
groupId: group.groupId
});
}

private onTabTitleChanged(title: Title<Widget>): void {
const tabInfo = this.getOrRebuildModel(this.tabInfoLookup, title);
if (!tabInfo) {
return;
}
const oldTabDto = tabInfo.tab;
const newTabDto = this.createTabDto(title, tabInfo.group.groupId);
if (newTabDto.isActive && !tabInfo.group.isActive) {
tabInfo.group.isActive = true;
this.currentActiveGroup.isActive = false;
this.currentActiveGroup = tabInfo.group;
this.proxy.$acceptTabGroupUpdate(tabInfo.group);
}
if (!this.tabDtosEqual(oldTabDto, newTabDto)) {
tabInfo.group.tabs[tabInfo.tabIndex] = newTabDto;
tabInfo.tab = newTabDto;
this.proxy.$acceptTabOperation({
kind: TabModelOperationKind.TAB_UPDATE,
index: tabInfo.tabIndex,
tabDto: newTabDto,
groupId: tabInfo.group.groupId
});
}
}

private onTabClosed(tabInfo: TabInfo, title: Title<Widget>): void {
tabInfo.group.tabs.splice(tabInfo.tabIndex, 1);
this.tabInfoLookup.delete(title);
this.proxy.$acceptTabOperation({
kind: TabModelOperationKind.TAB_CLOSE,
index: tabInfo.tabIndex,
tabDto: this.createTabDto(title, tabInfo.group.groupId),
groupId: tabInfo.group.groupId
});
}

private onTabMoved(tabBar: TabBar<Widget>, args: TabBar.ITabMovedArgs<Widget>): void {
const tabInfo = this.getOrRebuildModel(this.tabInfoLookup, args.title)!;
tabInfo.tabIndex = args.toIndex;
const tabDto = this.createTabDto(args.title, tabInfo.group.groupId);
tabInfo.group.tabs.splice(args.fromIndex, 1);
tabInfo.group.tabs.splice(args.toIndex, 0, tabDto);
this.proxy.$acceptTabOperation({
kind: TabModelOperationKind.TAB_MOVE,
tsmaeder marked this conversation as resolved.
Show resolved Hide resolved
index: args.toIndex,
tabDto,
groupId: tabInfo.group.groupId,
oldIndex: args.fromIndex
});
}

private onTabGroupCreated(tabBar: TabBar<Widget>): void {
this.tabGroupChanged = true;
}

private onTabGroupClosed(tabBar: TabBar<Widget>): void {
this.tabGroupChanged = true;
}
// #endregion

// #region Messages received from Ext Host
$moveTab(tabId: string, index: number, viewColumn: number, preserveFocus?: boolean): void {
return;
}

async $closeTab(tabIds: string[], preserveFocus?: boolean): Promise<boolean> {
return false;
const widgetIds = tabIds.map(tabId => tabId.substring(tabId.indexOf('~') + 1));
const widgets = widgetIds.map(e => this.applicationShell.getWidgetById(e)).filter((e): e is Widget => e !== undefined);
await this.applicationShell.closeMany(widgets);
return true;
}

async $closeGroup(groupIds: number[], preserveFocus?: boolean): Promise<boolean> {
return false;
for (const groupId of groupIds) {
const tabBar = Array.from(this.tabGroupModel.entries()).find(([bar, groupDto]) => groupDto.groupId === groupId)?.[0];
if (tabBar) {
this.applicationShell.closeTabs(tabBar);
}
}
return true;
}
// #endregion

dispose(): void {
this.toDisposeOnDestroy.forEach(disposable => disposable.dispose());
this.disposableTabBarListeners.forEach(disposable => disposable.dispose());
}
}
Loading