Skip to content

Commit

Permalink
Tab API Implementation (#12109)
Browse files Browse the repository at this point in the history
Signed-off-by: Jonah Iden <jonah.iden@typefox.io>
Co-authored-by: Jan Bicker <jan.bicker@typefox.io>
Co-authored-by: Paul Maréchal <paul.marechal@ericsson.com>
  • Loading branch information
3 people authored Feb 2, 2023
1 parent 2663bbc commit d59d527
Show file tree
Hide file tree
Showing 8 changed files with 604 additions and 351 deletions.
13 changes: 13 additions & 0 deletions packages/core/src/browser/shell/application-shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,13 +89,19 @@ export class DockPanelRenderer implements DockLayout.IRenderer {

readonly tabBarClasses: string[] = [];

private readonly onDidCreateTabBarEmitter = new Emitter<TabBar<Widget>>();

constructor(
@inject(TabBarRendererFactory) protected readonly tabBarRendererFactory: TabBarRendererFactory,
@inject(TabBarToolbarRegistry) protected readonly tabBarToolbarRegistry: TabBarToolbarRegistry,
@inject(TabBarToolbarFactory) protected readonly tabBarToolbarFactory: TabBarToolbarFactory,
@inject(BreadcrumbsRendererFactory) protected readonly breadcrumbsRendererFactory: BreadcrumbsRendererFactory,
) { }

get onDidCreateTabBar(): CommonEvent<TabBar<Widget>> {
return this.onDidCreateTabBarEmitter.event;
}

createTabBar(): TabBar<Widget> {
const renderer = this.tabBarRendererFactory();
const tabBar = new ToolbarAwareTabBar(
Expand All @@ -115,6 +121,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.onDidCreateTabBarEmitter.fire(tabBar);
return tabBar;
}

Expand Down Expand Up @@ -221,6 +228,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 +508,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);
}
293 changes: 287 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,309 @@
// *****************************************************************************

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';
import { DisposableCollection } from '@theia/core';

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: DisposableCollection = new DisposableCollection();
private toDisposeOnDestroy: DisposableCollection = new DisposableCollection();

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();
// tab Open event is done in backend
} else {
const tabBar = mainPanel.findTabBar(widget.title)!;
const oldTabInfo = this.tabInfoLookup.get(widget.title);
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.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.createTabId(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 createTabId(tabTitle: Title<Widget>, groupId: number): string {
return `${groupId}~${tabTitle.owner.id}`;
}

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

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: DisposableCollection, 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);
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,
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 widgets: Widget[] = [];
for (const tabId of tabIds) {
const cleanedId = tabId.substring(tabId.indexOf('~') + 1);
const widget = this.applicationShell.getWidgetById(cleanedId);
if (widget) {
widgets.push(widget);
}
}
await this.applicationShell.closeMany(widgets);
return true;
}

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

dispose(): void {
this.toDisposeOnDestroy.dispose();
this.disposableTabBarListeners.dispose();
}
}
Loading

0 comments on commit d59d527

Please sign in to comment.