feat: manually mirror opencoze's code from bytedance
Change-Id: I09a73aadda978ad9511264a756b2ce51f5761adf
This commit is contained in:
@@ -0,0 +1,550 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { inject, injectable, optional } from 'inversify';
|
||||
import { Emitter, type RecursivePartial } from '@flowgram-adapter/common';
|
||||
import { NavigationHistory, type URI } from '@coze-project-ide/core';
|
||||
|
||||
import { WidgetManager } from '../widget-manager';
|
||||
import {
|
||||
STATUS_BAR_CONTENT,
|
||||
type StatusBarWidget,
|
||||
} from '../widget/react-widgets/status-bar-widget';
|
||||
import {
|
||||
ACTIVITY_BAR_CONTENT,
|
||||
type ActivityBarWidget,
|
||||
} from '../widget/react-widgets/activity-bar-widget';
|
||||
import { ReactWidget } from '../widget/react-widget';
|
||||
import {
|
||||
type SidePanelHandler,
|
||||
SidePanelHandlerFactory,
|
||||
} from '../widget/handlers/side-panel-handler';
|
||||
import { DockPanelRendererFactory } from '../widget/dock-panel-renderer-factory';
|
||||
import { type DockPanelRenderer } from '../widget/dock-panel-renderer';
|
||||
import { FlowDockPanel } from '../widget/dock-panel';
|
||||
import { type AbstractWidget } from '../widget/base-widget';
|
||||
import { createBoxLayout, createSplitLayout } from '../utils/layout';
|
||||
import { isURIMatch } from '../utils';
|
||||
import { type SplitOptions, type SplitScreenConfig } from '../types/view';
|
||||
import { LayoutPanelType } from '../types';
|
||||
import {
|
||||
type BoxLayout,
|
||||
BoxPanel,
|
||||
type DockLayout,
|
||||
type DockPanel,
|
||||
Panel,
|
||||
type SplitLayout,
|
||||
SplitPanel,
|
||||
type Title,
|
||||
Widget,
|
||||
} from '../lumino/widgets';
|
||||
import { PANEL_CLASS_NAME_MAP, SINGLE_MODE } from '../constants';
|
||||
import {
|
||||
applicationShellLayoutVersion,
|
||||
type Options,
|
||||
type LayoutData,
|
||||
} from './types';
|
||||
|
||||
export const ApplicationShellOptions = Symbol('ApplicationShellOptions');
|
||||
|
||||
interface ShellProps {
|
||||
createLayout?: (shell: ApplicationShell) => BoxLayout;
|
||||
splitScreenConfig?: SplitScreenConfig;
|
||||
disableFullScreen?: boolean;
|
||||
}
|
||||
|
||||
@injectable()
|
||||
export class ApplicationShell extends Widget {
|
||||
@inject(FlowDockPanel.Factory)
|
||||
protected readonly dockPanelFactory: FlowDockPanel.Factory;
|
||||
|
||||
@inject(WidgetManager)
|
||||
protected widgetManager: WidgetManager;
|
||||
|
||||
@inject(NavigationHistory)
|
||||
protected navigationHistory: NavigationHistory;
|
||||
|
||||
constructor(
|
||||
@inject(DockPanelRendererFactory)
|
||||
protected dockPanelRendererFactory: () => DockPanelRenderer,
|
||||
@inject(SidePanelHandlerFactory)
|
||||
protected readonly sidePanelHandlerFactory: () => SidePanelHandler,
|
||||
@inject(ApplicationShellOptions)
|
||||
@optional()
|
||||
options: RecursivePartial<Options> = {},
|
||||
) {
|
||||
super(options as Widget.IOptions);
|
||||
}
|
||||
|
||||
private _currentWidget?: ReactWidget;
|
||||
|
||||
private _currentWidgetParent?: Widget | null;
|
||||
|
||||
public disableFullScreen?: boolean;
|
||||
|
||||
leftPanelHandler: SidePanelHandler;
|
||||
|
||||
mainPanel: FlowDockPanel;
|
||||
|
||||
bottomPanel: FlowDockPanel;
|
||||
|
||||
bottomSplitLayout: SplitLayout;
|
||||
|
||||
leftRightSplitLayout: SplitLayout;
|
||||
|
||||
statusBar: Panel;
|
||||
|
||||
rightToolbar: Panel;
|
||||
|
||||
activityBar: Panel;
|
||||
|
||||
topPanel: Panel;
|
||||
|
||||
primarySidebar: Panel;
|
||||
|
||||
secondarySidebar: Panel;
|
||||
|
||||
activityBarWidget: ActivityBarWidget;
|
||||
|
||||
statusBarWidget: StatusBarWidget;
|
||||
|
||||
closeWidgetUriStack: URI[] = [];
|
||||
|
||||
/**
|
||||
* 当前 focus widget 变化
|
||||
*/
|
||||
protected readonly onCurrentWidgetChangeEmitter = new Emitter<
|
||||
AbstractWidget | undefined
|
||||
>();
|
||||
|
||||
readonly onCurrentWidgetChange = this.onCurrentWidgetChangeEmitter.event;
|
||||
|
||||
async init(props: ShellProps): Promise<void> {
|
||||
const { createLayout, splitScreenConfig, disableFullScreen } = props;
|
||||
this.disableFullScreen = disableFullScreen;
|
||||
this.mainPanel = this.createMainPanel({
|
||||
splitOptions: splitScreenConfig?.main?.splitOptions,
|
||||
...splitScreenConfig?.main?.dockPanelOptions,
|
||||
});
|
||||
this.bottomPanel = this.createBottomPanel({
|
||||
splitOptions: splitScreenConfig?.bottom?.splitOptions,
|
||||
...splitScreenConfig?.bottom?.dockPanelOptions,
|
||||
});
|
||||
this.bottomPanel.hide();
|
||||
|
||||
this.topPanel = this.createPanel(LayoutPanelType.TOP_BAR);
|
||||
// 扩展,目前暂时未用到。
|
||||
this.rightToolbar = this.createPanel(LayoutPanelType.RIGHT_BAR);
|
||||
// 默认模式下 rightToolbar 隐藏
|
||||
this.rightToolbar.hide();
|
||||
this.statusBar = this.createPanel(LayoutPanelType.STATUS_BAR);
|
||||
this.activityBar = this.createPanel(LayoutPanelType.ACTIVITY_BAR);
|
||||
this.secondarySidebar = this.createPanel(LayoutPanelType.SECONDARY_SIDEBAR);
|
||||
|
||||
// 创建左侧面板
|
||||
this.leftPanelHandler = this.sidePanelHandlerFactory();
|
||||
this.leftPanelHandler.create('left');
|
||||
this.leftPanelHandler.expand();
|
||||
this.primarySidebar = this.leftPanelHandler.contentPanel;
|
||||
const uri = PANEL_CLASS_NAME_MAP[LayoutPanelType.PRIMARY_SIDEBAR];
|
||||
this.primarySidebar.id = uri.displayName;
|
||||
this.widgetManager.setWidget(uri.toString(), this.primarySidebar);
|
||||
|
||||
// 默认右侧隐藏
|
||||
this.secondarySidebar.hide();
|
||||
this.layout = createLayout?.(this) || this.createLayout();
|
||||
|
||||
this.activityBarWidget =
|
||||
await this.widgetManager.getOrCreateWidgetFromURI(ACTIVITY_BAR_CONTENT);
|
||||
try {
|
||||
this.statusBarWidget =
|
||||
await this.widgetManager.getOrCreateWidgetFromURI(STATUS_BAR_CONTENT);
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
async addWidget(
|
||||
widget: AbstractWidget,
|
||||
options?: {
|
||||
area: LayoutPanelType;
|
||||
addOptions?: DockLayout.IAddOptions;
|
||||
mode?: DockPanel.Mode;
|
||||
},
|
||||
): Promise<void> {
|
||||
if (!widget.id) {
|
||||
console.error(
|
||||
'Widgets added to the application shell must have a unique id property.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
const { area, mode } = options || {};
|
||||
switch (area) {
|
||||
case LayoutPanelType.MAIN_PANEL:
|
||||
this.mainPanel.mode = mode || 'multiple-document';
|
||||
this.mainPanel.addWidget(widget, {
|
||||
addClickListener: true,
|
||||
...options?.addOptions,
|
||||
});
|
||||
break;
|
||||
case LayoutPanelType.TOP_BAR:
|
||||
this.topPanel.addWidget(widget);
|
||||
break;
|
||||
case LayoutPanelType.BOTTOM_PANEL:
|
||||
this.bottomPanel.addWidget(widget);
|
||||
break;
|
||||
case LayoutPanelType.STATUS_BAR:
|
||||
this.statusBar.addWidget(widget);
|
||||
break;
|
||||
case LayoutPanelType.ACTIVITY_BAR:
|
||||
this.activityBar.addWidget(widget);
|
||||
break;
|
||||
case LayoutPanelType.PRIMARY_SIDEBAR:
|
||||
this.primarySidebar.addWidget(widget);
|
||||
break;
|
||||
case LayoutPanelType.SECONDARY_SIDEBAR:
|
||||
this.secondarySidebar.addWidget(widget);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unexpected area: ${options?.area}`);
|
||||
}
|
||||
// topBar 和 statusbar 不监听
|
||||
if (
|
||||
area !== LayoutPanelType.STATUS_BAR &&
|
||||
area !== LayoutPanelType.TOP_BAR
|
||||
) {
|
||||
this.track(widget);
|
||||
}
|
||||
}
|
||||
|
||||
getWidgetArea(widget: AbstractWidget) {
|
||||
const { parent } = widget;
|
||||
switch (parent) {
|
||||
case this.mainPanel:
|
||||
return LayoutPanelType.MAIN_PANEL;
|
||||
case this.topPanel:
|
||||
return LayoutPanelType.TOP_BAR;
|
||||
case this.bottomPanel:
|
||||
return LayoutPanelType.BOTTOM_PANEL;
|
||||
case this.statusBar:
|
||||
return LayoutPanelType.STATUS_BAR;
|
||||
case this.activityBar:
|
||||
return LayoutPanelType.ACTIVITY_BAR;
|
||||
case this.primarySidebar:
|
||||
return LayoutPanelType.PRIMARY_SIDEBAR;
|
||||
case this.secondarySidebar:
|
||||
return LayoutPanelType.SECONDARY_SIDEBAR;
|
||||
}
|
||||
throw new Error(`Unknown widget area: ${widget.id}`);
|
||||
}
|
||||
|
||||
getPanelFromArea(area: LayoutPanelType) {
|
||||
switch (area) {
|
||||
case LayoutPanelType.TOP_BAR:
|
||||
return this.topPanel;
|
||||
case LayoutPanelType.ACTIVITY_BAR:
|
||||
return this.activityBar;
|
||||
case LayoutPanelType.BOTTOM_PANEL:
|
||||
return this.bottomPanel;
|
||||
case LayoutPanelType.PRIMARY_SIDEBAR:
|
||||
return this.primarySidebar;
|
||||
case LayoutPanelType.SECONDARY_SIDEBAR:
|
||||
return this.secondarySidebar;
|
||||
case LayoutPanelType.STATUS_BAR:
|
||||
return this.statusBar;
|
||||
case LayoutPanelType.RIGHT_BAR:
|
||||
return this.rightToolbar;
|
||||
default:
|
||||
return this.mainPanel;
|
||||
}
|
||||
}
|
||||
|
||||
// 给本地持久化消费使用的方法
|
||||
setCurrentWidget(widget?: ReactWidget) {
|
||||
this._currentWidget = widget;
|
||||
this._currentWidgetParent = widget?.parent;
|
||||
}
|
||||
|
||||
getCurrentWidget(
|
||||
area: LayoutPanelType.MAIN_PANEL | LayoutPanelType.BOTTOM_PANEL,
|
||||
): Widget | undefined {
|
||||
let title: Title<Widget> | null | undefined;
|
||||
switch (area) {
|
||||
case LayoutPanelType.MAIN_PANEL:
|
||||
title = this.mainPanel.currentTitle;
|
||||
break;
|
||||
case LayoutPanelType.BOTTOM_PANEL:
|
||||
title = this.bottomPanel.currentTitle;
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Illegal argument: ${area}`);
|
||||
}
|
||||
return title ? title.owner : undefined;
|
||||
}
|
||||
|
||||
get currentWidget(): ReactWidget | undefined {
|
||||
return this._currentWidget;
|
||||
}
|
||||
|
||||
protected createLayout(): BoxLayout {
|
||||
const bottomSplitLayout = createSplitLayout(
|
||||
[this.mainPanel, this.bottomPanel],
|
||||
[1, 0],
|
||||
{
|
||||
orientation: 'vertical',
|
||||
spacing: 0,
|
||||
},
|
||||
);
|
||||
this.bottomSplitLayout = bottomSplitLayout;
|
||||
const middleContentPanel = new SplitPanel({ layout: bottomSplitLayout });
|
||||
|
||||
const leftRightSplitLayout = createSplitLayout(
|
||||
[
|
||||
// 左边的不可伸缩 bar
|
||||
this.primarySidebar,
|
||||
middleContentPanel,
|
||||
// 右边的不可伸缩 bar
|
||||
this.secondarySidebar,
|
||||
],
|
||||
[0, 1, 0],
|
||||
{ orientation: 'horizontal', spacing: 0 },
|
||||
);
|
||||
this.leftRightSplitLayout = leftRightSplitLayout;
|
||||
const mainDockPanel = new SplitPanel({ layout: leftRightSplitLayout });
|
||||
|
||||
const centerLayout = createBoxLayout(
|
||||
[
|
||||
// 左边的不可伸缩 bar
|
||||
this.activityBar,
|
||||
mainDockPanel,
|
||||
// 右边的不可伸缩 bar
|
||||
this.rightToolbar,
|
||||
],
|
||||
[0, 1, 0],
|
||||
{ direction: 'left-to-right', spacing: 0 },
|
||||
);
|
||||
const centerPanel = new BoxPanel({ layout: centerLayout });
|
||||
|
||||
return createBoxLayout(
|
||||
[this.topPanel, centerPanel, this.statusBar],
|
||||
[0, 1, 0],
|
||||
{
|
||||
direction: 'top-to-bottom',
|
||||
spacing: 0,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
protected createPanel(type: LayoutPanelType): Panel {
|
||||
const panel = new Panel();
|
||||
const uri = PANEL_CLASS_NAME_MAP[type];
|
||||
panel.id = uri.displayName;
|
||||
this.widgetManager.setWidget(uri.toString(), panel);
|
||||
return panel;
|
||||
}
|
||||
|
||||
protected createBottomPanel(config: {
|
||||
splitOptions?: SplitOptions;
|
||||
}): FlowDockPanel {
|
||||
const BOTTOM_AREA_CLASS = 'flow-app-bottom';
|
||||
const renderer = this.dockPanelRendererFactory();
|
||||
(renderer as DockPanelRenderer).tabBarClasses.push(BOTTOM_AREA_CLASS);
|
||||
const dockPanel = this.dockPanelFactory({
|
||||
mode: 'multiple-document',
|
||||
renderer,
|
||||
spacing: 0,
|
||||
...config,
|
||||
});
|
||||
const uri = PANEL_CLASS_NAME_MAP[LayoutPanelType.BOTTOM_PANEL];
|
||||
dockPanel.id = uri.displayName;
|
||||
dockPanel.node.addEventListener('p-dragenter', event => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
// Make sure that the main panel hides its overlay when the bottom panel is expanded
|
||||
// this.mainPanel.overlay.hide(0);
|
||||
});
|
||||
this.widgetManager.setWidget(uri.toString(), dockPanel);
|
||||
return dockPanel;
|
||||
}
|
||||
|
||||
protected createMainPanel(config: {
|
||||
splitOptions?: SplitOptions;
|
||||
}): FlowDockPanel {
|
||||
const renderer = this.dockPanelRendererFactory();
|
||||
const dockPanel = this.dockPanelFactory({
|
||||
mode: 'multiple-document',
|
||||
renderer,
|
||||
spacing: 0,
|
||||
...config,
|
||||
});
|
||||
const uri = PANEL_CLASS_NAME_MAP[LayoutPanelType.MAIN_PANEL];
|
||||
dockPanel.id = uri.displayName;
|
||||
this.widgetManager.setWidget(uri.toString(), dockPanel);
|
||||
return dockPanel;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将当前选中的 widget 的 tab 滚动到视区内
|
||||
*/
|
||||
tabbarIntoView(behavior?: boolean) {
|
||||
const { mainPanel } = this;
|
||||
const widgets = mainPanel.tabBars();
|
||||
for (const customTabBar of widgets) {
|
||||
customTabBar?.titles?.forEach(title => {
|
||||
if (title?.owner?.id && title.owner.id === this.currentWidget?.id) {
|
||||
const currentTabId = `#shell-tab-${this.currentWidget?.uri?.displayName}`;
|
||||
const currentTabDom = customTabBar.node.querySelector(currentTabId);
|
||||
setTimeout(() => {
|
||||
currentTabDom?.scrollIntoView({
|
||||
behavior: behavior ? 'smooth' : 'auto',
|
||||
});
|
||||
}, 0);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Track the given widget so it is considered in the `current` and `active` state of the shell.
|
||||
*/
|
||||
track(widget: Widget): void {
|
||||
if (widget instanceof ReactWidget) {
|
||||
widget.onActivate(() => {
|
||||
this._currentWidget = widget;
|
||||
if (widget.parent) {
|
||||
this._currentWidgetParent = widget.parent;
|
||||
}
|
||||
this.onCurrentWidgetChangeEmitter.fire(widget);
|
||||
this.tabbarIntoView();
|
||||
});
|
||||
widget.onDispose(() => {
|
||||
const uri = widget.getResourceURI();
|
||||
|
||||
if (uri) {
|
||||
const index = this.closeWidgetUriStack.findIndex(p =>
|
||||
isURIMatch(p, uri),
|
||||
);
|
||||
// 有重复的先删除,再 push
|
||||
if (index !== -1) {
|
||||
this.closeWidgetUriStack.splice(index, 1);
|
||||
}
|
||||
this.closeWidgetUriStack.push(uri);
|
||||
}
|
||||
if (this._currentWidget === widget) {
|
||||
const nextWidget = (this._currentWidgetParent as FlowDockPanel)
|
||||
?.selectedWidgets?.()
|
||||
?.next?.()?.value;
|
||||
|
||||
this._currentWidget = nextWidget;
|
||||
this.onCurrentWidgetChangeEmitter.fire(nextWidget);
|
||||
this.tabbarIntoView();
|
||||
}
|
||||
if (!this.bottomPanel.selectedWidgets()?.next?.()?.value) {
|
||||
this.bottomPanel.hide();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/***********************************************************************
|
||||
* layout 相关
|
||||
*/
|
||||
|
||||
getLayoutData(): LayoutData {
|
||||
// 记录子分屏 layout 数据
|
||||
const widgets: Widget[] = [];
|
||||
this.primarySidebar.widgets.forEach(_widget => {
|
||||
widgets.push(_widget);
|
||||
});
|
||||
|
||||
return {
|
||||
version: applicationShellLayoutVersion,
|
||||
mainPanel: {
|
||||
...this.mainPanel.saveLayout(),
|
||||
mode: this.mainPanel.mode,
|
||||
},
|
||||
bottomPanel: {
|
||||
...this.bottomPanel.saveLayout(),
|
||||
expanded: this.bottomPanel.isHidden,
|
||||
},
|
||||
primarySidebar: {
|
||||
widgets,
|
||||
},
|
||||
split: {
|
||||
main: this.bottomSplitLayout.relativeSizes(),
|
||||
leftRight: this.leftRightSplitLayout.relativeSizes(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
setLayoutData(data: LayoutData) {
|
||||
const { version } = data;
|
||||
const layoutData = data;
|
||||
if (version && Number(version) > applicationShellLayoutVersion) {
|
||||
return;
|
||||
}
|
||||
const { mainPanel, bottomPanel, split } = layoutData;
|
||||
if (mainPanel) {
|
||||
this.mainPanel.restoreLayout(mainPanel);
|
||||
if (mainPanel.mode === SINGLE_MODE) {
|
||||
this.mainPanel.mode = SINGLE_MODE;
|
||||
}
|
||||
this.mainPanel.initWidgets();
|
||||
}
|
||||
if (bottomPanel) {
|
||||
const { expanded, ...bottomLayout } = bottomPanel;
|
||||
this.bottomPanel.restoreLayout(bottomLayout);
|
||||
if (!expanded) {
|
||||
this.bottomPanel.show();
|
||||
}
|
||||
}
|
||||
if (split?.main) {
|
||||
this.bottomSplitLayout.setRelativeSizes(split.main);
|
||||
(window as any).temp = this.bottomSplitLayout;
|
||||
}
|
||||
if (split?.leftRight) {
|
||||
this.leftRightSplitLayout.setRelativeSizes(split.leftRight);
|
||||
}
|
||||
}
|
||||
|
||||
// 下边是 tabs 切换,等 mainPanel 切换成 dockpanel 的时候启用
|
||||
// activateWithLabel(widget: Widget) {
|
||||
// this.mainPanel.activateWidget(widget);
|
||||
// }
|
||||
|
||||
// findTabBar(label: string): {
|
||||
// tabBar: TabBar<Widget> | undefined;
|
||||
// title: Title<Widget> | undefined;
|
||||
// index: number;
|
||||
// } {
|
||||
// let selectedTabBar = undefined;
|
||||
// let selectedTitle = undefined;
|
||||
// let selectedIndex = 0;
|
||||
// each(this.mainPanel.tabBars(), bar => {
|
||||
// bar.titles.forEach((title, idx) => {
|
||||
// if (title.label === label) {
|
||||
// title?.owner.activate();
|
||||
// selectedTabBar = bar;
|
||||
// selectedTitle = title;
|
||||
// selectedIndex = idx;
|
||||
// }
|
||||
// });
|
||||
// });
|
||||
// return {
|
||||
// tabBar: selectedTabBar,
|
||||
// title: selectedTitle,
|
||||
// index: selectedIndex,
|
||||
// };
|
||||
// }
|
||||
}
|
||||
22
frontend/packages/project-ide/view/src/shell/index.ts
Normal file
22
frontend/packages/project-ide/view/src/shell/index.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { ApplicationShell } from './application-shell';
|
||||
export {
|
||||
LayoutRestorer,
|
||||
type CustomPreferenceConfig,
|
||||
CustomPreferenceContribution,
|
||||
} from './layout-restorer';
|
||||
491
frontend/packages/project-ide/view/src/shell/layout-restorer.ts
Normal file
491
frontend/packages/project-ide/view/src/shell/layout-restorer.ts
Normal file
@@ -0,0 +1,491 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { isFunction } from 'lodash';
|
||||
import { inject, injectable, multiInject, optional } from 'inversify';
|
||||
import { Emitter, isObject, type Disposable } from '@flowgram-adapter/common';
|
||||
import {
|
||||
StorageService,
|
||||
WindowService,
|
||||
logger,
|
||||
URI,
|
||||
} from '@coze-project-ide/core';
|
||||
|
||||
import { WidgetManager } from '../widget-manager';
|
||||
import { type ReactWidget } from '../widget/react-widget';
|
||||
import { ViewRenderer } from '../view-renderer';
|
||||
import { type ViewPluginOptions } from '../types/view';
|
||||
import { type Widget } from '../lumino/widgets';
|
||||
import { type LayoutData } from './types';
|
||||
import { ApplicationShell } from './application-shell';
|
||||
|
||||
/**
|
||||
* 在会话之间存储和恢复 widget 其内部状态的接口
|
||||
*/
|
||||
interface StatefulWidget {
|
||||
/**
|
||||
* widget 内部的状态,返回 undefined 将不会保存
|
||||
*/
|
||||
storeState(): object | undefined;
|
||||
|
||||
/**
|
||||
* 复原存储的状态
|
||||
*/
|
||||
restoreState(state: object): void;
|
||||
}
|
||||
|
||||
interface PreferenceSettingEvent {
|
||||
key: string;
|
||||
|
||||
value: any;
|
||||
}
|
||||
|
||||
const CustomPreferenceContribution = Symbol('CustomPreferenceContribution');
|
||||
|
||||
interface CustomPreferenceContribution {
|
||||
/**
|
||||
* 注册 command
|
||||
*/
|
||||
registerCustomPreferences(restorer: LayoutRestorer): void;
|
||||
}
|
||||
|
||||
export interface CustomPreferenceConfig {
|
||||
/**
|
||||
* 该配置唯一 key
|
||||
*/
|
||||
key: string;
|
||||
/**
|
||||
* 标题
|
||||
*/
|
||||
title: string;
|
||||
/**
|
||||
* 描述
|
||||
*/
|
||||
descprition?: string;
|
||||
/**
|
||||
* 顺序
|
||||
*/
|
||||
order: number;
|
||||
/**
|
||||
* 值设置器配置
|
||||
*/
|
||||
setting:
|
||||
| {
|
||||
type: 'switch';
|
||||
}
|
||||
| {
|
||||
type: 'input';
|
||||
}
|
||||
| {
|
||||
type: 'checkbox';
|
||||
}
|
||||
| {
|
||||
type: 'option';
|
||||
optionList: Array<{
|
||||
label: string;
|
||||
value: string;
|
||||
}>;
|
||||
};
|
||||
/**
|
||||
* 默认值
|
||||
*/
|
||||
default: any;
|
||||
}
|
||||
|
||||
namespace StatefulWidget {
|
||||
export function is(arg: unknown): arg is StatefulWidget {
|
||||
return (
|
||||
isObject<StatefulWidget>(arg) &&
|
||||
isFunction(arg.storeState) &&
|
||||
isFunction(arg.restoreState)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
interface WidgetDescription {
|
||||
uriStr: string;
|
||||
innerWidgetState?: string;
|
||||
}
|
||||
|
||||
type RestoreState = LayoutData & {
|
||||
innerState?: Record<string, any>;
|
||||
};
|
||||
|
||||
/**
|
||||
* 整个 restore 的流程:
|
||||
*
|
||||
* -------------------- 初始化 --------------------
|
||||
* 1. 读取 options 配置,等待 DockPanel、SplitLayout 实例化完成
|
||||
* 2. 从数据源 (目前是 localStorage) 读取持久化数据,包含 layoutData 和 innerState
|
||||
* 3. 遍历 layoutData 中的 widget uri 根据保存的 uri 线索重新创建 widget
|
||||
* 3.1. 根据 uri 找到 Factory,根据 uri 和 Factory 创建 widget
|
||||
* 3.2. 用 portal 挂载,这是本 ide widget 的挂载方式决定的
|
||||
* 3.3. 运行 widget.init,这是 ReactWidget 决定的
|
||||
* 3.4. shell.stack(widget),持久化的 widget 全部都需要被 stack
|
||||
* 4. 依次 restore 各个 panel 和 layout
|
||||
*
|
||||
* -------------------- 运行中 --------------------
|
||||
* 5. widget dispose 时,将 state 存于 innerState
|
||||
* 6. widget create 时,从 innerState 中取数据回填
|
||||
* (注意,这里都是和内存交互,和持久化数据源无关)
|
||||
*
|
||||
* -------------------- 销毁 --------------------
|
||||
* 7. 应用销毁前读取 layoutData,和 innerState 一起存入持久化数据源
|
||||
* 7.1. layoutData 中的 widget 对象仅保留它的 uri,并且会转化为 string 的形式存储
|
||||
*
|
||||
* Q:为什么 innerState 作为内存 state 也需要被存储?
|
||||
* A:被打开又被关闭的 widget state 只存在 inner 中,没有被 layoutData 囊括
|
||||
*/
|
||||
|
||||
@injectable()
|
||||
class LayoutRestorer {
|
||||
static storageKey = 'layout';
|
||||
|
||||
@inject(ApplicationShell)
|
||||
protected readonly applicationShell: ApplicationShell;
|
||||
|
||||
@inject(ViewRenderer)
|
||||
protected readonly viewRenderer: ViewRenderer;
|
||||
|
||||
@inject(StorageService)
|
||||
protected readonly storageService: StorageService;
|
||||
|
||||
@inject(WindowService)
|
||||
protected readonly windowService: WindowService;
|
||||
|
||||
@inject(WidgetManager)
|
||||
protected readonly widgetManager: WidgetManager;
|
||||
|
||||
/**
|
||||
* 维护在内存中的持久化数据,在应用初始化时从源读取,但不会用于持久化初始化
|
||||
*/
|
||||
innerState: Record<string, any> = {};
|
||||
|
||||
initd = new Emitter<void>();
|
||||
|
||||
onDidInit = this.initd.event;
|
||||
|
||||
viewOptions: ViewPluginOptions | undefined;
|
||||
|
||||
storageKey = '';
|
||||
|
||||
disabled = false;
|
||||
|
||||
public customPreferenceConfig: CustomPreferenceConfig[] = [];
|
||||
|
||||
private customPreferenceValue: Record<string, any> = {};
|
||||
|
||||
private onCustomPreferenceChangeEmitter =
|
||||
new Emitter<PreferenceSettingEvent>();
|
||||
|
||||
public onCustomPreferenceChange = this.onCustomPreferenceChangeEmitter.event;
|
||||
|
||||
@multiInject(CustomPreferenceContribution)
|
||||
@optional()
|
||||
protected readonly contributions: CustomPreferenceContribution[] = [];
|
||||
|
||||
private unloadEvent: undefined | Disposable;
|
||||
|
||||
public init(options: ViewPluginOptions) {
|
||||
/** 没地方放,暂时放这里 */
|
||||
this.windowService.onStart();
|
||||
this.viewOptions = options;
|
||||
const { getStorageKey } = options || {};
|
||||
if (getStorageKey) {
|
||||
this.storageKey = getStorageKey();
|
||||
}
|
||||
this.disabled = this.storageService.getData(
|
||||
'layout/disabled/v2',
|
||||
!!options.restoreDisabled,
|
||||
);
|
||||
if (!this.disabled) {
|
||||
this.unloadEvent = this.windowService.onUnload(() => {
|
||||
logger.log('LayoutRestorer: unload');
|
||||
this.storeLayout();
|
||||
});
|
||||
}
|
||||
|
||||
(options.customPreferenceConfigs || []).forEach(v => {
|
||||
this.customPreferenceConfig.push(v);
|
||||
});
|
||||
|
||||
for (const contrib of this.contributions) {
|
||||
contrib.registerCustomPreferences(this);
|
||||
}
|
||||
|
||||
this.customPreferenceConfig.forEach(config => {
|
||||
this.customPreferenceValue[config.key] = this.storageService.getData(
|
||||
config.key,
|
||||
config.default,
|
||||
);
|
||||
});
|
||||
|
||||
this.initd.fire();
|
||||
}
|
||||
|
||||
public setCustomPreferenceValue(key: string, value: any): void {
|
||||
this.customPreferenceValue[key] = value;
|
||||
this.storageService.setData(key, value);
|
||||
this.onCustomPreferenceChangeEmitter.fire({
|
||||
key,
|
||||
value,
|
||||
});
|
||||
}
|
||||
|
||||
public getCustomPreferenceValue(key: string): any {
|
||||
return this.customPreferenceValue[key];
|
||||
}
|
||||
|
||||
public registerCustomPreferenceConfig(config: CustomPreferenceConfig): void {
|
||||
this.customPreferenceConfig.push(config);
|
||||
}
|
||||
|
||||
public ban(v: boolean) {
|
||||
if (v === this.disabled) {
|
||||
return;
|
||||
}
|
||||
if (!v) {
|
||||
this.unloadEvent = this.windowService.onUnload(() => {
|
||||
logger.log('LayoutRestorer: unload');
|
||||
this.storeLayout();
|
||||
});
|
||||
} else {
|
||||
this.unloadEvent?.dispose();
|
||||
}
|
||||
this.disabled = v;
|
||||
this.storageService.setData('layout/disabled/v2', v);
|
||||
}
|
||||
|
||||
protected isWidgetProperty(propertyName: string): boolean {
|
||||
return propertyName === 'widget';
|
||||
}
|
||||
|
||||
protected isWidgetsProperty(propertyName: string): boolean {
|
||||
return propertyName === 'widgets';
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns the layout data to a string representation.
|
||||
*/
|
||||
protected deflate(data?: object): string | undefined {
|
||||
if (data === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return JSON.stringify(data, (property: string, value) => {
|
||||
if (this.isWidgetProperty(property)) {
|
||||
const description = this.convertToDescription(value as Widget);
|
||||
return description;
|
||||
} else if (this.isWidgetsProperty(property)) {
|
||||
const descriptions = [];
|
||||
for (const widget of value as Widget[]) {
|
||||
const description = this.convertToDescription(widget);
|
||||
if (description) {
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-ts-expect-error
|
||||
// @ts-ignore
|
||||
descriptions.push(description);
|
||||
}
|
||||
}
|
||||
return descriptions;
|
||||
} else if (property === 'currentUri' && value) {
|
||||
return (value as URI).toString();
|
||||
}
|
||||
return value;
|
||||
});
|
||||
}
|
||||
|
||||
protected async inflate(layoutData?: string): Promise<RestoreState> {
|
||||
if (layoutData === undefined) {
|
||||
return {};
|
||||
}
|
||||
const parseContext = new ShellLayoutRestorer.ParseContext();
|
||||
const layout = this.parse<RestoreState>(layoutData, parseContext);
|
||||
await parseContext.inflate();
|
||||
return layout;
|
||||
}
|
||||
|
||||
protected parse<T>(
|
||||
layoutData: string,
|
||||
parseContext: ShellLayoutRestorer.ParseContext,
|
||||
): T {
|
||||
return JSON.parse(layoutData, (property: string, value) => {
|
||||
if (this.isWidgetsProperty(property)) {
|
||||
const widgets = parseContext.filteredArray();
|
||||
const descs = value;
|
||||
for (let i = 0; i < descs.length; i++) {
|
||||
parseContext.push(async () => {
|
||||
widgets[i] = await this.convertToWidget(descs[i]);
|
||||
});
|
||||
}
|
||||
return widgets;
|
||||
} else if (isObject(value) && !Array.isArray(value)) {
|
||||
const copy: Record<string, unknown> = {};
|
||||
for (const p in value) {
|
||||
if (this.isWidgetProperty(p)) {
|
||||
parseContext.push(async () => {
|
||||
copy[p] = await this.convertToWidget(value[p] as any);
|
||||
});
|
||||
} else {
|
||||
copy[p] = value[p];
|
||||
}
|
||||
}
|
||||
return copy;
|
||||
} else if (property === 'currentUri' && value) {
|
||||
return new URI(value);
|
||||
}
|
||||
return value;
|
||||
});
|
||||
}
|
||||
|
||||
protected async convertToWidget(
|
||||
desc: WidgetDescription,
|
||||
): Promise<Widget | undefined> {
|
||||
if (!desc.uriStr) {
|
||||
return undefined;
|
||||
}
|
||||
const uri = new URI(desc.uriStr);
|
||||
const factory = this.widgetManager.getFactoryFromURI(uri);
|
||||
const widget = await this.widgetManager.getOrCreateWidgetFromURI(
|
||||
uri,
|
||||
factory,
|
||||
);
|
||||
this.viewRenderer.addReactPortal(widget);
|
||||
this.applicationShell.track(widget);
|
||||
|
||||
if (StatefulWidget.is(widget) && desc.innerWidgetState !== undefined) {
|
||||
let oldState: object;
|
||||
if (typeof desc.innerWidgetState === 'string') {
|
||||
const parseContext = new ShellLayoutRestorer.ParseContext();
|
||||
oldState = this.parse(desc.innerWidgetState, parseContext);
|
||||
await parseContext.inflate();
|
||||
} else {
|
||||
oldState = desc.innerWidgetState;
|
||||
}
|
||||
widget.restoreState(oldState);
|
||||
}
|
||||
if (widget.isDisposed) {
|
||||
return undefined;
|
||||
}
|
||||
return widget;
|
||||
}
|
||||
|
||||
private convertToDescription(widget: Widget): WidgetDescription | undefined {
|
||||
if (!(widget as ReactWidget).uri) {
|
||||
return;
|
||||
}
|
||||
return {
|
||||
uriStr: (widget as ReactWidget)?.uri?.toString() || '',
|
||||
innerWidgetState: StatefulWidget.is(widget)
|
||||
? this.deflate(widget.storeState())
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 持久化布局数据
|
||||
*/
|
||||
storeLayout() {
|
||||
if (this.disabled) {
|
||||
return;
|
||||
}
|
||||
const data = this.applicationShell.getLayoutData();
|
||||
const serializedData = this.deflate({
|
||||
...data,
|
||||
innerState: this.innerState,
|
||||
});
|
||||
logger.log('layout restorer data: ', serializedData);
|
||||
this.storageService.setData(
|
||||
LayoutRestorer.storageKey + this.storageKey,
|
||||
serializedData,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 取出布局数据
|
||||
*/
|
||||
async restoreLayout() {
|
||||
if (this.disabled) {
|
||||
return;
|
||||
}
|
||||
const serializedData = this.storageService.getData<string>(
|
||||
LayoutRestorer.storageKey + this.storageKey,
|
||||
);
|
||||
const { innerState, ...layoutData } = await this.inflate(serializedData);
|
||||
logger.log('layout restorer layout data:', layoutData);
|
||||
logger.log('layout restorer inner data:', innerState);
|
||||
if (innerState !== undefined && isObject(innerState)) {
|
||||
this.innerState = innerState;
|
||||
}
|
||||
this.applicationShell.setLayoutData(layoutData);
|
||||
}
|
||||
|
||||
storeWidget(widget: ReactWidget) {
|
||||
if (StatefulWidget.is(widget) && widget.uri) {
|
||||
const state = widget.storeState();
|
||||
this.innerState[widget.uri.toString()] = state;
|
||||
}
|
||||
}
|
||||
|
||||
restoreWidget(widget: ReactWidget) {
|
||||
if (StatefulWidget.is(widget) && widget.uri) {
|
||||
const key = widget.uri.toString();
|
||||
const state = this.innerState[key];
|
||||
if (state !== undefined) {
|
||||
widget.restoreState(state);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export namespace ShellLayoutRestorer {
|
||||
export class ParseContext {
|
||||
protected readonly toInflate: Inflate[] = [];
|
||||
|
||||
protected readonly toFilter: Widgets[] = [];
|
||||
|
||||
push(toInflate: Inflate): void {
|
||||
this.toInflate.push(toInflate);
|
||||
}
|
||||
|
||||
filteredArray(): Widgets {
|
||||
const array: Widgets = [];
|
||||
this.toFilter.push(array);
|
||||
return array;
|
||||
}
|
||||
|
||||
async inflate(): Promise<void> {
|
||||
const pending: Promise<void>[] = [];
|
||||
while (this.toInflate.length) {
|
||||
pending.push(this.toInflate.pop()!());
|
||||
}
|
||||
await Promise.all(pending);
|
||||
|
||||
if (this.toFilter.length) {
|
||||
this.toFilter.forEach(arr => {
|
||||
for (let i = 0; i < arr?.length; i++) {
|
||||
if (arr[i] === undefined) {
|
||||
arr.splice(i--, 1);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type Widgets = (Widget | undefined)[];
|
||||
export type Inflate = () => Promise<void>;
|
||||
}
|
||||
|
||||
export { LayoutRestorer, StatefulWidget, CustomPreferenceContribution };
|
||||
71
frontend/packages/project-ide/view/src/shell/types.ts
Normal file
71
frontend/packages/project-ide/view/src/shell/types.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { type DockPanel, type Widget } from '../lumino/widgets';
|
||||
|
||||
/**
|
||||
* 版本号控制向下不兼容问题
|
||||
*/
|
||||
export type ApplicationShellLayoutVersion =
|
||||
/** 初始化版本 */
|
||||
0.2;
|
||||
|
||||
export const applicationShellLayoutVersion: ApplicationShellLayoutVersion = 0.2;
|
||||
|
||||
/**
|
||||
* The areas of the application shell where widgets can reside.
|
||||
*/
|
||||
export type Area =
|
||||
| 'main'
|
||||
| 'top'
|
||||
| 'left'
|
||||
| 'right'
|
||||
| 'bottom'
|
||||
| 'secondaryWindow';
|
||||
|
||||
/**
|
||||
* General options for the application shell. These are passed on construction and can be modified
|
||||
* through dependency injection (`ApplicationShellOptions` symbol).
|
||||
*/
|
||||
export interface Options extends Widget.IOptions {}
|
||||
|
||||
export interface LayoutData {
|
||||
version?: string | ApplicationShellLayoutVersion;
|
||||
mainPanel?: DockPanel.ILayoutConfig & {
|
||||
mode: DockPanel.Mode;
|
||||
};
|
||||
primarySidebar?: {
|
||||
widgets?: Widget[];
|
||||
};
|
||||
bottomPanel?: DockPanel.ILayoutConfig & {
|
||||
// 是否折叠
|
||||
expanded?: boolean;
|
||||
};
|
||||
split?: {
|
||||
main?: number[];
|
||||
leftRight?: number[];
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Data to save and load the bottom panel layout.
|
||||
*/
|
||||
export interface BottomPanelLayoutData {
|
||||
config?: DockPanel.ILayoutConfig;
|
||||
size?: number;
|
||||
expanded?: boolean;
|
||||
pinned?: boolean[];
|
||||
}
|
||||
Reference in New Issue
Block a user