feat: manually mirror opencoze's code from bytedance

Change-Id: I09a73aadda978ad9511264a756b2ce51f5761adf
This commit is contained in:
fanlv
2025-07-20 17:36:12 +08:00
commit 890153324f
14811 changed files with 1923430 additions and 0 deletions

View File

@@ -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,
// };
// }
}

View 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';

View 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 };

View 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[];
}