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,46 @@
/*
* 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 } from 'inversify';
import {
type CommandContribution,
type CommandRegistry,
type CustomTitleType,
Command,
} from '@coze-project-ide/client';
import { ModalService, ModalType } from '@/services';
@injectable()
export class CloseConfirmContribution implements CommandContribution {
@inject(ModalService) private modalService: ModalService;
registerCommands(commands: CommandRegistry): void {
commands.registerCommand(Command.Default.VIEW_SAVING_WIDGET_CLOSE_CONFIRM, {
execute: (titles: CustomTitleType[]) => {
const hasUnsaved = titles.some(title => title?.saving);
if (hasUnsaved) {
this.modalService.onModalVisibleChangeEmitter.fire({
type: ModalType.CLOSE_CONFIRM,
options: titles,
});
} else {
titles.forEach(title => title?.owner?.close?.());
}
},
});
}
}

View File

@@ -0,0 +1,31 @@
/*
* 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 {
bindContributions,
definePluginCreator,
type PluginCreator,
CommandContribution,
} from '@coze-project-ide/client';
import { CloseConfirmContribution } from './close-confirm-contribution';
export const createCloseConfirmPlugin: PluginCreator<void> =
definePluginCreator({
onBind: ({ bind }) => {
bindContributions(bind, CloseConfirmContribution, [CommandContribution]);
},
});

View File

@@ -0,0 +1,154 @@
/*
* 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 { I18n } from '@coze-arch/i18n';
import {
definePluginCreator,
type PluginCreator,
MenuService,
Command,
CommandRegistry,
ApplicationShell,
type FlowDockPanel,
TabBar,
ShortcutsService,
} from '@coze-project-ide/client';
import { ViewService } from '../create-preset-plugin/view-service';
const CUSTOM_COMMAND = {
// 在左侧分屏打开
SPLIT_LEFT: {
id: 'view.custom.split-left',
label: I18n.t('project_ide_tabs_open_on_left'),
},
// 在右侧分屏打开
SPLIT_RIGHT: {
id: 'view.custom.split-right',
label: I18n.t('project_ide_tabs_open_on_right'),
},
REFRESH: {
id: 'view.custom.refresh-widget',
label: I18n.t('refresh_project_tags'),
},
};
function getAllTabsCount(dockPanel: FlowDockPanel): number {
let count = 0;
// 遍历 DockPanel 中的所有小部件
Array.from(dockPanel.children()).forEach(widget => {
if (widget instanceof TabBar) {
// 累计 TabBar 中的所有标签页数
count += widget.titles.length;
}
});
return count;
}
export const createContextMenuPlugin: PluginCreator<void> = definePluginCreator(
{
onInit(ctx) {
const menuService = ctx.container.get<MenuService>(MenuService);
const command = ctx.container.get<CommandRegistry>(CommandRegistry);
const viewService = ctx.container.get<ViewService>(ViewService);
const shell = ctx.container.get<ApplicationShell>(ApplicationShell);
const shortcutsService =
ctx.container.get<ShortcutsService>(ShortcutsService);
/**
* 更改标题
*/
// 更新 command 标题 label
command.updateCommand(Command.Default.VIEW_CLOSE_CURRENT_WIDGET, {
label: I18n.t('project_ide_tabs_close'),
});
command.updateCommand(Command.Default.VIEW_CLOSE_OTHER_WIDGET, {
label: I18n.t('project_ide_tabs_close_other_tabs'),
});
command.updateCommand(Command.Default.VIEW_CLOSE_ALL_WIDGET, {
label: I18n.t('project_ide_tabs_close_all'),
});
command.registerCommand(CUSTOM_COMMAND.REFRESH, {
execute: widget => {
widget.refresh();
},
});
shortcutsService.registerHandlers({
keybinding: 'alt r',
commandId: CUSTOM_COMMAND.REFRESH.id,
});
command.registerCommand(CUSTOM_COMMAND.SPLIT_LEFT, {
execute: widget => {
viewService.splitScreen('left', widget);
},
// 分屏功能在所有 tab 大于 1 时才可以使用
isEnabled: () => {
const tabCounts = getAllTabsCount(shell.mainPanel);
return tabCounts > 1;
},
});
command.registerCommand(CUSTOM_COMMAND.SPLIT_RIGHT, {
execute: widget => {
viewService.splitScreen('right', widget);
},
// 分屏功能在所有 tab 大于 1 时才可以使用
isEnabled: () => {
const tabCounts = getAllTabsCount(shell.mainPanel);
return tabCounts > 1;
},
});
/**
* 注册 menu
*/
// 关闭
menuService.addMenuItem({
command: Command.Default.VIEW_CLOSE_CURRENT_WIDGET,
selector: '.lm-TabBar-tab',
});
// 关闭其他
menuService.addMenuItem({
command: Command.Default.VIEW_CLOSE_OTHER_WIDGET,
selector: '.lm-TabBar-tab',
});
// 关闭所有
menuService.addMenuItem({
command: Command.Default.VIEW_CLOSE_ALL_WIDGET,
selector: '.lm-TabBar-tab',
});
// 刷新标签
menuService.addMenuItem({
command: CUSTOM_COMMAND.REFRESH.id,
selector: '.lm-TabBar-tab',
});
// 分割线
menuService.addMenuItem({
type: 'separator',
selector: '.lm-TabBar-tab',
});
// 向左分屏
menuService.addMenuItem({
command: CUSTOM_COMMAND.SPLIT_LEFT.id,
selector: '.lm-TabBar-tab',
});
// 向右分屏
menuService.addMenuItem({
command: CUSTOM_COMMAND.SPLIT_RIGHT.id,
selector: '.lm-TabBar-tab',
});
},
},
);

View File

@@ -0,0 +1,69 @@
/*
* 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 {
LabelHandler,
LifecycleContribution,
WindowService,
bindContributions,
} from '@coze-project-ide/client';
import {
ViewContribution,
definePluginCreator,
type PluginCreator,
} from '@coze-project-ide/client';
import {
ModalService,
ErrorService,
MessageEventService,
WsService,
} from '@/services';
import { ProjectIDEClientProps } from '../../types';
import { ViewService } from './view-service';
import { TooltipContribution } from './tooltip-contribution';
import { ProjectIDEServices } from './project-ide-services';
import { PresetContribution } from './preset-contribution';
import { LifecycleService } from './lifecycle-service';
export const createPresetPlugin: PluginCreator<ProjectIDEClientProps> =
definePluginCreator({
onBind: ({ bind }, opts) => {
bind(ProjectIDEClientProps).toConstantValue(opts);
bind(LifecycleService).toSelf().inSingletonScope();
bind(ViewService).toSelf().inSingletonScope();
bind(ModalService).toSelf().inSingletonScope();
bind(MessageEventService).toSelf().inSingletonScope();
bind(ErrorService).toSelf().inSingletonScope();
bind(WsService).toSelf().inSingletonScope();
bind(ProjectIDEServices).toSelf().inSingletonScope();
bindContributions(bind, PresetContribution, [
ViewContribution,
LifecycleContribution,
]);
bindContributions(bind, TooltipContribution, [LabelHandler]);
},
onStart: ctx => {
const windowService = ctx.container.get<WindowService>(WindowService);
windowService.onStart();
},
onDispose: ctx => {
const lifecycleService =
ctx.container.get<LifecycleService>(LifecycleService);
lifecycleService.dispose();
},
});

View File

@@ -0,0 +1,57 @@
/*
* 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, postConstruct } from 'inversify';
import {
ApplicationShell,
DisposableCollection,
type Disposable,
Emitter,
type CustomTitleType,
type Event,
EventService,
MenuService,
} from '@coze-project-ide/client';
@injectable()
export class LifecycleService implements Disposable {
@inject(ApplicationShell) shell: ApplicationShell;
@inject(EventService) eventService: EventService;
@inject(MenuService) menuService: MenuService;
protected readonly onFocusEmitter = new Emitter<CustomTitleType>();
readonly onFocus: Event<CustomTitleType> = this.onFocusEmitter.event;
private disposable = new DisposableCollection(this.onFocusEmitter);
@postConstruct()
init() {
this.disposable.push(
this.shell.mainPanel.onDidChangeCurrent(title => {
if (title) {
this.onFocusEmitter.fire(title as CustomTitleType);
}
}),
);
}
dispose() {
this.disposable.dispose();
}
}

View File

@@ -0,0 +1,288 @@
/*
* 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 } from 'inversify';
import { I18n } from '@coze-arch/i18n';
import {
type ViewContribution,
type ViewOptionRegisterService,
type URI,
LayoutPanelType,
type WidgetFactory,
type LifecycleContribution,
BoxPanel,
ApplicationShell,
WidgetManager,
CommandRegistry,
ShortcutsService,
ContextKeyService,
ToolbarAlign,
Command,
ViewRenderer,
type ReactWidget,
} from '@coze-project-ide/client';
import { ProjectIDEWidget } from '@/widgets/project-ide-widget';
import { PrimarySidebarWidget } from '@/widgets/primary-sidebar-widget';
import { ProjectIDEClientProps, type WidgetRegistry } from '@/types';
import { WsService } from '@/services';
import { WidgetContext } from '@/context/widget-context';
import { CustomCommand } from '@/constants';
import { customLayout } from '../../utils';
import {
SIDEBAR_URI,
UI_BUILDER_URI,
UI_BUILDER_CONTENT,
TOP_BAR_URI,
MAIN_PANEL_DEFAULT_URI,
SECONDARY_SIDEBAR_URI,
} from '../../constants';
import { withRegistryContent } from './with-registry-content';
import { WidgetService } from './widget-service';
import { ViewService } from './view-service';
import { ProjectIDEServices } from './project-ide-services';
@injectable()
export class PresetContribution
implements ViewContribution, LifecycleContribution
{
@inject(ProjectIDEClientProps) props: ProjectIDEClientProps;
@inject(WidgetManager) widgetManager: WidgetManager;
@inject(ContextKeyService) contextKeyService: ContextKeyService;
@inject(ProjectIDEServices) services: ProjectIDEServices;
@inject(CommandRegistry) commandRegistry: CommandRegistry;
@inject(ShortcutsService) shortcutsService: ShortcutsService;
@inject(ApplicationShell) shell: ApplicationShell;
@inject(ViewService) viewService: ViewService;
@inject(ViewRenderer) viewRenderer: ViewRenderer;
@inject(WsService) wsService: WsService;
onInit() {
this.wsService.init();
// register command
this.props.view.widgetRegistries.forEach(registry => {
if (registry.registerCommands) {
const commands = registry.registerCommands();
commands.forEach(cmd => {
const existCmd = this.commandRegistry.getCommand(cmd.id);
if (!existCmd) {
this.commandRegistry.registerCommand(
{
id: cmd.id,
label: cmd.label,
},
{
execute: props => {
const currentContext = this.contextKeyService.getContext(
'widgetContext',
) as WidgetContext;
cmd.execute(currentContext, props);
},
isEnabled: props => {
const currentUri = this.contextKeyService.getContext(
'widgetFocus',
) as URI;
const currentContext = this.contextKeyService.getContext(
'widgetContext',
) as WidgetContext;
if (
currentUri?.toString?.() &&
!registry.match.test(currentUri.toString()) &&
cmd.when === 'widgetFocus'
) {
return false;
}
return cmd.isEnable(currentContext, props);
},
},
);
}
});
}
if (registry.registerShortcuts) {
const shortcuts = registry.registerShortcuts();
shortcuts.forEach(shortcut => {
this.shortcutsService.registerHandlers({
commandId: shortcut.commandId,
keybinding: shortcut.keybinding,
preventDefault: shortcut.preventDefault,
});
});
}
if (registry.registerContextMenu) {
const menus = registry.registerContextMenu();
this.services.contextmenu.registerContextMenu(menus, registry.match);
}
});
// 覆写全屏逻辑
this.commandRegistry.unregisterCommand(Command.Default.VIEW_FULL_SCREEN);
this.commandRegistry.registerCommand(
{
id: Command.Default.VIEW_FULL_SCREEN,
label: I18n.t('project_ide_maximize'),
},
{
execute: () => {
this.viewService.switchFullScreenMode();
},
},
);
this.commandRegistry.registerCommand(
{
id: CustomCommand.RELOAD,
label: I18n.t('refresh_project_tags'),
},
{
execute: (widget?: ProjectIDEWidget) => {
if (!widget) {
const { currentWidget } = this.shell;
(currentWidget as ProjectIDEWidget)?.refresh?.();
} else {
widget.refresh();
}
},
},
);
this.shortcutsService.registerHandlers({
commandId: CustomCommand.RELOAD,
keybinding: 'alt r',
preventDefault: false,
});
}
private createLayout(shell: ApplicationShell) {
// 设置 panel 存储到 widgetManager
const uiBuilderPanel = new BoxPanel();
uiBuilderPanel.id = UI_BUILDER_URI.displayName;
this.widgetManager.setWidget(UI_BUILDER_URI.toString(), uiBuilderPanel);
return customLayout(shell, uiBuilderPanel);
}
private createWidget(factory: WidgetRegistry<any>, uri: URI) {
const childContainer = this.widgetManager.containerFactory.createChild();
childContainer.bind(ProjectIDEWidget).toSelf().inSingletonScope();
const widget = childContainer.get<ProjectIDEWidget>(ProjectIDEWidget);
const store = factory.createStore?.(uri);
childContainer.bind(WidgetService).toSelf().inSingletonScope();
const widgetService = childContainer.get(WidgetService);
widgetService.init(factory, this.props.view.widgetTitleRender);
const widgetContext: WidgetContext = {
uri,
store,
widget: widgetService,
services: this.services,
};
widget.context = widgetContext;
widget.container = childContainer;
childContainer.bind(WidgetContext).toConstantValue(widgetContext);
widget.render = withRegistryContent(factory);
return widget;
}
registerView(service: ViewOptionRegisterService): void {
const widgetFactories: WidgetFactory[] =
this.props.view.widgetRegistries.map(factory => ({
area: factory.area || LayoutPanelType.MAIN_PANEL,
match: factory.match,
createWidget: this.createWidget.bind(this, factory),
toolbarItems: this.props.view.preToolbar
? [
{
render: this.props.view.preToolbar as (
widget: ReactWidget,
) => React.ReactElement<any, any> | null,
align: ToolbarAlign.LEADING,
},
{
render: this.props.view.toolbar as (
widget: ReactWidget,
) => React.ReactElement<any, any> | null,
align: ToolbarAlign.TRAILING,
},
]
: [],
}));
service.register({
presetConfig: {
disableContextMenu: true,
splitScreenConfig: {
main: {
splitOptions: {
maxSplitCount: 2,
splitOrientation: 'horizontal', // 只支持水平分屏
},
dockPanelOptions: {
spacing: 6,
},
},
},
disableFullScreen: true,
},
widgetFactories: [
{
area: LayoutPanelType.MAIN_PANEL,
canHandle: UI_BUILDER_CONTENT.match.bind(UI_BUILDER_CONTENT),
render: this.props.view.uiBuilder,
},
{
area: LayoutPanelType.TOP_BAR,
canHandle: TOP_BAR_URI.match.bind(TOP_BAR_URI),
render: this.props.view.topBar,
},
{
area: LayoutPanelType.MAIN_PANEL,
canHandle: MAIN_PANEL_DEFAULT_URI.match.bind(MAIN_PANEL_DEFAULT_URI),
render: this.props.view.widgetDefaultRender,
},
{
area: LayoutPanelType.PRIMARY_SIDEBAR,
canHandle: SIDEBAR_URI.match.bind(SIDEBAR_URI),
widget: PrimarySidebarWidget,
},
{
area: LayoutPanelType.SECONDARY_SIDEBAR,
canHandle: SECONDARY_SIDEBAR_URI.match.bind(SECONDARY_SIDEBAR_URI),
render: this.props.view.secondarySidebar,
},
...widgetFactories,
],
defaultLayoutData: {
defaultWidgets: [TOP_BAR_URI],
},
customLayout: this.createLayout.bind(this),
});
}
onDispose() {
this.wsService.onDispose();
}
}

View File

@@ -0,0 +1,98 @@
/*
* 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 } from 'inversify';
import {
CommandService,
MenuService,
type URI,
ContextKeyService,
} from '@coze-project-ide/client';
import {
type ContextMenuService,
type MenuItem,
type CommandService as CustomCommandService,
} from '@/types/services';
import { ViewService } from './view-service';
/**
* 获取 service 操作
* 全局任意位置均可调用
* command命令系统注册
* contextmenu右键菜单注册
* view视图操作
*/
@injectable()
export class ProjectIDEServices {
@inject(CommandService)
private commandService: CommandService;
@inject(ContextKeyService)
private contextKeyService: ContextKeyService;
@inject(MenuService)
private menu: MenuService;
@inject(ViewService)
public view: ViewService;
private registerMenus(options: MenuItem[], match?: RegExp) {
const filter = () => {
const currentUri = this.contextKeyService.getContext(
'widgetFocus',
) as URI;
return Boolean(match?.test?.(currentUri.toString()));
};
options.forEach(option => {
if (!option.submenu) {
this.menu.addMenuItem({
command: option.commandId,
selector: option.selector,
filter,
});
} else {
const submenu = this.menu.createSubMenu();
this.menu.addMenuItem({
command: option.commandId,
selector: option.selector,
submenu,
filter,
});
option.submenu.forEach(sub => {
submenu.addItem({
command: sub.commandId,
filter,
});
});
}
});
}
public contextmenu: ContextMenuService = {
registerContextMenu: (options: MenuItem[], match?: RegExp) => {
this.registerMenus(options, match);
},
open: e => this.menu.open(e),
};
public command: CustomCommandService = {
execute: (id, ...args) => {
this.commandService.executeCommand(id, ...args);
},
};
}

View File

@@ -0,0 +1,69 @@
/*
* 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 React from 'react';
import { inject, injectable, postConstruct } from 'inversify';
import {
type URI,
type LabelHandler,
HoverService,
} from '@coze-project-ide/client';
import { Tooltip } from '@coze-arch/coze-design';
// 自定义 IDE HoverService 样式
@injectable()
class TooltipContribution implements LabelHandler {
@inject(HoverService) hoverService: HoverService;
visible = false;
@postConstruct()
init() {
this.hoverService.enableCustomHoverHost();
}
canHandle(uri: URI): number {
return 500;
}
renderer(uri: URI, opt?: any): React.ReactNode {
// 下边的 opacity、width 设置原因:
// semi 源码位置https://github.com/DouyinFE/semi-design/blob/main/packages/semi-foundation/tooltip/foundation.ts#L342
// semi 有 trigger 元素判断,本次自定义 semi 组件没有 focus 内部元素。
return opt?.content ? (
<Tooltip
key={opt.content}
content={opt.content}
position={opt.position}
// 覆盖设置重置 foundation opacity避免 tooltip 跳动
style={{ opacity: 1 }}
trigger="custom"
getPopupContainer={() => document.body}
visible={true}
>
{/* 宽度 0 避免被全局样式影响导致 Tooltip 定位错误 */}
<div style={{ width: 0 }}></div>
</Tooltip>
) : null;
}
onDispose() {
return;
}
}
export { TooltipContribution };

View File

@@ -0,0 +1,259 @@
/*
* 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 } from 'inversify';
import {
ApplicationShell,
WidgetManager,
type URI,
type BoxPanel,
OpenerService,
Emitter,
type Event,
type DockLayout,
type ReactWidget,
LayoutPanelType,
ViewRenderer,
} from '@coze-project-ide/client';
import { type ProjectIDEWidget } from '@/widgets/project-ide-widget';
import { type WidgetContext } from '@/context';
import {
UI_BUILDER_URI,
MAIN_PANEL_DEFAULT_URI,
UI_BUILDER_CONTENT,
SECONDARY_SIDEBAR_URI,
} from '@/constants';
import { type ModeType } from '../../types';
@injectable()
export class ViewService {
@inject(ApplicationShell)
public shell: ApplicationShell;
@inject(WidgetManager)
private widgetManager: WidgetManager;
@inject(OpenerService)
openerService: OpenerService;
@inject(ViewRenderer)
private viewRenderer: ViewRenderer;
public isFullScreenMode = false;
protected readonly onSidebarVisibleChangeEmitter = new Emitter<boolean>();
readonly onSidebarVisibleChange: Event<boolean> =
this.onSidebarVisibleChangeEmitter.event;
protected readonly onSecondarySidebarVisibleChangeEmitter =
new Emitter<boolean>();
readonly onSecondarySidebarChange: Event<boolean> =
this.onSecondarySidebarVisibleChangeEmitter.event;
protected readonly onFullScreenModeChangeEmitter = new Emitter<boolean>();
readonly onFullScreenModeChange: Event<boolean> =
this.onFullScreenModeChangeEmitter.event;
/**
* 主侧边栏功能集合
*/
public primarySidebar = {
onSidebarVisibleChange: this.onSidebarVisibleChange,
getVisible: () => this.shell.primarySidebar.isVisible,
changeVisible: (vis: boolean) => {
if (vis) {
this.shell.primarySidebar.show();
this.onSidebarVisibleChangeEmitter.fire(true);
} else {
this.shell.primarySidebar.hide();
this.onSidebarVisibleChangeEmitter.fire(false);
}
},
};
public secondarySidebar = {
getVisible: () => this.shell.secondarySidebar.isVisible,
changeVisible: (vis: boolean) => {
if (vis) {
// 打开前需要判断面板是否已经注册打开
const secondaryPanel = this.widgetManager.getWidgetFromURI(
SECONDARY_SIDEBAR_URI,
);
if (!secondaryPanel) {
this.openerService.open(SECONDARY_SIDEBAR_URI);
}
this.shell.secondarySidebar.show();
this.onSecondarySidebarVisibleChangeEmitter.fire(true);
} else {
this.shell.secondarySidebar.hide();
this.onSecondarySidebarVisibleChangeEmitter.fire(false);
}
},
};
private switchPanel(uri?: URI) {
const uiBuilderPanel = this.widgetManager.getWidgetFromURI(UI_BUILDER_URI);
if (uri && UI_BUILDER_URI.match(uri)) {
// 跳转到 UIBuilder
(this.shell.mainPanel.parent?.parent as BoxPanel).hide();
uiBuilderPanel?.show();
} else {
uiBuilderPanel?.hide();
(this.shell.mainPanel.parent?.parent as BoxPanel).show();
}
}
async uiBuilderReopen() {
const uiBuilderWidget =
await this.widgetManager.getOrCreateWidgetFromURI(UI_BUILDER_CONTENT);
uiBuilderWidget.dispose();
this.openPanel('ui-builder');
}
secondarySidebarReOpen() {
if (!this.secondarySidebar.getVisible()) {
return;
}
const secondaryPanel = this.widgetManager.getWidgetFromURI(
SECONDARY_SIDEBAR_URI,
);
secondaryPanel?.dispose();
this.openerService.open(SECONDARY_SIDEBAR_URI);
}
async open(uri: URI) {
this.switchPanel(uri);
// openService
await this.openerService.open(uri);
}
async openPanel(type?: ModeType) {
if (type === 'ui-builder') {
this.switchPanel(UI_BUILDER_URI);
const factory = this.widgetManager.getFactoryFromURI(UI_BUILDER_CONTENT)!;
const uiBuilderWidget = await this.widgetManager.getOrCreateWidgetFromURI(
UI_BUILDER_CONTENT,
factory,
);
this.viewRenderer.addReactPortal(uiBuilderWidget);
if (!uiBuilderWidget?.isAttached && uiBuilderWidget) {
const uiBuilderPanel = this.widgetManager.getWidgetFromURI(
UI_BUILDER_URI,
) as BoxPanel;
uiBuilderPanel?.addWidget?.(uiBuilderWidget);
}
} else {
this.switchPanel();
}
}
// 打开默认页
async openDefault() {
await this.openerService.open(MAIN_PANEL_DEFAULT_URI, {
mode: 'single-document',
});
}
closeWidgetByUri(uri: URI) {
const widget = this.widgetManager.getWidgetFromURI(uri);
if (widget) {
widget.close();
}
}
getWidgetContextFromURI<T>(uri: URI): WidgetContext<T> | undefined {
const widgetFromURI = this.widgetManager.getWidgetFromURI(
uri,
) as ProjectIDEWidget;
if (widgetFromURI) {
return widgetFromURI.context;
}
return undefined;
}
// 由于最大分屏数量为 2
// 因此 children[0] 为左边分屏children[1] 为右边分屏
splitScreen(direction: 'left' | 'right', widget: ReactWidget) {
const mode = direction === 'left' ? 'split-left' : 'split-right';
const splitScreenIdx = direction === 'left' ? 0 : 1;
const layoutConfig = (
this.shell.mainPanel?.layout as DockLayout
)?.saveLayout()?.main;
// 未分屏场景,直接打开
if ((layoutConfig as DockLayout.ITabAreaConfig)?.type === 'tab-area') {
this.shell.mainPanel.addWidget(widget, {
mode,
});
this.shell.mainPanel.activateWidget(widget);
} else if (
(layoutConfig as DockLayout.ISplitAreaConfig)?.type === 'split-area'
) {
const { widgets } = (layoutConfig as DockLayout.ISplitAreaConfig)
?.children[splitScreenIdx] as DockLayout.ITabAreaConfig;
const tabActivateWidget = widgets.find(_widget => _widget.isVisible);
// 已分屏场景
this.shell.mainPanel.addWidget(widget, {
mode: 'tab-after',
ref: tabActivateWidget,
});
this.shell.mainPanel.activateWidget(widget);
}
}
/**
* 全屏模式切换
*/
switchFullScreenMode() {
if (!this.isFullScreenMode) {
this.enableFullScreenMode();
} else {
this.disableFullScreenMode();
}
}
/**
* 开启全屏模式
* 在 CozeProjectIDE 中,全屏模式隐藏侧边栏和顶部导航栏
*/
enableFullScreenMode() {
if (this.isFullScreenMode) {
return;
}
// 隐藏侧边栏
this.primarySidebar.changeVisible(false);
// 隐藏顶部导航栏
const topBar = this.shell.getPanelFromArea(LayoutPanelType.TOP_BAR);
topBar.hide();
this.isFullScreenMode = true;
this.onFullScreenModeChangeEmitter.fire(true);
}
disableFullScreenMode() {
if (!this.isFullScreenMode) {
return;
}
// 显示侧边栏
this.primarySidebar.changeVisible(true);
// 显示顶部导航栏
const topBar = this.shell.getPanelFromArea(LayoutPanelType.TOP_BAR);
topBar.show();
this.isFullScreenMode = false;
this.onFullScreenModeChangeEmitter.fire(false);
}
}

View File

@@ -0,0 +1,116 @@
/*
* 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 } from 'inversify';
import {
CommandRegistry,
Emitter,
type CustomTitleType,
type Event,
} from '@coze-project-ide/client';
import { ProjectIDEWidget } from '@/widgets/project-ide-widget';
import { type WidgetTitleRender } from '@/types/client';
import { type WidgetRegistry, type WidgetUIState } from '@/types';
@injectable()
export class WidgetService {
@inject(ProjectIDEWidget) public widget: ProjectIDEWidget;
@inject(CommandRegistry) private commandRegistry: CommandRegistry;
private _uiState: WidgetUIState = 'loading';
private _title: string;
private _iconType: string;
private _widgetTitleRender: WidgetTitleRender;
private registry: WidgetRegistry;
readonly onFocusEmitter = new Emitter<void>();
readonly onFocus: Event<void> = this.onFocusEmitter.event;
readonly onTitleChangedEmitter = new Emitter<string>();
readonly onTitleChanged: Event<string> = this.onTitleChangedEmitter.event;
readonly onIconTypeChangeEmitter = new Emitter<string>();
readonly onIconTypeChanged: Event<string> =
this.onIconTypeChangeEmitter.event;
init(factory: WidgetRegistry, widgetTitleRender: WidgetTitleRender) {
this.registry = factory;
this._widgetTitleRender = widgetTitleRender;
this.setTitle(this._title);
}
/** 触发重渲染 */
update() {
(this.widget.title as CustomTitleType).iconLabel = this._widgetTitleRender({
commandRegistry: this.commandRegistry,
registry: this.registry,
uiState: this._uiState,
title: this._title,
widget: this.widget,
}) as any;
(this.widget.title as CustomTitleType).saving = this._uiState === 'saving';
}
setTitle(title: string, uiState?: WidgetUIState) {
if (this._title !== title) {
this.onTitleChangedEmitter.fire(title);
}
this._title = title;
if (uiState) {
this._uiState = uiState;
}
this.update();
}
getTitle() {
return this._title;
}
getUIState() {
return this._uiState;
}
setUIState(uiState: WidgetUIState) {
if (this._uiState !== uiState) {
this._uiState = uiState;
this.update();
}
}
getIconType(): string {
return this._iconType;
}
setIconType(iconType: string) {
if (this._iconType !== iconType) {
this.onIconTypeChangeEmitter.fire(iconType);
}
this._iconType = iconType;
this.update();
}
close() {
this.widget.close();
}
}

View File

@@ -0,0 +1,41 @@
/*
* 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 React from 'react';
import { Spin } from '@coze-arch/coze-design';
import { useCurrentWidget } from '@coze-project-ide/client';
import { type ProjectIDEWidget } from '@/widgets/project-ide-widget';
import { type RegistryHandler } from '@/types';
import { useMount } from './use-mount';
import { useLifeCycle } from './use-lifecycle';
export const withRegistryContent = (registry: RegistryHandler<any>) => {
const WidgetComp = () => {
const widget: ProjectIDEWidget = useCurrentWidget();
const { context } = widget;
useLifeCycle(registry, context, widget);
const { loaded, mounted, content } = useMount(registry, widget);
return loaded && mounted ? content : <Spin />;
};
return WidgetComp;
};

View File

@@ -0,0 +1,67 @@
/*
* 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 { useEffect, useCallback } from 'react';
import {
ApplicationShell,
ContextKeyService,
type ReactWidget,
useIDEService,
} from '@coze-project-ide/client';
import { type ProjectIDEWidget } from '@/widgets/project-ide-widget';
import { type RegistryHandler } from '@/types';
import { type WidgetContext } from '@/context/widget-context';
import { LifecycleService } from '../lifecycle-service';
export const useLifeCycle = (
registry: RegistryHandler,
widgetContext: WidgetContext,
widget?: ReactWidget,
) => {
const lifecycleService = useIDEService<LifecycleService>(LifecycleService);
const contextKeyService = useIDEService<ContextKeyService>(ContextKeyService);
const setContextKey = useCallback(() => {
registry?.onFocus?.(widgetContext);
contextKeyService.setContext('widgetFocus', widget?.uri);
contextKeyService.setContext('widgetContext', widgetContext);
}, [widgetContext]);
const shell = useIDEService<ApplicationShell>(ApplicationShell);
// 生命周期管理
useEffect(() => {
const currentUri = (shell.mainPanel.currentTitle?.owner as ProjectIDEWidget)
?.uri;
if (currentUri && widget?.uri?.match(currentUri)) {
setContextKey();
}
const listenActivate = lifecycleService.onFocus(title => {
if (
(title.owner as ReactWidget).uri?.toString() === widget?.uri?.toString()
) {
setContextKey();
}
});
const listenDispose = widget?.onDispose?.(() => {
registry?.onDispose?.(widgetContext);
});
return () => {
listenActivate?.dispose?.();
listenDispose?.dispose?.();
};
}, []);
};

View File

@@ -0,0 +1,120 @@
/*
* 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 {
useEffect,
useMemo,
useRef,
useState,
useLayoutEffect,
useCallback,
} from 'react';
import { isFunction } from 'lodash-es';
import { type ProjectIDEWidget } from '@/widgets/project-ide-widget';
import { type RegistryHandler } from '@/types';
export const useMount = (
registry: RegistryHandler,
widget: ProjectIDEWidget,
) => {
/**
* 是否已经挂载
*/
const [mounted, setMounted] = useState(widget.isVisible);
const [version, setVersion] = useState(0);
const mountedRef = useRef(widget.isVisible);
/**
* 是否已加载完成
*/
const [loaded, setLoaded] = useState(!registry.load);
/**
* renderContent 函数结果缓存
* 由于 registry 和 widget 基本不变,可以保证在同一个 widget 中 renderContent 函数只会运行一次
* 除非 WidgetComp 组件被卸载 =.=
*/
const content = useMemo(() => {
if (!isFunction(registry.renderContent)) {
return null;
}
return registry.renderContent(widget.context, widget);
}, [registry, widget, version]);
/**
* 支持 registry 定义加载函数
*/
const load = useCallback(async () => {
if (!registry.load || !isFunction(registry.load)) {
return;
}
await registry.load(widget.context);
setLoaded(true);
}, [registry, widget, setLoaded]);
/**
* 监听 widget 的显示隐藏状态,若 widget 显示且未挂载,则需要主动挂载一次
*/
const watchWidgetStatus = useCallback(
(w: ProjectIDEWidget) => {
const { isVisible } = w;
if (isVisible && !mountedRef.current) {
setMounted(true);
mountedRef.current = true;
}
return w.onDidChangeVisibility(visible => {
if (visible && !mountedRef.current) {
setMounted(true);
mountedRef.current = true;
}
});
},
[setMounted, mountedRef],
);
/**
* 监听器可以较早挂载,避免多渲染一次
*/
useLayoutEffect(() => {
const dispose = watchWidgetStatus(widget);
const disposeRefresh = widget.onRefresh(() => {
setVersion(prev => prev + 1);
});
return () => {
dispose.dispose();
disposeRefresh.dispose();
};
}, [widget, watchWidgetStatus]);
/**
* 加载函数时机暂无特殊设计,先保持和历史逻辑一致
*/
useEffect(() => {
load();
}, [load]);
return {
loaded,
mounted,
content,
};
};

View File

@@ -0,0 +1,19 @@
/*
* 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 { createPresetPlugin } from './create-preset-plugin';
export { createContextMenuPlugin } from './create-context-menu-plugin';
export { createCloseConfirmPlugin } from './close-confirm-plugin';