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,70 @@
/*
* 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, { useCallback } from 'react';
import { IDEClient, type IDEClientOptions } from '@coze-project-ide/client';
import { type ProjectIDEClientProps as PresetPluginOptions } from '../types';
import {
createPresetPlugin,
createCloseConfirmPlugin,
createContextMenuPlugin,
} from '../plugins';
interface ProjectIDEClientProps {
presetOptions: PresetPluginOptions;
plugins?: IDEClientOptions['plugins'];
}
export const ProjectIDEClient: React.FC<
React.PropsWithChildren<ProjectIDEClientProps>
> = ({ presetOptions, plugins, children }) => {
const options = useCallback(() => {
const temp: IDEClientOptions = {
preferences: {
defaultData: {
theme: 'light',
},
},
view: {
restoreDisabled: true,
widgetFactories: [],
defaultLayoutData: {},
widgetFallbackRender: presetOptions.view.widgetFallbackRender,
},
plugins: [
createPresetPlugin(presetOptions),
createCloseConfirmPlugin(),
createContextMenuPlugin(),
...(plugins || []),
],
};
return temp;
}, [presetOptions, plugins]);
return (
<IDEClient
options={options}
// 兼容 mnt e2e 环境,在 e2e 环境下,高度会被坍缩成 0
// 因此需要额外的样式兼容
// className={(window as any)._mnt_e2e_testing_ ? 'e2e-flow-container' : ''}
className="e2e-flow-container"
>
{children}
</IDEClient>
);
};

View File

@@ -0,0 +1,18 @@
/*
* 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 { ProjectIDEClient } from './ide-client';
export * from './resource-folder';

View File

@@ -0,0 +1,82 @@
/*
* 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 { ResourceTypeEnum } from './type';
import { BaseResourceContextMenuBtnType } from './hooks/use-right-click-panel/constant';
const ROOT_KEY = '$-ROOT-$';
const RESOURCE_FOLDER_WRAPPER_CLASS = 'resource-list-right-click-wrapper';
const ROOT_NODE = {
id: ROOT_KEY,
type: ResourceTypeEnum.Folder,
name: 'root',
};
const ITEM_HEIGHT = 24;
const HALF_ICON_WIDTH = 5;
const TAB_SIZE = 8;
const MAX_DEEP = 5;
const RESOURCE_FOLDER_CONTEXT_KEY = 'resourceFolderContextKey';
/**
* 乐观 ui 创建文件的 ID 默认前缀
*/
const OPTIMISM_ID_PREFIX = 'resource-folder-optimism-id-';
const MOUSEUP_IGNORE_CLASS_NAME = 'mouseup-ignore-class-name';
const MORE_TOOLS_CLASS_NAME = 'more-tools-class-name';
enum ItemStatus {
Normal = 'normal',
Disabled = 'disabled', // 禁止操作
}
const COLOR_CONFIG = {
selectedItemBgColor: 'rgba(6, 7, 9, 0.14)',
tempSelectedItemBgColor: 'rgba(6, 7, 9, 0.04)',
errorItemBgColor: 'rgba(255, 241, 242, 1)',
dragFolderHoverBgColor: 'rgba(148, 152, 247, 0.44)',
textErrorColor: 'rgba(var(--blockwise-error-color))',
textWarningColor: 'rgba(var(--blockwise-warning-color))',
textSelectedColor: 'rgba(6, 7, 9, 0.96)',
textNormalColor: 'rgba(6, 7, 9, 0.5)',
};
export {
ROOT_KEY,
ITEM_HEIGHT,
HALF_ICON_WIDTH,
TAB_SIZE,
MAX_DEEP,
ROOT_NODE,
ItemStatus,
COLOR_CONFIG,
OPTIMISM_ID_PREFIX,
BaseResourceContextMenuBtnType,
RESOURCE_FOLDER_WRAPPER_CLASS,
MOUSEUP_IGNORE_CLASS_NAME,
RESOURCE_FOLDER_CONTEXT_KEY,
MORE_TOOLS_CLASS_NAME,
};

View File

@@ -0,0 +1,51 @@
/*
* 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 } from 'react';
import { useStateRef } from './uss-state-ref';
const useCollapsedMap = ({ _collapsedMap, _setCollapsedMap, resourceMap }) => {
const [collapsedMapRef, setCollapsedMap, collapsedState] = useStateRef(
_collapsedMap || {},
);
useEffect(() => {
if (_collapsedMap) {
setCollapsedMap(_collapsedMap);
}
}, [_collapsedMap]);
const setCollapsed = v => {
_setCollapsedMap?.(v);
if (!_collapsedMap) {
setCollapsedMap(v);
}
};
const handleCollapse = (id, v) => {
if (resourceMap.current?.[id]?.type === 'folder') {
setCollapsed({
...collapsedMapRef.current,
[id]: v,
});
}
};
return { handleCollapse, collapsedMapRef, setCollapsed, collapsedState };
};
export { useCollapsedMap };

View File

@@ -0,0 +1,64 @@
/*
* 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 { useRef } from 'react';
import { ContextKeyService, useIDEService } from '@coze-project-ide/client';
import { type ResourceFolderContextType } from '../type';
import { RESOURCE_FOLDER_CONTEXT_KEY } from '../constant';
const useContextChange = (id: string) => {
const contextRef = useRef<Partial<ResourceFolderContextType>>({
id,
});
const contextService = useIDEService<ContextKeyService>(ContextKeyService);
const setContext = dispatch => {
contextService.setContext(RESOURCE_FOLDER_CONTEXT_KEY, dispatch);
};
const getContext = (): Partial<ResourceFolderContextType> =>
contextService.getContext(RESOURCE_FOLDER_CONTEXT_KEY);
const updateContext = (other: Partial<ResourceFolderContextType>) => {
if (getContext()?.id === id) {
contextRef.current = {
...contextRef.current,
...other,
};
setContext(contextRef.current);
}
};
const updateId = () => {
setContext(contextRef.current);
};
const clearContext = () => {
if (getContext()?.id === id) {
contextService.setContext(RESOURCE_FOLDER_CONTEXT_KEY, undefined);
}
contextRef.current = {
id,
};
};
return { updateContext, clearContext, updateId };
};
export { useContextChange };

View File

@@ -0,0 +1,401 @@
/*
* 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.
*/
/* eslint-disable complexity */
/* eslint-disable max-lines-per-function */
/* eslint-disable @coze-arch/max-line-per-function */
import type React from 'react';
import { useEffect, useRef, useState } from 'react';
import { Toast } from '@coze-arch/coze-design';
import { baseValidateNames, getCreateResourceIndex } from '../../utils';
import {
type EditItemType,
type ChangeNameType,
type ResourceMapType,
type ItemType,
type IdType,
type CreateResourcePropType,
type ResourceType,
ResourceTypeEnum,
type ValidatorConfigType,
type ConfigType,
} from '../../type';
import {
ItemStatus,
MAX_DEEP,
BaseResourceContextMenuBtnType,
ROOT_KEY,
} from '../../constant';
import { useCustomValidator } from './use-custom-validator';
const CREATE_RESOURCE_ID = '-1';
const useCreateEditResource = ({
folderEnable,
defaultResourceType,
tempSelectedMapRef,
registerEvent,
setCollapsedMap,
onChangeName,
onCreate,
resourceMap,
selectedIdRef,
resourceTreeRef,
onDelete,
validateConfig,
config,
resourceList,
}: {
folderEnable?: boolean;
defaultResourceType?: string;
registerEvent: (key: BaseResourceContextMenuBtnType, fn: (e) => void) => void;
setCollapsedMap: (id: IdType, v: boolean) => void;
resourceTreeRef: React.MutableRefObject<ResourceType>;
tempSelectedMapRef: React.MutableRefObject<Record<string, ResourceType>>;
disabled: React.MutableRefObject<boolean>;
isFocusRef: React.MutableRefObject<boolean>;
onChangeName?: (v: ChangeNameType) => void;
resourceMap: React.MutableRefObject<ResourceMapType>;
onCreate?: (v: CreateResourcePropType) => void;
onDelete?: (v: ResourceType[]) => void;
selectedIdRef: React.MutableRefObject<string>;
validateConfig?: ValidatorConfigType;
config?: ConfigType;
resourceList: React.MutableRefObject<ResourceType[]>;
}): {
context: EditItemType;
onCreateResource: (type: ItemType) => void;
isInEditModeRef: React.MutableRefObject<boolean>;
handleRenderList: (
list: ResourceType[],
createResourceInfo?: EditItemType['createResourceInfo'],
) => (typeof CREATE_RESOURCE_ID | ResourceType)[];
handleRename: (resourceId: IdType) => void;
} => {
const [errorMsg, setErrorMsg] = useState('');
const errorMsgRef = useRef('');
const [isInEditMode, setIsInEditMode] = useState(false);
const isInEditModeRef = useRef(false);
const setInEditMode = (v: boolean) => {
isInEditModeRef.current = v;
setIsInEditMode(v);
};
const [editResource, setEditResource] = useState<ResourceType | null>(null);
const editResourceRef = useRef<ResourceType | null>(null);
const [createResourceInfo, setCreateResourceInfo] = useState<{
/**
* 用于定位渲染位置的 index
* 资源在文件夹后面,所有资源前面
* 文件夹在当前文件夹下最前面
*/
index: number;
parentId: IdType;
type: ItemType;
} | null>(null);
const createResourceInfoRef = useRef<{
parentId: IdType;
type: ItemType;
} | null>(null);
const preName = useRef<null | string>(null);
const nextName = useRef<null | string>(null);
const reset = () => {
setEditResource(null);
editResourceRef.current = null;
setCreateResourceInfo(null);
createResourceInfoRef.current = null;
setInEditMode(false);
setErrorMsg('');
errorMsgRef.current = '';
preName.current = null;
nextName.current = null;
};
const handleRename = (resourceId: IdType) => {
const target = resourceMap.current[resourceId];
if (target && target.status !== ItemStatus.Disabled) {
setEditResource(target);
editResourceRef.current = target;
setInEditMode(true);
preName.current = target.name;
nextName.current = target.name;
}
};
const onEditName = () => {
const v = tempSelectedMapRef.current;
if (Object.keys(v).filter(key => key !== ROOT_KEY).length === 1) {
const tempSelected = Object.values(v)[0];
handleRename(tempSelected.id);
}
};
const handleSave = (_nextValue?: string) => {
const nextValue =
_nextValue === undefined ? nextName.current || '' : _nextValue;
if (editResourceRef.current) {
if (editResourceRef.current.id === CREATE_RESOURCE_ID) {
if (nextValue !== '' && createResourceInfoRef.current?.parentId) {
onCreate?.({
parentId: createResourceInfoRef.current?.parentId,
name: nextValue,
type: createResourceInfoRef.current?.type,
path:
resourceMap.current?.[createResourceInfoRef.current?.parentId]
?.path || [],
});
}
} else {
if (
nextValue !== '' &&
nextValue !== preName.current &&
editResourceRef.current.id
) {
onChangeName?.({
id: editResourceRef.current.id,
name: nextValue,
type: editResourceRef.current.type,
path: resourceMap.current[editResourceRef.current.id].path,
resource: resourceMap.current[
editResourceRef.current.id
] as ResourceType,
});
}
}
}
reset();
};
const updateErrorMsg = (error: string) => {
if (error) {
setErrorMsg(error);
errorMsgRef.current = error;
return;
}
/**
* 同名检测
*/
// if (editResourceRef.current) {
// const parentFolder = createResourceInfoRef.current
// ? getResourceById(
// resourceTreeRef.current,
// createResourceInfoRef.current.parentId,
// )?.resource
// : getParentResource(resourceTreeRef.current, editResourceRef.current);
// if (parentFolder) {
// const sameNameValidate = validateSameNameInFolder({
// folder: parentFolder,
// editResource: {
// ...editResourceRef.current,
// name: v,
// },
// });
// if (sameNameValidate) {
// setErrorMsg(sameNameValidate);
// errorMsgRef.current = sameNameValidate;
// return;
// }
// }
// }
setErrorMsg('');
errorMsgRef.current = '';
};
const { validateAndUpdate } = useCustomValidator({
validator: validateConfig?.customValidator || (baseValidateNames as any),
callback: updateErrorMsg,
});
const handleChangeName = (v: null | string) => {
nextName.current = v;
if (createResourceInfoRef?.current) {
/**
* 新建资源
*/
const parentPath =
resourceMap.current[createResourceInfoRef?.current?.parentId]?.path;
validateAndUpdate({
type: 'create',
label: v || '',
parentPath: parentPath || [],
resourceTree: resourceTreeRef.current,
id: CREATE_RESOURCE_ID,
});
} else if (editResourceRef?.current) {
/**
* 编辑资源
*/
const path = editResourceRef?.current?.path || [];
const parentPath = path.slice(0, path?.length - 1);
validateAndUpdate({
type: 'edit',
label: v || '',
parentPath: parentPath || [],
resourceTree: resourceTreeRef.current,
id: editResourceRef.current.id,
});
}
};
const onCreateResource = async (_type: ItemType) => {
const type = _type || defaultResourceType;
if (!type) {
return;
}
if (!folderEnable && type === ResourceTypeEnum.Folder) {
return;
}
let selectResource = Object.values(tempSelectedMapRef.current || {})[0];
if (!selectResource) {
selectResource = resourceMap.current[selectedIdRef.current];
}
if (!selectResource) {
selectResource = resourceMap.current[ROOT_KEY];
}
if (selectResource.type !== 'folder' && selectResource?.path) {
// 如果不是 folder 则选中父亲 folder
selectResource =
resourceMap.current[
selectResource.path[selectResource.path.length - 2]
];
}
if (
(selectResource?.path?.length || 0) +
(type === ResourceTypeEnum.Folder ? 1 : 0) >
(config?.maxDeep || MAX_DEEP)
) {
Toast.warning(`Can't create ${type}. MaxDeep is 5`);
return;
}
if (selectResource) {
let parentId = selectResource.id;
if (selectResource.type !== 'folder') {
parentId = selectResource.path?.[selectResource.path?.length - 2] || '';
}
preName.current = '';
nextName.current = '';
setCollapsedMap(parentId, false);
await new Promise(resolve => {
setTimeout(() => {
resolve(null);
}, 0);
});
editResourceRef.current = {
id: CREATE_RESOURCE_ID,
name: '',
type,
};
setEditResource(editResourceRef.current);
createResourceInfoRef.current = {
parentId,
type,
};
const index = getCreateResourceIndex({
resourceList: resourceList.current,
parentId,
type,
});
setCreateResourceInfo({
parentId,
type,
index,
});
setInEditMode(true);
}
};
useEffect(() => {
registerEvent(BaseResourceContextMenuBtnType.EditName, () => {
if (!editResourceRef.current) {
onEditName();
}
});
registerEvent(BaseResourceContextMenuBtnType.Delete, () => {
const selectedResource = Object.values(tempSelectedMapRef.current).filter(
v => v.id !== ROOT_KEY,
);
if (selectedResource.length > 0) {
onDelete?.(selectedResource);
}
});
registerEvent(BaseResourceContextMenuBtnType.CreateFolder, () => {
onCreateResource(ResourceTypeEnum.Folder);
});
registerEvent(BaseResourceContextMenuBtnType.CreateResource, type => {
onCreateResource(type);
});
}, []);
const handleRenderList = (
list: ResourceType[],
info?: EditItemType['createResourceInfo'],
) => {
if (!info) {
return list as (typeof CREATE_RESOURCE_ID | ResourceType)[];
}
const { index } = info;
return [
...list.slice(0, index),
CREATE_RESOURCE_ID,
...list.slice(index, list.length),
] as (typeof CREATE_RESOURCE_ID | ResourceType)[];
};
return {
context: {
isInEditMode,
editResourceId: editResource?.id,
createResourceInfo,
handleChangeName,
errorMsg,
errorMsgRef,
handleSave,
},
onCreateResource,
isInEditModeRef,
handleRenderList,
handleRename,
};
};
export { useCreateEditResource, CREATE_RESOURCE_ID };

View File

@@ -0,0 +1,53 @@
/*
* 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.
*/
class PromiseController<T, O> {
private lastVersion: number;
private callbacks: Array<(v: O) => void> = [];
private mainFunction: (v: T) => Promise<O>;
constructor() {
this.lastVersion = 0;
}
registerPromiseFn(fn: (v: T) => Promise<O>) {
this.mainFunction = fn;
return this;
}
registerCallbackFb(cb: (v: O) => void) {
this.callbacks.push(cb);
return this;
}
async excute(v: T) {
if (!this.mainFunction) {
return;
}
this.lastVersion += 1;
const currentVersion = this.lastVersion;
const res = await this.mainFunction(v);
if (this.lastVersion === currentVersion) {
this.callbacks.forEach(cb => cb(res));
}
}
dispose() {
this.callbacks = [];
}
}
export { PromiseController };

View File

@@ -0,0 +1,51 @@
/*
* 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, useRef } from 'react';
import { type CustomValidatorPropsType } from '../../type';
import { PromiseController } from './promise-controller';
const useCustomValidator = ({
validator,
callback,
}: {
validator: (data: CustomValidatorPropsType) => Promise<string>;
callback: (label: string) => void;
}) => {
const promiseController = useRef(
new PromiseController<CustomValidatorPropsType, string>(),
);
useEffect(() => {
promiseController.current
.registerPromiseFn(validator)
.registerCallbackFb(callback);
return () => {
promiseController.current?.dispose?.();
};
}, []);
const validateAndUpdate = (data: CustomValidatorPropsType) => {
promiseController.current?.excute(data);
};
return {
validateAndUpdate,
};
};
export { useCustomValidator };

View File

@@ -0,0 +1,109 @@
/*
* 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, { useEffect, useRef } from 'react';
enum EventKey {
MouseDown = 'mouseDown',
MouseDownInDiv = 'mouseDownInDiv',
MouseUpInDiv = 'mouseUpInDiv',
MouseUp = 'mouseUp',
MouseMove = 'mouseMove',
KeyDown = 'keyDown',
}
const useEvent = () => {
const mouseDownRef = useRef<Array<(e) => void>>([]);
const mouseDownInDivRef = useRef<Array<(e) => void>>([]);
const mouseUpInDivRef = useRef<Array<(e) => void>>([]);
const mouseUpRef = useRef<Array<(e) => void>>([]);
const mouseMoveRef = useRef<Array<(e) => void>>([]);
const keyDownRef = useRef<Array<(e) => void>>([]);
const addEventListener = (key: EventKey, fn: (e) => void) => {
if (key === EventKey.KeyDown) {
keyDownRef.current.push(fn);
} else if (key === EventKey.MouseDownInDiv) {
mouseDownInDivRef.current.push(fn);
} else if (key === EventKey.MouseUpInDiv) {
mouseUpInDivRef.current.push(fn);
} else if (key === EventKey.MouseDown) {
mouseDownRef.current.push(fn);
} else if (key === EventKey.MouseUp) {
mouseUpRef.current.push(fn);
} else if (key === EventKey.MouseMove) {
mouseMoveRef.current.push(fn);
}
};
const onMouseDown = e => {
mouseDownRef.current.forEach(fn => {
fn(e);
});
};
const onMouseDownInDiv = e => {
mouseDownInDivRef.current.forEach(fn => {
fn(e);
});
};
const onMouseUpInDiv = e => {
mouseUpInDivRef.current.forEach(fn => {
fn(e);
});
};
const onMouseUp = e => {
mouseUpRef.current.forEach(fn => {
fn(e);
});
};
const onKeyDown = e => {
keyDownRef.current.forEach(fn => {
fn(e);
});
};
const onMouseMove = e => {
mouseMoveRef.current.forEach(fn => {
fn(e);
});
};
useEffect(() => {
window.addEventListener('mousedown', onMouseDown);
window.addEventListener('mouseup', onMouseUp);
window.addEventListener('keydown', onKeyDown);
window.addEventListener('mousemove', onMouseMove);
return () => {
window.removeEventListener('mousedown', onMouseDown);
window.removeEventListener('mouseup', onMouseUp);
window.removeEventListener('keydown', onKeyDown);
window.removeEventListener('mousemove', onMouseMove);
};
}, []);
const customEventBox = ({ children }) => (
<div
className={'resource-list-custom-event-wrapper'}
onMouseDownCapture={onMouseDownInDiv}
onMouseUp={onMouseUpInDiv}
>
{children}
</div>
);
return { addEventListener, customEventBox, onMouseDownInDiv, onMouseUpInDiv };
};
export { useEvent, EventKey };

View File

@@ -0,0 +1,89 @@
/*
* 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 { useRef } from 'react';
import { calcOffsetTopByCollapsedMap } from '../../utils';
import {
type ResourceType,
type ConfigType,
type ResourceMapType,
} from '../../type';
import { ITEM_HEIGHT } from '../../constant';
const useFocusResource = ({
resourceTreeRef,
collapsedMapRef,
resourceMap,
config,
}: {
config?: ConfigType;
resourceTreeRef: React.MutableRefObject<ResourceType>;
collapsedMapRef: React.MutableRefObject<Record<string, boolean>>;
resourceMap: React.MutableRefObject<ResourceMapType>;
}) => {
const scrollWrapper = useRef<HTMLDivElement>(null);
const scrollEnable = useRef(true);
const scrollInView = (selectedId: string) => {
if (
!scrollWrapper.current ||
!scrollEnable.current ||
!selectedId ||
!resourceTreeRef.current ||
!resourceMap?.current?.[selectedId]
) {
return;
}
const scrollTop = calcOffsetTopByCollapsedMap({
selectedId,
resourceTree: resourceTreeRef.current,
collapsedMap: collapsedMapRef.current,
itemHeight: config?.itemHeight || ITEM_HEIGHT,
});
// 如果在视图内, 则不滚
if (
scrollTop > scrollWrapper.current.scrollTop &&
scrollTop <
scrollWrapper.current.offsetHeight + scrollWrapper.current.scrollTop
) {
return;
}
scrollWrapper.current.scrollTo({
top: scrollTop,
behavior: 'smooth',
});
};
const tempDisableScroll = (t?: number) => {
scrollEnable.current = false;
setTimeout(() => {
scrollEnable.current = true;
}, t || 300);
};
return {
scrollInView,
scrollWrapper,
tempDisableScroll,
};
};
export { useFocusResource };

View File

@@ -0,0 +1,584 @@
/*
* 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.
*/
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable complexity */
/* eslint-disable @coze-arch/max-line-per-function */
/* eslint-disable max-lines-per-function */
import type React from 'react';
import { useEffect, useRef, useState } from 'react';
import { DragService, type URI, useIDEService } from '@coze-project-ide/client';
import { EventKey } from '../use-custom-event';
import { flatTree, getResourceListFromIdToId } from '../../utils';
import {
type DragAndDropType,
type ResourceType,
type DragPropType,
type ResourceMapType,
type IdType,
type CommonRenderProps,
ResourceTypeEnum,
type ConfigType,
} from '../../type';
import { MORE_TOOLS_CLASS_NAME, ROOT_KEY, ROOT_NODE } from '../../constant';
import {
CLICK_OUTSIDE,
CLICK_TOOL_BAR,
type MousePosition,
DATASET_PARENT_DATA_KEY_ID,
findTargetElement,
canStartDrag,
getFolderIdFromPath,
CLICK_CONTEXT_MENU,
validateDrag,
getElementByXY,
DATASET_RESOURCE_FOLDER_KEY,
} from './utils';
import { useDragUI } from './use-drag-ui';
const useMouseEvent = ({
draggable,
uniqId,
updateId,
setTempSelectedMap,
collapsedMapRef,
setCollapsedMap,
resourceTreeRef,
onDrag,
disabled,
resourceMap,
addEventListener,
selectedIdRef,
onSelected,
tempSelectedMapRef,
resourceTreeWrapperRef,
iconRender,
config,
}: {
draggable: boolean; // 是否可以拖拽
uniqId: string;
updateId: () => void;
resourceTreeWrapperRef: React.MutableRefObject<HTMLDivElement | null>;
setTempSelectedMap: (v: Record<string, ResourceType>) => void;
collapsedMapRef: React.MutableRefObject<Record<string, boolean>>;
setCollapsedMap: (id: IdType, v: boolean) => void;
resourceTreeRef: React.MutableRefObject<ResourceType>;
tempSelectedMapRef: React.MutableRefObject<Record<string, ResourceType>>;
disabled?: React.MutableRefObject<boolean>;
selectedIdRef?: React.MutableRefObject<string>;
resourceMap: React.MutableRefObject<ResourceMapType>;
onDrag: (v: DragPropType) => void;
onSelected?: (id: string | number, resource: ResourceType) => void;
addEventListener: (key: EventKey, fn: (e) => void) => void;
iconRender?: (v: CommonRenderProps) => React.ReactElement | undefined;
config?: ConfigType;
}): {
context: DragAndDropType;
isFocusRef: React.MutableRefObject<boolean>;
dragPreview: React.ReactElement;
onMouseMove;
handleFocus: () => void;
handleBlur: () => void;
} => {
const isMouseDownRef = useRef<MousePosition | null>(null);
const [isFocus, setIsFocus] = useState(false);
const isFocusRef = useRef(false);
const dragService = useIDEService<DragService>(DragService);
/**
* 存储拖拽过程中是否合法的字段
*/
const [draggingError, setDraggingError] = useState<string>('');
const multiModeFirstSelected = useRef<ResourceType | null>(null);
const { isDragging, isDraggingRef, handleDrag, dragPreview } = useDragUI({
iconRender,
selectedMap: tempSelectedMapRef.current,
addEventListener,
config,
});
/**
* 用于开启拖拽到 mainPanel 下打开资源的方法
*/
const dragAndOpenResource = e => {
if (!config?.resourceUriHandler) {
return;
}
const uris = Object.values(tempSelectedMapRef.current)
.filter(resource => resource.type !== ResourceTypeEnum.Folder)
.map(resource => config.resourceUriHandler?.(resource))
.filter(Boolean);
if (!uris.length) {
return;
}
dragService?.startDrag?.({
uris: uris as URI[],
position: {
clientX: e.clientX,
clientY: e.clientY,
},
callback: v => {
//
},
backdropTransform: {
/**
* 通过边缘检测的方式,阻止 lm-cursor-backdrop 元素进入树组件内
*/
clientX: (eventX: number) =>
Math.max(
eventX,
(resourceTreeWrapperRef.current?.clientWidth || 0) + 100,
),
},
});
};
const startDrag = e => {
if (draggable) {
dragAndOpenResource(e);
setDraggingError('');
handleDrag(true);
}
};
const stopDrag = () => {
setDraggingError('');
handleDrag(false);
};
const changeTempSelectedMap = (v: Record<string, ResourceType>) => {
const values = Object.values(v);
if (values.length === 1) {
multiModeFirstSelected.current = values[0];
} else if (values.length === 0) {
multiModeFirstSelected.current = null;
}
tempSelectedMapRef.current = v;
setTempSelectedMap(v);
};
const [currentHoverItem, setCurrentHoverItem] = useState<ResourceType | null>(
null,
);
/**
* 用于记录拖拽过程中,当前 hover 元素的 父元素 id。
*/
const hoverItemParentId = useRef<string | null>(null);
/**
* 用于记录拖拽过程中,当前 hover 元素的 父元素及其下钻所有节点的id表
*/
const [highlightItemMap, setHighlightItemMap] = useState<ResourceMapType>({});
useEffect(() => {
if (currentHoverItem?.id) {
const parentId = getFolderIdFromPath(currentHoverItem);
if (parentId !== hoverItemParentId.current) {
const treeList = resourceMap.current[parentId]
? flatTree(
resourceMap.current[parentId],
resourceMap.current,
collapsedMapRef.current,
)
: [];
setHighlightItemMap(
treeList.reduce(
(pre, cur, index) => ({
...pre,
[cur.id]: {
...cur,
},
}),
{},
),
);
hoverItemParentId.current = parentId;
}
} else {
hoverItemParentId.current = null;
setHighlightItemMap({});
}
}, [currentHoverItem?.id]);
const handleFocus = () => {
updateId();
setIsFocus(true);
isFocusRef.current = true;
};
const handleBlur = () => {
changeTempSelectedMap({});
setIsFocus(false);
isFocusRef.current = false;
};
const getCurrentDragResourceList = () => {
let resourceList = Object.values(tempSelectedMapRef.current).filter(
item => item.id !== ROOT_KEY,
);
/**
* 将文件夹下的文件给过滤掉,防止拖拽之后失去层级结构
*/
resourceList = resourceList.filter(resource => {
const { type, path } = resourceMap.current[resource.id];
if (type !== ResourceTypeEnum.Folder) {
const resourcePath = path || [];
return !(resourcePath || [])
.slice(0, resourcePath.length - 1)
.some(id => id !== ROOT_KEY && tempSelectedMapRef.current[id]);
}
return true;
});
return resourceList;
};
const onMouseDownInDiv = e => {
if (disabled?.current) {
return;
}
const target = findTargetElement(e.target, uniqId);
if (!target) {
return;
}
handleFocus();
if (typeof target === 'object' && e.button === 0) {
isMouseDownRef.current = {
x: e.clientX,
y: e.clientY,
};
}
// 这里要选中一次是保证拖动前选中正确的 item
if (typeof target === 'object' && !e.shiftKey && !e.metaKey) {
let currentSelected: ResourceType | null = null;
if (target.id === ROOT_KEY) {
currentSelected = ROOT_NODE;
} else {
currentSelected = resourceMap?.current?.[String(target?.id)] || {};
}
if (
currentSelected?.id &&
!tempSelectedMapRef.current?.[currentSelected.id]
) {
changeTempSelectedMap({ [currentSelected.id]: currentSelected });
}
}
};
const onMouseDown = e => {
if (disabled?.current || !isFocusRef.current) {
return;
}
const target = findTargetElement(e.target, uniqId);
stopDrag();
if (!target || target === CLICK_OUTSIDE) {
handleBlur();
return;
}
// 点到右键的 panel 中 啥也别操作
if (target === CLICK_CONTEXT_MENU) {
return;
}
};
const onMouseUpInDiv = e => {
if (disabled?.current || isDraggingRef.current) {
return;
}
const target = findTargetElement(e.target, uniqId, MORE_TOOLS_CLASS_NAME);
if (typeof target === 'object' && target !== null) {
let currentSelected: ResourceType | null = null;
if (target.id === ROOT_KEY) {
currentSelected = ROOT_NODE;
} else {
currentSelected = resourceMap?.current?.[String(target?.id)] || {};
}
/**
* 如果点击了 more tools 三颗点,那需要先将临时选中表重置为只选中该 item。 用于右键菜单的消费。
*/
if (target.customTag === MORE_TOOLS_CLASS_NAME) {
const nextSelected = { [currentSelected.id]: currentSelected };
changeTempSelectedMap(nextSelected);
return;
}
// 如果右键, 并且点击在已经被选中的资源上,则不再做操作,因为要弹操作栏
if (
e.ctrlKey ||
(e.button === 2 &&
currentSelected?.id &&
tempSelectedMapRef.current?.[currentSelected.id])
) {
return;
}
if (e.shiftKey) {
const firstSelectedId =
multiModeFirstSelected.current?.id || selectedIdRef?.current;
if (
!firstSelectedId ||
!currentSelected?.id ||
firstSelectedId === currentSelected?.id
) {
return;
}
// 批量一下子多选
let nextSelected: any = getResourceListFromIdToId({
resourceTree: resourceTreeRef.current,
from: firstSelectedId,
to: currentSelected.id,
options: { collapsedMap: collapsedMapRef.current },
});
nextSelected = (nextSelected || []).reduce((prev, next) => {
const id = typeof next === 'object' ? String(next.id) : String(next);
return {
...prev,
[id]: resourceMap?.current[id],
} as any;
}, {});
if (nextSelected[ROOT_KEY]) {
delete nextSelected[ROOT_KEY];
}
changeTempSelectedMap(nextSelected);
} else if (e.metaKey) {
// 挑着多选
let nextSelected = { ...tempSelectedMapRef.current };
if (currentSelected?.id) {
if (nextSelected[currentSelected.id]) {
delete nextSelected[currentSelected.id];
} else {
nextSelected = {
...nextSelected,
[currentSelected.id]: currentSelected,
};
}
}
if (nextSelected[ROOT_KEY]) {
delete nextSelected[ROOT_KEY];
}
changeTempSelectedMap(nextSelected);
} else {
if (currentSelected?.type && currentSelected.type !== 'folder') {
onSelected?.(currentSelected.id, currentSelected as any);
}
if (currentSelected?.id) {
changeTempSelectedMap({ [currentSelected.id]: currentSelected });
}
}
}
if (
!isDraggingRef.current ||
!Object.keys(tempSelectedMapRef.current).length
) {
if (
typeof target === 'object' &&
target !== null &&
target.id !== ROOT_KEY
) {
if (e.button === 0 && !e.shiftKey && !e.metaKey) {
setCollapsedMap(target.id, !collapsedMapRef.current[target.id]);
}
}
}
};
const onMouseMove = e => {
if (disabled?.current) {
return;
}
if (!isMouseDownRef.current) {
return;
}
const selectMapValue = Object.values(tempSelectedMapRef.current);
if (selectMapValue.length === 0) {
return;
}
if (selectMapValue.length === 1 && selectMapValue[0].id === ROOT_KEY) {
return;
}
const isStartDrag =
isDraggingRef.current ||
canStartDrag(isMouseDownRef.current, {
x: e.clientX,
y: e.clientY,
});
if (!isStartDrag) {
return;
}
/**
* 开始拖拽
*/
if (!isDraggingRef.current) {
startDrag(e);
return;
}
if (!resourceTreeWrapperRef.current) {
return;
}
const target = getElementByXY({
e,
wrapperElm: resourceTreeWrapperRef.current,
uniqId,
});
// 点到右键的 panel 中 啥也别操作
if (target === CLICK_CONTEXT_MENU) {
return;
}
if (!target) {
setCurrentHoverItem(null);
return;
}
if (target === CLICK_OUTSIDE || target === CLICK_TOOL_BAR) {
setCurrentHoverItem(null);
return;
}
if (typeof target === 'object' && target?.id !== currentHoverItem?.id) {
if (target?.id === ROOT_KEY) {
setCurrentHoverItem(ROOT_NODE);
} else {
setCurrentHoverItem(resourceMap.current[String(target?.id)]);
const toId = getFolderIdFromPath(resourceMap.current[target.id]);
const resourceList = getCurrentDragResourceList();
const error = validateDrag(
resourceList,
resourceMap.current[toId],
config,
);
setDraggingError(error);
}
}
};
const onMouseUp = e => {
if (disabled?.current) {
return;
}
if (
isDraggingRef.current &&
Object.keys(tempSelectedMapRef.current).length &&
resourceTreeWrapperRef.current
) {
const target = getElementByXY({
e,
wrapperElm: resourceTreeWrapperRef.current,
uniqId,
});
// 点到右键的 panel 中 啥也别操作
if (target === CLICK_CONTEXT_MENU || !target) {
return;
}
if (typeof target === 'object') {
const resourceList = getCurrentDragResourceList();
const toId = getFolderIdFromPath(resourceMap.current[target.id]);
const error = validateDrag(
resourceList,
resourceMap.current[toId],
config,
);
if (error) {
onDrag?.({ errorMsg: error });
} else {
onDrag?.({
resourceList,
toId,
});
setCollapsedMap(toId, false);
}
}
stopDrag();
}
isMouseDownRef.current = null;
setCurrentHoverItem(null);
};
useEffect(() => {
addEventListener(EventKey.MouseDown, onMouseDown);
addEventListener(EventKey.MouseDownInDiv, onMouseDownInDiv);
addEventListener(EventKey.MouseUpInDiv, onMouseUpInDiv);
addEventListener(EventKey.MouseUp, onMouseUp);
}, []);
const dataHandler = (resource: ResourceType) => ({
[`data-${DATASET_PARENT_DATA_KEY_ID}`]: resource.id,
[`data-${DATASET_RESOURCE_FOLDER_KEY}`]: uniqId,
});
return {
context: {
isDragging,
draggingError,
isFocus,
dataHandler,
tempSelectedMapRef,
currentHoverItem,
highlightItemMap,
},
isFocusRef,
onMouseMove,
dragPreview,
handleFocus,
handleBlur,
};
};
export { useMouseEvent };

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 React, { useEffect, useRef, useState } from 'react';
import { EventKey } from '../use-custom-event';
import {
type ConfigType,
type CommonRenderProps,
type ResourceType,
} from '../../type';
export const useDragUI = ({
iconRender,
selectedMap,
addEventListener,
config,
}: {
iconRender?: (v: CommonRenderProps) => React.ReactElement | undefined;
selectedMap: Record<string, ResourceType>;
addEventListener: (key: EventKey, fn: (e) => void) => void;
config?: ConfigType;
}) => {
const [isDragging, setIsDragging] = useState(false);
const isDraggingRef = useRef(false);
const [mousePosition, setMousePosition] = useState<{
x: number;
y: number;
} | null>(null);
const handleDrag = (v: boolean) => {
isDraggingRef.current = v;
setIsDragging(v);
setMousePosition(null);
if (v) {
document.body.style.cursor = 'grabbing';
} else {
document.body.style.cursor = '';
}
};
const handleMouseMove = e => {
if (isDraggingRef.current) {
setMousePosition({
x: e.clientX,
y: e.clientY,
});
}
};
useEffect(() => {
addEventListener(EventKey.MouseMove, handleMouseMove);
}, []);
const dragPreview =
mousePosition && !config?.dragUi?.disable ? (
<div
style={{
position: 'absolute',
zIndex: 99999,
top: 5,
left: 5,
display: isDragging && mousePosition?.x ? 'block' : 'none',
transform: `translate(${mousePosition?.x || 0}px, ${
mousePosition?.y || 0
}px)`,
userSelect: 'none',
pointerEvents: 'none',
backgroundColor: 'rgba(6, 7, 9, 0.08)',
borderRadius: 6,
padding: '2px 4px',
minWidth: 20,
minHeight: 20,
...(config?.dragUi?.wrapperStyle || {}),
}}
className={config?.dragUi?.wrapperClassName || ''}
>
{Object.values(selectedMap).length > 1 ? (
<>{Object.values(selectedMap).length}</>
) : (
Object.values(selectedMap).map(item => (
<div
key={item.name}
style={{ display: 'flex', alignItems: 'center' }}
>
{item?.type ? (
<span style={{ marginRight: 4 }}>
{iconRender?.({
resource: item,
})}
</span>
) : (
<></>
)}
<span>{item.name}</span>
</div>
))
)}
</div>
) : (
<></>
);
return { handleDrag, isDragging, isDraggingRef, dragPreview };
};

View File

@@ -0,0 +1,231 @@
/*
* 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.
*/
/* eslint-disable complexity */
import { type ConfigType, type IdType, type ResourceType } from '../../type';
import { MAX_DEEP, ROOT_KEY, ROOT_NODE } from '../../constant';
const DATASET_RESOURCE_FOLDER_KEY = 'resource_folder_key';
const DATASET_PARENT_DATA_STOP_TAG = 'resource_folder_drag_and_drop_stop_tag';
const DATASET_PARENT_DATA_KEY_ID = 'resource_folder_drag_and_drop_id';
const TOOL_BAR_CLASS_NAME = 'resource_folder_tool_bar_class_name';
const CLICK_TOOL_BAR = 'click_tool_bar';
const CLICK_OUTSIDE = 'click_outside';
const CLICK_CONTEXT_MENU = 'click_context_menu';
const PATH_SPLIT_KEY = '-$$-';
const START_DRAG_GAP = 10;
export interface MousePosition {
x: number;
y: number;
}
const isPointInRect = (
point: { x: number; y: number },
rect: { x: number; y: number; width: number; height: number },
) =>
point.x > rect.x &&
point.x < rect.x + rect.width &&
point.y > rect.y &&
point.y < rect.y + rect.height;
const BORDER_GAP = 8; // 默认边缘阈值
/**
* 相对于 findTargetElement ,增加了边缘检测功能。
* 当鼠标在 资源目录边缘, 会算作聚焦在 root 节点
*/
const getElementByXY = ({
e,
wrapperElm,
uniqId,
}: {
e: MouseEvent;
wrapperElm: HTMLDivElement;
uniqId: string;
}) => {
const { pageX, pageY } = e;
const { x, y, width, height } = wrapperElm.getBoundingClientRect();
if (
isPointInRect({ x: pageX, y: pageY }, { x, y, width, height }) &&
!isPointInRect(
{ x: pageX, y: pageY },
{
x: x + BORDER_GAP,
y: y + BORDER_GAP,
width: width - BORDER_GAP * 2,
height: height - BORDER_GAP * 2,
},
)
) {
return ROOT_NODE;
}
return findTargetElement(e.target as HTMLElement, uniqId);
};
type TargetType =
| {
id: IdType;
customTag?: string;
}
| null
| typeof CLICK_OUTSIDE
| typeof CLICK_CONTEXT_MENU
| typeof CLICK_TOOL_BAR;
const findTargetElement = (
elm: HTMLElement | null,
uniqueId: string,
/**
* 遇到该 className 会进行记录,并且返回 id 的时候会带上该 className
*/
customClassName?: string,
): TargetType | string => {
const extraProps: {
customTag?: string;
} = {};
if (customClassName && elm?.classList?.contains?.(customClassName)) {
extraProps.customTag = customClassName;
}
if (!elm) {
return CLICK_OUTSIDE;
}
if (
elm.dataset?.[DATASET_PARENT_DATA_KEY_ID] !== undefined &&
elm.dataset?.[DATASET_RESOURCE_FOLDER_KEY] === uniqueId
) {
return {
id: elm.dataset[DATASET_PARENT_DATA_KEY_ID],
...extraProps,
};
} else if (
elm.dataset?.[DATASET_PARENT_DATA_STOP_TAG] &&
elm.dataset[DATASET_RESOURCE_FOLDER_KEY] === uniqueId
) {
return {
id: ROOT_KEY,
...extraProps,
};
} else if (elm.classList.contains(TOOL_BAR_CLASS_NAME)) {
return CLICK_TOOL_BAR;
}
const result = findTargetElement(
elm.parentElement,
uniqueId,
customClassName,
);
if (typeof result === 'object' && result !== null) {
return {
...result,
...extraProps,
};
}
return result;
};
const getFolderIdFromPath = (resource: ResourceType | null): string => {
if (!resource) {
return '';
}
if (resource.type === 'folder') {
return String(resource.id);
} else {
return String(resource.path?.[resource.path.length - 2]);
}
};
const canStartDrag = (
startPosition: MousePosition,
currentPosition: MousePosition,
): boolean => {
if (
Math.abs(currentPosition.x - startPosition.x) > START_DRAG_GAP ||
Math.abs(currentPosition.y - startPosition.y) > START_DRAG_GAP
) {
return true;
}
return false;
};
const validateDrag = (
resourceList: ResourceType[],
target: ResourceType,
config?: ConfigType,
) => {
const { name, path, id } = target;
/**
* 只要有一个文件是自己层级挪动到自己层级的则 return
*/
const selfList = resourceList.filter(resource => {
const resourcePath = resource.path || [];
const parentId = resourcePath[resourcePath.length - 2];
return parentId === id;
});
if (selfList.length) {
return `Can't move ${selfList
.map(item => item.name)
.join(', ')} into ${name}`;
}
// 校验是不是爹移到儿子
const notAllowedList = resourceList.filter(resource =>
(path || []).includes(String(resource.id)),
);
if (notAllowedList.length) {
return `Can't move ${notAllowedList
.map(item => item.name)
.join(', ')} into ${name}`;
}
// 校验移动之后层级是不是过深
const maxDeep = resourceList.reduce(
(max, resource) => Math.max(max, (resource?.maxDeep || 0) + 1),
0,
);
const targetDeep = (target.path || []).length - 1;
if (targetDeep + maxDeep > (config?.maxDeep || MAX_DEEP)) {
return `Can't move into ${name}. MaxDeep is ${config?.maxDeep || MAX_DEEP}`;
}
return '';
};
export {
DATASET_PARENT_DATA_STOP_TAG,
DATASET_PARENT_DATA_KEY_ID,
DATASET_RESOURCE_FOLDER_KEY,
CLICK_CONTEXT_MENU,
TOOL_BAR_CLASS_NAME,
CLICK_TOOL_BAR,
CLICK_OUTSIDE,
PATH_SPLIT_KEY,
findTargetElement,
getElementByXY,
getFolderIdFromPath,
validateDrag,
canStartDrag,
};

View File

@@ -0,0 +1,191 @@
/*
* 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.
*/
/* eslint-disable @coze-arch/max-line-per-function */
/* eslint-disable max-lines-per-function */
import { useState } from 'react';
import { noop } from 'lodash-es';
import {
getResourceById,
findResourceByPath,
sortResourceList,
} from '../../utils';
import {
type ChangeNameType,
type CreateResourcePropType,
type DragPropType,
type ResourceType,
type ResourceMapType,
type IdType,
} from '../../type';
import { OPTIMISM_ID_PREFIX } from '../../constant';
export const useOptimismUI = ({
enable,
onChange = noop,
onDrag = noop,
onChangeName = noop,
onCreate = noop,
onDelete = noop,
onRevertDelete = noop,
changeResourceTree,
scrollInView,
resourceTreeRef,
resourceMap,
}: {
enable?: boolean;
onChange?: (resource: ResourceType[]) => void;
onDrag?: (v: DragPropType) => void;
onChangeName?: (v: ChangeNameType) => void;
onCreate?: (v: CreateResourcePropType) => void;
onDelete?: (ids: ResourceType[]) => void;
onRevertDelete?: (ids: ResourceType[]) => void;
changeResourceTree: (v) => void;
scrollInView: (selectedId: string) => void;
resourceTreeRef: React.MutableRefObject<ResourceType>;
resourceMap: React.MutableRefObject<ResourceMapType>;
}) => {
const [optimismSavingMap, setOptimismSavingMap] = useState<
Record<IdType, true>
>({});
const clearOptimismSavingMap = () => {
setOptimismSavingMap({});
};
const addOptimismSavingItems = (_ids: string | string[]) => {
const ids = _ids instanceof Array ? _ids : [_ids];
setOptimismSavingMap({
...optimismSavingMap,
...ids.reduce(
(pre, cur) => ({
[cur]: true,
...pre,
}),
{},
),
});
};
const handleDrag = (res: DragPropType) => {
const { toId, resourceList } = res;
if (!toId || !resourceList) {
return;
}
resourceList.forEach(resource => {
const target = findResourceByPath(
resourceTreeRef.current,
resource.path!.slice(0, resource.path!.length - 1),
);
if (target?.children) {
target.children = target.children.filter(
child => child.id !== resource.id,
);
}
});
const toTarget = getResourceById(resourceTreeRef.current, toId)?.resource;
if (toTarget) {
toTarget.children = sortResourceList([
...(toTarget.children || []),
...resourceList,
]);
}
addOptimismSavingItems(resourceList.map(resource => resource.id));
changeResourceTree(resourceTreeRef.current);
onDrag?.(res);
onChange?.(resourceTreeRef.current?.children || []);
};
const handleChangeName = (res: ChangeNameType) => {
const target = findResourceByPath(resourceTreeRef.current, res.path || []);
if (!target) {
return;
}
target.name = res.name;
addOptimismSavingItems(target.id);
changeResourceTree(resourceTreeRef.current);
onChangeName?.(res);
onChange?.(resourceTreeRef.current?.children || []);
};
const handleCreate = (res: CreateResourcePropType) => {
const { path, type, name } = res;
const target = findResourceByPath(resourceTreeRef.current, path);
if (!target) {
return;
}
const tempId = `${OPTIMISM_ID_PREFIX}${Number(new Date())}`;
const tempFile = {
id: tempId,
type,
name,
};
addOptimismSavingItems(tempId);
target.children = sortResourceList([...(target.children || []), tempFile]);
changeResourceTree(resourceTreeRef.current);
onCreate(res);
resourceMap.current[tempId] = tempFile;
scrollInView?.(tempId);
onChange?.(resourceTreeRef.current?.children || []);
};
const handleDelete = (res: ResourceType[]) => {
onDelete(res);
};
const handleRevertDelete = (res: ResourceType[]) => {
res.forEach(source => {
const target = findResourceByPath(
resourceTreeRef.current,
source.path || [],
);
if (target) {
target.status = 'normal';
}
});
changeResourceTree(resourceTreeRef.current);
onRevertDelete?.(res);
onChange?.(resourceTreeRef.current?.children || []);
};
const commonArgs = { optimismSavingMap, clearOptimismSavingMap };
if (!enable) {
return {
handleDrag: onDrag,
handleChangeName: onChangeName,
handleCreate: onCreate,
handleDelete: onDelete,
handleRevertDelete: onRevertDelete,
...commonArgs,
};
}
return {
handleDrag,
handleChangeName,
handleCreate,
handleDelete,
handleRevertDelete,
...commonArgs,
};
};

View File

@@ -0,0 +1,121 @@
/*
* 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, useRef } from 'react';
import { CommandRegistry, useIDEService } from '@coze-project-ide/client';
import { ContextMenuConfigMap } from '../use-right-click-panel/constant';
import {
type RightPanelConfigType,
type ResourceFolderContextType,
type ResourceType,
} from '../../type';
import { BaseResourceContextMenuBtnType } from '../../constant';
const useRegisterCommand = ({
isFocus,
id,
updateContext,
clearContext,
selectedIdRef,
tempSelectedMapRef,
}: {
isFocus: boolean;
id: string;
updateContext: (v: Partial<ResourceFolderContextType>) => void;
clearContext: () => void;
selectedIdRef: React.MutableRefObject<string>;
tempSelectedMapRef: React.MutableRefObject<Record<string, ResourceType>>;
}) => {
const cbRef = useRef<Record<string, (v?: any) => void>>({});
const commandRegistry = useIDEService<CommandRegistry>(CommandRegistry);
const dispatchInstance = useRef<ResourceFolderContextType>({
onEnter: () => {
cbRef.current[BaseResourceContextMenuBtnType.EditName]?.();
},
onDelete: () => {
cbRef.current[BaseResourceContextMenuBtnType.Delete]?.();
},
onCreateFolder: () => {
cbRef.current[BaseResourceContextMenuBtnType.CreateFolder]?.();
},
onCreateResource: () => {
cbRef.current[BaseResourceContextMenuBtnType.CreateResource]?.();
},
});
const registerEvent = (type: BaseResourceContextMenuBtnType, cb) => {
cbRef.current[type] = cb;
};
const registerCommand = (config: RightPanelConfigType[]) => {
config.forEach(command => {
if ('type' in command) {
return;
}
if (ContextMenuConfigMap[command.id]) {
if (command.label || command.shortLabel) {
commandRegistry.updateCommand(command.id, {
...(command.label ? { label: command.label } : {}),
...(command.shortLabel ? { shortLabel: command.shortLabel } : {}),
});
}
} else if (command.execute) {
// 如果有自定义的 execute 函数才会需要重新注册
if (commandRegistry.getCommand(command.id)) {
commandRegistry.unregisterCommand(command.id);
}
commandRegistry.registerCommand(
{
id: command.id,
label: command.label,
shortLabel: command.label,
},
{
execute: () => {
command.execute?.();
},
isEnabled: opt => !opt.disabled,
isVisible: opt => !opt.isHidden,
},
);
}
});
};
useEffect(() => {
if (isFocus) {
updateContext({
...dispatchInstance.current,
currentSelectedId: selectedIdRef.current,
tempSelectedMap: tempSelectedMapRef.current,
id,
});
} else {
clearContext();
}
}, [isFocus]);
return {
registerEvent,
registerCommand,
};
};
export { useRegisterCommand };

View File

@@ -0,0 +1,53 @@
/*
* 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 enum BaseResourceContextMenuBtnType {
CreateFolder = 'resource-folder-create-folder',
CreateResource = 'resource-folder-create-resource',
EditName = 'resource-folder-edit-name',
Delete = 'resource-folder-delete',
}
export const ContextMenuConfigMap: Record<
BaseResourceContextMenuBtnType,
{
id: string;
label: string;
disabled?: boolean;
executeName: string;
}
> = {
[BaseResourceContextMenuBtnType.CreateFolder]: {
id: BaseResourceContextMenuBtnType.CreateFolder,
label: 'Create Folder',
executeName: 'onCreateFolder',
},
[BaseResourceContextMenuBtnType.CreateResource]: {
id: BaseResourceContextMenuBtnType.CreateResource,
label: 'Create Resource',
executeName: 'onCreateResource',
},
[BaseResourceContextMenuBtnType.EditName]: {
id: BaseResourceContextMenuBtnType.EditName,
label: 'Edit Name',
executeName: 'onEnter',
},
[BaseResourceContextMenuBtnType.Delete]: {
id: BaseResourceContextMenuBtnType.Delete,
label: 'Delete',
executeName: 'onDelete',
},
};

View File

@@ -0,0 +1,148 @@
/*
* 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.
*/
/* eslint-disable @typescript-eslint/no-explicit-any */
import type React from 'react';
import { useRef, useEffect } from 'react';
import { MenuService, useIDEService } from '@coze-project-ide/client';
import { createUniqId } from '../../utils';
import { type ResourceType, type RightPanelConfigType } from '../../type';
import { RESOURCE_FOLDER_WRAPPER_CLASS } from '../../constant';
import { handleConfig } from './util';
import { ContextMenuConfigMap } from './constant';
const commandIdStashSet: Set<string> = new Set();
const RESOURCE_FOLDER_SEPARATOR_KEY = 'resource-folder-separator-key';
const useRightClickPanel = ({
tempSelectedMapRef,
contextMenuHandler,
registerCommand,
id,
contextMenuDisabled,
onContextMenuVisibleChange,
}: {
tempSelectedMapRef: React.MutableRefObject<Record<string, ResourceType>>;
contextMenuHandler?: (v: ResourceType[]) => RightPanelConfigType[];
registerCommand: (config: RightPanelConfigType[]) => void;
id: string;
contextMenuDisabled?: boolean;
onContextMenuVisibleChange?: (v: boolean) => void;
}) => {
const menuService = useIDEService<MenuService>(MenuService);
const separatorNum = useRef(0);
const menuNum = useRef(0);
const rightPanelVisible = useRef(false);
const changeRightPanelVisible = (v: boolean) => {
if (rightPanelVisible.current !== v) {
rightPanelVisible.current = v;
onContextMenuVisibleChange?.(rightPanelVisible.current);
}
};
const clearMenuItems = () => {
menuService.clearMenuItems(
[
...commandIdStashSet.keys(),
...Object.keys(ContextMenuConfigMap),
...new Array(separatorNum.current)
.fill(null)
.map(_ => command => command === RESOURCE_FOLDER_SEPARATOR_KEY),
].filter(Boolean),
);
separatorNum.current = 0;
menuNum.current = 0;
};
const dispose = () => {
clearMenuItems();
(menuService as any)?.contextMenu?.menu?.close?.();
changeRightPanelVisible?.(false);
};
const contextMenuCallback = (e, resources?: ResourceType[]) => {
const baseConfig = contextMenuHandler
? contextMenuHandler(
resources || Object.values(tempSelectedMapRef.current),
)
: [];
const config = handleConfig(baseConfig);
registerCommand(config);
clearMenuItems();
config.forEach(v => {
if ('type' in v) {
separatorNum.current = separatorNum.current + 1;
menuNum.current = menuNum.current + 1;
menuService.addMenuItem({
command: RESOURCE_FOLDER_SEPARATOR_KEY,
type: 'separator',
selector: `.${createUniqId(RESOURCE_FOLDER_WRAPPER_CLASS, id)}`,
});
return;
}
if (!v.id) {
return;
}
if (!commandIdStashSet.has(v.id)) {
commandIdStashSet.add(v.id);
}
if (!contextMenuDisabled) {
menuNum.current = menuNum.current + 1;
menuService.addMenuItem({
command: v.id,
selector: `.${createUniqId(RESOURCE_FOLDER_WRAPPER_CLASS, id)}`,
args: v,
tooltip: v.tooltip,
});
}
});
if (!contextMenuDisabled && menuNum.current > 0) {
menuService.open(e);
setTimeout(() => {
changeRightPanelVisible?.(true);
}, 0);
}
return () => {
dispose();
};
};
useEffect(() => {
(menuService as any)?.contextMenu?.menu?.aboutToClose?.connect?.(() => {
if (rightPanelVisible.current) {
changeRightPanelVisible?.(false);
}
});
return () => {
dispose();
};
}, []);
return { contextMenuCallback, closeContextMenu: dispose };
};
export { useRightClickPanel };

View File

@@ -0,0 +1,42 @@
/*
* 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 RightPanelConfigType } from '../../type';
import { ContextMenuConfigMap } from './constant';
/**
* 主要替换资源树默认支持的 右键菜单配置,
* 并且对三方注入的右键菜单的 id 进行包装
*/
export const handleConfig = (
baseConfig: RightPanelConfigType[],
): RightPanelConfigType[] =>
baseConfig.map(config => {
if ('type' in config) {
return config;
}
if (ContextMenuConfigMap[config.id]) {
return {
...ContextMenuConfigMap[config.id],
...config,
id: config.id,
};
}
return {
...config,
id: config.id,
};
});

View File

@@ -0,0 +1,65 @@
/*
* 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, useRef } from 'react';
const useSelectedChange = ({
selected,
resourceMap,
collapsedMapRef,
setCollapsed,
tempSelectedMapRef,
setTempSelectedMap,
scrollInView,
updateContext,
}) => {
const selectedIdRef = useRef<string>(selected || '');
useEffect(() => {
if (!selected) {
setTempSelectedMap({});
return;
}
selectedIdRef.current = selected;
updateContext({ currentSelectedId: selected });
// 将聚焦的 path 上的文件夹都展开
const path = resourceMap.current[selected]?.path || [];
path.forEach(pathKey => {
delete collapsedMapRef.current[pathKey];
});
setCollapsed({
...collapsedMapRef.current,
});
tempSelectedMapRef.current = {};
if (resourceMap.current?.[selected]) {
tempSelectedMapRef.current = {
[selected]: resourceMap.current[selected],
};
}
setTempSelectedMap(tempSelectedMapRef.current);
setTimeout(() => {
scrollInView(selected);
}, 16);
}, [selected]);
return selectedIdRef;
};
export { useSelectedChange };

View File

@@ -0,0 +1,35 @@
/*
* 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 { useRef, useState } from 'react';
const useStateRef = <T>(
v: T,
cb?: (v: T) => void,
): [React.MutableRefObject<T>, (v: T) => void, T] => {
const [state, setState] = useState<T>(v);
const ref = useRef<T>(v);
const onChange = (nextV: T) => {
ref.current = nextV;
setState(nextV);
cb?.(nextV);
};
return [ref, onChange, state];
};
export { useStateRef };

View File

@@ -0,0 +1,159 @@
.resource-list-wrapper {
height: 100%;
width: 100%;
user-select: none;
:global{
.resource-list-custom-event-wrapper {
height: 100%;
width: 100%;
}
.resource-list-scroll-container {
height: 100%;
overflow-y: auto;
overflow-x: hidden;
}
.resource-list-drag-and-drop-wrapper {
height: 100%;
.item-wrapper {
position: relative;
border-radius: 4px;
.item-wrapper-indent-line {
position: absolute;
border-left-width: 1px;
border-left-style: solid;
transition: all 0.2s ease-in-out;
}
}
.base-item-hover-class {
&:hover {
background-color: rgba(6, 7, 9, 0.08);
}
}
.item-is-temp-selected {
background-color: rgba(6, 7, 9, 0.04);
border-radius: 0px;
}
.item-is-in-edit {
background-color: rgba(6, 7, 9, 0.04);
}
.item-is-selected {
background-color: rgba(6, 7, 9, 0.14);
}
.dragging-hover-class {
background-color: rgba(148, 152, 247, 0.44);
border-radius: 0px;
}
.file-item-wrapper {
}
.base-item {
width: 100%;
display: flex;
align-items: center;
padding-right: 8px;
transition: all 0.1s ease-in-out;
.base-item-icon {
display: flex;
align-items: center;
margin-right: 4px;
}
.base-item-name-input {
width: 100%;
position: relative;
display: flex;
align-items: center;
.semi-input-wrapper {
height: 20px;
line-height: 20px;
border-radius: 6px;
}
.semi-input-wrapper-focus {
background-color: white;
}
[class~='semi-input'] {
padding: 0 4px;
height: 20px;
line-height: 20px;
}
.base-item-name-input-error-msg-absolute {
z-index: 999;
position: absolute;
width: 100%;
top: 26px;
background-color: rgba(255, 241, 242, 1);
border-width: 1px;
border-color: rgba(242, 36, 53, 1);
border-radius: 6px;
border-style: solid;
color: rgba(6, 7, 9, 0.5);
display: flex;
align-items: center;
padding: 2px 4px;
font-size: 12px;
line-height: 16px;
font-weight: 700;
}
}
.base-item-name-input-error {
[class~='semi-input-wrapper'] {
border-color: rgba(242, 36, 53, 1);
}
}
.base-item-more-hover-display-class {
display: none;
}
&:hover {
.base-item-more-hover-display-class {
display: flex;
}
}
}
.base-item-more-btn {
// 用于覆盖 coze-design 二次封装被固化的样式。。。
min-width: 16px;
padding-left: 0;
padding-right: 0;
background-color: transparent;
color: rgba(6, 7, 9, 0.96);
&:hover {
background-color: rgba(6, 7, 9, 0.14);
}
width: 16px;
height: 16px;
align-items: center;
justify-content: center;
svg {
width: 12px;
height: 12px;
}
}
.base-radius-class-first {
border-radius: 4px 4px 0 0;
}
.base-radius-class-last {
border-radius: 0 0 4px 4px;
}
.base-radius-class-single {
border-radius: 4px;
}
}
}
}

View File

@@ -0,0 +1,776 @@
/*
* 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.
*/
/* eslint-disable complexity */
/* eslint-disable max-lines */
/* eslint-disable max-lines-per-function */
/* eslint-disable @coze-arch/max-line-per-function */
import ReactDOM from 'react-dom';
import React, {
forwardRef,
useEffect,
useImperativeHandle,
useRef,
} from 'react';
import { noop } from 'lodash-es';
import { createUniqId, flatTree, mapResourceTree } from './utils';
import {
type DragPropType,
type ResourceType,
type ChangeNameType,
type ResourceMapType,
type CreateResourcePropType,
type CommonRenderProps,
type RightPanelConfigType,
ResourceTypeEnum,
type ValidatorConfigType,
type ConfigType,
type RenderMoreSuffixType,
type IdType,
} from './type';
import { BaseRender } from './render-components/base-render';
import { useStateRef } from './hooks/uss-state-ref';
import { useSelectedChange } from './hooks/use-selected-change';
import { useRightClickPanel } from './hooks/use-right-click-panel';
import { useRegisterCommand } from './hooks/use-register-command';
import { useOptimismUI } from './hooks/use-optimism-ui';
import {
DATASET_PARENT_DATA_STOP_TAG,
DATASET_RESOURCE_FOLDER_KEY,
} from './hooks/use-mouse-event/utils';
import { useMouseEvent } from './hooks/use-mouse-event';
import { useFocusResource } from './hooks/use-focus-resource';
import { useEvent } from './hooks/use-custom-event';
import {
CREATE_RESOURCE_ID,
useCreateEditResource,
} from './hooks/use-create-edit-resource';
import { useContextChange } from './hooks/use-context-change';
import { useCollapsedMap } from './hooks/use-collapsed-map';
import { RESOURCE_FOLDER_WRAPPER_CLASS, ROOT_KEY, ROOT_NODE } from './constant';
import s from './index.module.less';
interface RefType {
/**
* 创建文件夹
*/
createFolder: () => void;
/**
* 创建资源
*/
createResource: (type: string) => void;
/**
* 重命名资源
*/
renameResource: (id: IdType) => void;
/**
* 手动关闭右键菜单
*/
closeContextMenu: () => void;
/**
* 收起所有文件夹
*/
collapseAll: () => void;
/**
* 展开所有文件夹
*/
expandAll: () => void;
/**
* 手动聚焦
*/
focus: () => void;
/**
* 手动失焦
*/
blur: () => void;
}
interface Props {
id?: string;
style?: React.CSSProperties;
resourceTree: ResourceType | ResourceType[];
resourceMap: ResourceMapType;
disabled?: boolean;
/**
* 主要的资源类型,非必填。
* 主要用于快捷键创建资源的默认类型。
*/
defaultResourceType?: string;
/**
* 是否使用乐观 ui;
* false 时onChange 失效;
* default = true
*
* 传入 loadingRender 时,会对乐观保存的 item 尾部增加一个渲染块,由外部控制渲染
*/
useOptimismUI?:
| boolean
| {
loadingRender?: () => React.ReactElement;
};
/**
* 当前选中的资源 id 受控的
*/
selected?: string;
/**
* 是否渲染每个 item 末尾的 more 按钮hover 等同于 右键
*/
renderMoreSuffix?: RenderMoreSuffixType;
/**
* 用于 name 校验的配置
*/
validateConfig?: ValidatorConfigType;
/**
* 支持搜索, 高亮展示
*/
searchConfig?: {
searchKey?: string;
highlightStyle?: React.CSSProperties;
};
/**
* 可选。
* 传入则是受控的收起展开树。
* 不传则内部自己维护树
*/
collapsedMap?: Record<string, boolean>;
setCollapsedMap?: (v: Record<string, boolean>) => void;
/**
* 树变更的回调函数,依赖 useOptimismUI 为 true。
*/
onChange?: (resource: ResourceType[]) => void;
/**
* 单击选中资源的回调,仅支持非 folder 类型资源
*/
onSelected?: (id: string | number, resource: ResourceType) => void;
/**
* 拖拽完成之后的回调
*/
onDrag?: (v: DragPropType) => void;
/**
* 修改 name 之后的回调
*/
onChangeName?: (v: ChangeNameType) => void;
/**
* 创建资源的回调
*/
onCreate?: (v: CreateResourcePropType) => void;
/**
* 删除的回调。该方法不会被乐观 ui 逻辑改写,最好业务层加一个二次确认逻辑,删除之后走数据更新的方式来更新树组件
*/
onDelete?: (ids: ResourceType[]) => void;
/**
* 用于自定义配置渲染资源的 icon
* @returns react 节点
*/
iconRender?: (v: CommonRenderProps) => React.ReactElement | undefined;
/**
* 用于自定义配置每一项末尾的元素
*/
suffixRender?: {
width: number; // 用于文本超长的 tooltip 偏移量计算的,是一个必填字段
render: (v: CommonRenderProps) => React.ReactElement | undefined;
};
/**
* 用于自定义配置每一个资源的文本渲染器。
* 如果采用自定义渲染,则需要自己实现搜索高亮能力
* @returns react 节点
*/
textRender?: (v: CommonRenderProps) => React.ReactElement | undefined;
/**
* 右键菜单配置
* @param v 当前临时选中的资源列表。 可以通过 id 判断是否是根文件。ROOT_KEY: 根文件 id
* @returns 组件内置注册好的命令和菜单,详见 BaseResourceContextMenuBtnType 枚举
*/
contextMenuHandler?: (v: ResourceType[]) => RightPanelConfigType[];
/**
* 右键菜单弹窗展示和隐藏的回调。
*/
onContextMenuVisibleChange?: (v: boolean) => void;
/**
* 禁用右键菜单,主要是兼容在 popover 的场景内
*/
contextMenuDisabled?: boolean;
/**
* 一些用于杂七杂八的配置项
*/
config?: ConfigType;
/**
* 能力黑名单
*/
powerBlackMap?: {
dragAndDrop?: boolean;
folder?: boolean;
};
/**
* 列表为空的渲染组件
*/
empty?: React.ReactElement;
}
let idIndex = 0;
const ResourceFolder = forwardRef<RefType, Props>(
(
{
id,
resourceTree: _resourceTree,
resourceMap: _resourceMap,
selected,
disabled,
searchConfig,
defaultResourceType,
useOptimismUI: _useOptimismUI = false,
style,
collapsedMap: _collapsedMap,
setCollapsedMap: _setCollapsedMap,
validateConfig,
onChange = noop,
onSelected: _onSelected = noop,
onDrag: _onDrag = noop,
onChangeName: _onChangeName = noop,
onCreate: _onCreate = noop,
onDelete: _onDelete = noop,
iconRender,
suffixRender,
textRender,
renderMoreSuffix: _renderMoreSuffix = false,
contextMenuHandler,
onContextMenuVisibleChange,
contextMenuDisabled,
config,
powerBlackMap,
empty,
},
ref,
) => {
const uniqId = useRef(id ? id : `${idIndex++}`);
const resourceTreeWrapperRef = useRef<HTMLDivElement>(null);
const { updateContext, clearContext, updateId } = useContextChange(
uniqId.current,
);
const renderMoreSuffix = contextMenuDisabled ? false : _renderMoreSuffix;
/**
* 临时选中的表
*/
const [tempSelectedMapRef, setTempSelectedMap] = useStateRef<
Record<string, ResourceType>
>({}, v => {
updateContext?.({ tempSelectedMap: v });
});
/**
* 打平的树
*/
const resourceMap = useRef<ResourceMapType>(_resourceMap || {});
const changeResourceMap = nextMap => {
resourceMap.current = nextMap;
// 变更之后维护临时选中表
tempSelectedMapRef.current = Object.keys(
tempSelectedMapRef.current,
).reduce((pre, cur) => {
if (resourceMap.current[cur]) {
return {
...pre,
[cur]: resourceMap.current[cur],
};
}
return pre;
}, {});
setTempSelectedMap(tempSelectedMapRef.current);
};
useEffect(() => {
changeResourceMap(_resourceMap);
}, [_resourceMap]);
/**
* 处理一系列收起展开的 hook
*/
const { collapsedMapRef, handleCollapse, setCollapsed, collapsedState } =
useCollapsedMap({
_collapsedMap,
_setCollapsedMap,
resourceMap,
});
/**
* 用于渲染的树
*/
const [resourceTreeRef, setResourceTree] = useStateRef<ResourceType>(
{
...ROOT_NODE,
children:
_resourceTree instanceof Array ? _resourceTree : [_resourceTree],
} as unknown as ResourceType,
v => {
setResourceList(
flatTree(v, resourceMap.current, collapsedMapRef.current),
);
},
);
const changeResourceTree = v => {
resourceTreeRef.current = v;
changeResourceMap(mapResourceTree(v?.children || []));
setResourceTree(resourceTreeRef.current);
};
const [resourceList, setResourceList] = useStateRef(
flatTree(
resourceTreeRef.current,
resourceMap.current,
collapsedMapRef.current,
),
);
useEffect(() => {
setResourceList(
flatTree(
resourceTreeRef.current,
resourceMap.current,
collapsedMapRef.current,
),
);
}, [collapsedState]);
const disabledRef = useRef(!!disabled);
useEffect(() => {
disabledRef.current = !!disabled;
}, [disabled]);
/**
* 用于收敛树组件的滚动,聚焦逻辑的 hook
*/
const { scrollInView, scrollWrapper, tempDisableScroll } = useFocusResource(
{
resourceTreeRef,
collapsedMapRef,
resourceMap,
config,
},
);
const {
handleDrag: onDrag,
handleChangeName: onChangeName,
handleCreate: onCreate,
handleDelete: onDelete,
optimismSavingMap,
clearOptimismSavingMap,
} = useOptimismUI({
enable: !!_useOptimismUI,
onDrag: _onDrag,
onChangeName: _onChangeName,
onCreate: _onCreate,
onDelete: _onDelete,
changeResourceTree,
scrollInView,
resourceTreeRef,
resourceMap,
onChange,
});
useEffect(() => {
resourceTreeRef.current = {
...ROOT_NODE,
children:
_resourceTree instanceof Array ? _resourceTree : [_resourceTree],
};
setResourceTree(resourceTreeRef.current);
clearOptimismSavingMap();
}, [_resourceTree]);
/**
* 处理选中的资源变更之后的副作用
*/
const selectedIdRef = useSelectedChange({
selected,
resourceMap,
collapsedMapRef,
setCollapsed,
tempSelectedMapRef,
setTempSelectedMap,
scrollInView,
updateContext,
});
const { addEventListener, onMouseDownInDiv, onMouseUpInDiv } = useEvent();
const {
onMouseMove,
context: dragAndDropContext,
context: { isFocus },
isFocusRef,
dragPreview,
handleFocus,
handleBlur,
} = useMouseEvent({
draggable: !powerBlackMap?.dragAndDrop,
uniqId: uniqId.current,
updateId,
iconRender,
resourceTreeWrapperRef,
collapsedMapRef,
tempSelectedMapRef,
setTempSelectedMap,
setCollapsedMap: handleCollapse,
resourceTreeRef,
selectedIdRef,
onSelected: (...props) => {
_onSelected(...props);
tempDisableScroll();
},
onDrag,
addEventListener,
disabled: disabledRef,
resourceMap,
config,
});
const { registerEvent, registerCommand } = useRegisterCommand({
isFocus,
updateContext,
clearContext,
id: uniqId.current,
selectedIdRef,
tempSelectedMapRef,
});
const {
context: createEditResourceContext,
onCreateResource,
isInEditModeRef,
handleRenderList,
handleRename,
} = useCreateEditResource({
folderEnable: !powerBlackMap?.folder,
defaultResourceType,
registerEvent,
setCollapsedMap: handleCollapse,
resourceTreeRef,
tempSelectedMapRef,
selectedIdRef,
isFocusRef,
resourceMap,
onChangeName,
onCreate,
disabled: disabledRef,
onDelete,
validateConfig,
config,
resourceList,
});
const { contextMenuCallback, closeContextMenu } = useRightClickPanel({
tempSelectedMapRef,
contextMenuHandler,
registerCommand,
id: uniqId.current,
contextMenuDisabled,
onContextMenuVisibleChange,
});
useImperativeHandle(ref, () => ({
focus: () => {
handleFocus();
},
blur: () => {
handleBlur();
},
createResource: (type: string) => {
if (isInEditModeRef.current || !type) {
return;
}
onCreateResource?.(type);
},
renameResource: (resourceId: IdType) => {
handleRename(resourceId);
},
createFolder: () => {
if (isInEditModeRef.current) {
return;
}
onCreateResource?.(ResourceTypeEnum.Folder);
},
expandAll: () => {
collapsedMapRef.current = {};
setCollapsed(collapsedMapRef.current);
},
collapseAll: () => {
collapsedMapRef.current = Object.keys(resourceMap.current).reduce(
(pre, cur) => {
if (
cur !== ROOT_KEY &&
resourceMap.current?.[cur]?.type === 'folder'
) {
return {
...pre,
[cur]: true,
};
}
return pre;
},
{},
);
setCollapsed(collapsedMapRef.current);
},
closeContextMenu,
}));
const commonProps = {
searchConfig,
suffixRender,
config,
renderMoreSuffix,
textRender,
contextMenuCallback,
resourceTreeWrapperRef,
iconRender,
isDragging: dragAndDropContext.isDragging,
draggingError: dragAndDropContext.draggingError,
currentHoverItem: dragAndDropContext.currentHoverItem,
validateConfig,
errorMsg: createEditResourceContext.errorMsg,
errorMsgRef: createEditResourceContext.errorMsgRef,
editResourceId: createEditResourceContext.editResourceId,
handleChangeName: createEditResourceContext.handleChangeName,
handleSave: createEditResourceContext.handleSave,
useOptimismUI: _useOptimismUI,
};
const createNode = createEditResourceContext?.createResourceInfo ? (
<div key={CREATE_RESOURCE_ID}>
<BaseRender
resource={{
id: CREATE_RESOURCE_ID,
name: '',
type: createEditResourceContext?.createResourceInfo.type,
}}
path={[
...(resourceMap.current?.[
createEditResourceContext?.createResourceInfo.parentId
]?.path || []),
CREATE_RESOURCE_ID,
]}
isInEdit={
CREATE_RESOURCE_ID === createEditResourceContext.editResourceId
}
{...commonProps}
/>
</div>
) : null;
const renderResourceList = handleRenderList(
resourceList.current,
createEditResourceContext?.createResourceInfo,
);
const emptyRender = () => {
const list = renderResourceList || [];
/**
* 为空数组,或者数组中只有一个 root 节点
*/
if (
(list.length === 0 ||
(list.length === 1 &&
list[0] !== CREATE_RESOURCE_ID &&
list[0].id === ROOT_KEY)) &&
empty
) {
return empty;
}
return null;
};
return (
<>
<div
key={uniqId.current}
className={`resource-list-wrapper ${s['resource-list-wrapper']}`}
ref={resourceTreeWrapperRef}
style={style || {}}
>
<div
{...{
[`data-${DATASET_PARENT_DATA_STOP_TAG}`]: true,
[`data-${DATASET_RESOURCE_FOLDER_KEY}`]: uniqId.current,
}}
ref={scrollWrapper}
className={`${createUniqId(
RESOURCE_FOLDER_WRAPPER_CLASS,
uniqId.current,
)} resource-list-drag-and-drop-wrapper resource-list-custom-event-wrapper resource-list-scroll-container`}
onMouseDown={onMouseDownInDiv}
onMouseUp={onMouseUpInDiv}
onMouseMove={onMouseMove}
onContextMenu={e => {
e.preventDefault();
}}
onContextMenuCapture={contextMenuCallback}
>
{renderResourceList.map((resource, i) => {
if (resource === CREATE_RESOURCE_ID) {
return createNode;
}
if (resource.id === ROOT_KEY) {
return null;
}
if (!resource) {
return <></>;
}
const isInEdit =
String(resource.id) ===
String(createEditResourceContext.editResourceId);
const isSelected = String(selected) === String(resource.id);
const isTempSelected = !!tempSelectedMapRef.current[resource.id];
const preItemTempSelected =
resourceList.current[i - 1]?.id !== ROOT_KEY &&
!!tempSelectedMapRef.current[resourceList.current[i - 1]?.id];
const nextItemTempSelected =
!!tempSelectedMapRef.current[resourceList.current[i + 1]?.id];
const isExpand = !collapsedMapRef.current[resource.id];
const highlightItem =
!powerBlackMap?.folder && // 不支持文件夹则不需支持拖拽时候的高亮
!!dragAndDropContext.highlightItemMap[resource.id];
const preHighlightItem =
resourceList.current[i - 1]?.id !== ROOT_KEY &&
!!dragAndDropContext.highlightItemMap[
resourceList.current[i - 1]?.id
];
const nextHighlightItem =
!!dragAndDropContext.highlightItemMap[
resourceList.current[i + 1]?.id
];
const extraClassName = [
/**
* 拖拽过程中的样式
*/
...(highlightItem
? [
resource.id !== ROOT_KEY ? 'dragging-hover-class' : '',
!preHighlightItem && !nextHighlightItem
? 'base-radius-class-single'
: '',
!preHighlightItem ? 'base-radius-class-first' : '',
!nextHighlightItem ? 'base-radius-class-last' : '',
]
: []),
isSelected ? 'item-is-selected' : '',
/**
* 拖拽过程中的 hover 态优先级 大于 临时选中态的优先级
*/
...(isTempSelected && !highlightItem
? [
'item-is-temp-selected',
!preItemTempSelected && !nextItemTempSelected
? 'base-radius-class-single'
: '',
!preItemTempSelected ? 'base-radius-class-first' : '',
!nextItemTempSelected ? 'base-radius-class-last' : '',
]
: []),
isInEdit ? 'item-is-in-edit' : '',
dragAndDropContext.isDragging || isSelected
? ''
: 'base-item-hover-class',
].join(' ');
return (
<div
key={resource.id}
className={`item-wrapper ${extraClassName}`}
{...dragAndDropContext.dataHandler(resource)}
>
<BaseRender
resource={resource}
path={resource.path || []}
isSelected={isSelected}
isTempSelected={isTempSelected}
isInEdit={isInEdit}
isExpand={isExpand}
isOptimismSaving={
_useOptimismUI && optimismSavingMap[resource.id]
}
{...commonProps}
/>
</div>
);
})}
{emptyRender()}
{/* 添加 24px 底部间距,标识加载完全 */}
<div style={{ padding: 12 }}></div>
</div>
</div>
{ReactDOM.createPortal(dragPreview, document.body)}
</>
);
},
);
export {
ResourceFolder,
ROOT_KEY,
type Props as ResourceFolderProps,
type RefType as ResourceFolderRefType,
mapResourceTree,
};
export {
type ResourceType,
type ResourceMapType,
type CommonRenderProps,
type RightPanelConfigType,
type RenderMoreSuffixType,
ResourceTypeEnum,
type ResourceFolderContextType as ResourceFolderShortCutContextType,
type CreateResourcePropType,
type IdType,
} from './type';
export {
BaseResourceContextMenuBtnType,
RESOURCE_FOLDER_CONTEXT_KEY,
} from './constant';

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 { type CommonComponentProps, ResourceTypeEnum } from '../type';
import { FolderRender } from './folder-render';
import { FileRender } from './file-render';
const BaseRender: React.FC<CommonComponentProps> = ({ ...props }) => {
const { resource, path } = props;
const Component =
resource.type === ResourceTypeEnum.Folder ? FolderRender : FileRender;
if (!Component) {
return <></>;
}
return (
<Component
key={`base-render-${resource.id}`}
{...props}
path={[...path, resource.id]}
/>
);
};
export { BaseRender };

View File

@@ -0,0 +1,147 @@
/*
* 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, { useMemo } from 'react';
import { type CommonComponentProps } from '../../type';
import { ITEM_HEIGHT, ItemStatus, TAB_SIZE } from '../../constant';
import { NameInput } from './name-input';
import { MoreTools } from './more-tools';
import { MemoText } from './memo-text';
const ItemRender = ({
resource,
path,
icon,
isSelected,
isTempSelected,
isInEdit,
searchConfig,
suffixRender,
config,
renderMoreSuffix,
textRender,
isDragging,
useOptimismUI,
isOptimismSaving,
contextMenuCallback,
resourceTreeWrapperRef,
...props
}: CommonComponentProps) => {
const { name, status } = resource;
const optimismUILoading = useMemo(() => {
if (isOptimismSaving) {
if (typeof useOptimismUI === 'object' && useOptimismUI.loadingRender) {
return useOptimismUI.loadingRender();
}
}
return null;
}, [isOptimismSaving]);
const suffix = useMemo(
() =>
!isInEdit &&
suffixRender?.render?.({
resource,
isSelected,
isTempSelected,
}),
[isSelected, isInEdit, resource, isTempSelected],
);
const moreTools = useMemo(
() =>
!isInEdit && renderMoreSuffix ? (
<MoreTools
resource={resource}
contextMenuCallback={contextMenuCallback}
resourceTreeWrapperRef={resourceTreeWrapperRef}
renderMoreSuffix={renderMoreSuffix}
/>
) : null,
[isInEdit, resource, renderMoreSuffix],
);
return (
<div
data-testid={`agent-ide.resource-item.${resource.type}.${resource.name}`}
key={resource.id}
className={'base-item'}
style={{
justifyContent: 'space-between',
height: config?.itemHeight || ITEM_HEIGHT,
borderRadius: 4,
paddingLeft: (path.length - 1) * (config?.tabSize || TAB_SIZE) - 4,
...(status === ItemStatus.Disabled
? {
fontStyle: 'italic',
filter: 'opacity(0.5)',
cursor: 'not-allowed',
textDecoration: 'line-through',
}
: {}),
}}
>
<div
style={{
display: 'flex',
overflow: isInEdit ? 'visible' : 'hidden',
width: '100%',
}}
>
{icon ? (
<span
className={'base-item-icon'}
style={{
color: 'rgba(6, 7, 9, 0.96)',
}}
>
{icon}
</span>
) : null}
{isInEdit ? (
<NameInput
resource={resource}
initValue={name}
handleSave={props.handleSave}
handleChangeName={props.handleChangeName}
errorMsg={props.errorMsg}
errorMsgRef={props.errorMsgRef}
validateConfig={props.validateConfig}
config={config}
/>
) : (
<MemoText
isSelected={isSelected}
resource={resource}
name={name}
searchConfig={searchConfig}
tooltipSpace={
(suffixRender?.width || 0) + (renderMoreSuffix ? 26 : 0)
}
textRender={textRender}
/>
)}
</div>
{optimismUILoading}
{suffix}
{moreTools}
</div>
);
};
export { ItemRender };

View File

@@ -0,0 +1,164 @@
/*
* 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, { memo } from 'react';
import { Typography, Highlight } from '@coze-arch/coze-design';
import {
type CommonRenderProps,
// ResourceTypeEnum,
// type ResourceStatusType,
type ResourceType,
} from '../../type';
import { COLOR_CONFIG } from '../../constant';
const { Text: BaseText } = Typography;
// const ResourceBadge = (props: {
// resource: ResourceType;
// status?: ResourceStatusType;
// }) => {
// const { resource, status = {} } = props;
// const badgeTextArr: string[] = [];
// if (status.problem?.number) {
// badgeTextArr.push(`${status?.problem?.number}`);
// }
// if (status.draft) {
// badgeTextArr.push('M');
// }
// const badgeText = badgeTextArr.join(', ');
// const level =
// status.problem?.status && status.problem.status !== 'normal'
// ? status.problem.status
// : status.draft
// ? 'warning'
// : '';
// return badgeText ? (
// <span
// style={{
// marginRight: 4,
// opacity: '0.75',
// color:
// level === 'error'
// ? 'rgba(var(--blockwise-error-color))'
// : 'rgba(var(--blockwise-warning-color))',
// }}
// >
// {resource.type !== ResourceTypeEnum.Folder ? (
// badgeText
// ) : (
// <Badge countStyle={{ backgroundColor: 'yellow' }} type="mini" />
// )}
// </span>
// ) : (
// <></>
// );
// };
const Text = ({
name,
resource,
searchConfig,
isSelected,
tooltipSpace,
textRender,
}: {
name: string;
resource: ResourceType;
searchConfig?: {
searchKey?: string;
highlightStyle?: React.CSSProperties;
};
isSelected?: boolean;
tooltipSpace?: number;
textRender?: (v: CommonRenderProps) => React.ReactElement | undefined;
}) => {
const color = (() => {
if (resource.problem?.status === 'error') {
return COLOR_CONFIG.textErrorColor;
} else if (resource.problem?.status === 'warning') {
return COLOR_CONFIG.textWarningColor;
} else if (isSelected) {
return COLOR_CONFIG.textSelectedColor;
}
return COLOR_CONFIG.textNormalColor;
})();
return (
<span
style={{
flex: 1,
display: 'flex',
height: '100%',
alignItems: 'center',
justifyContent: 'space-between',
overflow: 'hidden',
fontSize: 12,
}}
>
<BaseText
style={{ flex: 1 }}
ellipsis={{
showTooltip: {
opts: {
content: `${name}`,
style: { wordBreak: 'break-all' },
position: 'right',
spacing: 8 + (tooltipSpace || 0),
},
},
}}
>
{textRender ? (
textRender({ resource, isSelected })
) : (
<span
style={{
color,
}}
>
<Highlight
sourceString={name}
searchWords={[searchConfig?.searchKey || '']}
highlightStyle={{
...searchConfig?.highlightStyle,
backgroundColor: 'transparent',
color: 'var(--semi-color-primary)',
}}
/>
</span>
)}
</BaseText>
</span>
);
};
const MemoText = memo(Text, (pre, cur) => {
if (
pre.name !== cur.name ||
pre.searchConfig?.searchKey !== cur.searchConfig?.searchKey ||
pre.resource !== cur.resource ||
pre.isSelected !== cur.isSelected
) {
return false;
}
return true;
});
export { MemoText };

View File

@@ -0,0 +1,85 @@
/*
* 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 { IconCozMore } from '@coze-arch/coze-design/icons';
import { Button, Tooltip } from '@coze-arch/coze-design';
import { type RenderMoreSuffixType, type ResourceType } from '../../type';
import { MORE_TOOLS_CLASS_NAME } from '../../constant';
const MoreTools = ({
resource,
contextMenuCallback,
resourceTreeWrapperRef,
renderMoreSuffix,
}: {
resource: ResourceType;
contextMenuCallback: (e: any, resources?: ResourceType[]) => () => void;
resourceTreeWrapperRef: React.MutableRefObject<HTMLDivElement | null>;
renderMoreSuffix?: RenderMoreSuffixType;
}) => {
const handleClick = e => {
/**
* 这里将 event 的 currentTarget 设置成树组件的 wrapper 元素,保证 contextMenu 的 matchItems 方法可以正常遍历。
*/
e.currentTarget = resourceTreeWrapperRef.current;
contextMenuCallback(e, [resource]);
};
const btnElm = (
<Button
data-testid={`agent-ide.resource-item.${resource.type}.${resource.name}.more-tools`}
{...(typeof renderMoreSuffix === 'object' && renderMoreSuffix?.extraProps
? renderMoreSuffix?.extraProps
: {})}
className={`base-item-more-hover-display-class ${MORE_TOOLS_CLASS_NAME} base-item-more-btn ${
typeof renderMoreSuffix === 'object' && renderMoreSuffix.className
? renderMoreSuffix.className
: ''
}`}
style={
typeof renderMoreSuffix === 'object' && renderMoreSuffix.style
? renderMoreSuffix.style
: {}
}
icon={<IconCozMore />}
theme="borderless"
size="small"
onMouseUp={handleClick}
/>
);
if (typeof renderMoreSuffix === 'object' && renderMoreSuffix.render) {
return renderMoreSuffix.render({
onActive: handleClick,
baseBtn: btnElm,
resource,
});
}
if (typeof renderMoreSuffix === 'object' && renderMoreSuffix.tooltip) {
if (typeof renderMoreSuffix.tooltip === 'string') {
return <Tooltip content={renderMoreSuffix.tooltip}>{btnElm}</Tooltip>;
}
return <Tooltip {...renderMoreSuffix.tooltip}>{btnElm}</Tooltip>;
}
return btnElm;
};
export { MoreTools };

View File

@@ -0,0 +1,139 @@
/*
* 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, { useEffect, useRef, useState } from 'react';
import { Input } from '@coze-arch/coze-design';
import { type CommonComponentProps } from '../../type';
import { MOUSEUP_IGNORE_CLASS_NAME } from '../../constant';
const DATASET_PARENT_DATA_KEY_ID = 'name_input_wrapper';
const isClickOutside = (elm, deep = 0) => {
if (!elm || deep > 10) {
return true;
}
if (elm.dataset?.[DATASET_PARENT_DATA_KEY_ID] !== undefined) {
return false;
}
return isClickOutside(elm.parentElement, deep + 1);
};
const NameInput = ({
resource,
initValue,
handleSave: onSave,
handleChangeName,
errorMsg,
errorMsgRef,
validateConfig,
config,
}: { initValue: string } & Pick<
CommonComponentProps,
| 'resource'
| 'handleSave'
| 'handleChangeName'
| 'errorMsg'
| 'errorMsgRef'
| 'validateConfig'
| 'config'
>) => {
const [value, setValue] = useState(initValue);
const ref = useRef(null);
const handleSave = () => {
onSave(errorMsgRef?.current ? initValue : undefined);
};
const loaded = useRef(false);
useEffect(() => {
setTimeout(() => {
loaded.current = true;
}, 0);
const handleBlur = (e: MouseEvent) => {
const clickOutside = isClickOutside(e.target);
if (clickOutside) {
handleSave();
}
};
window.addEventListener('mousedown', handleBlur, true);
return () => {
window.removeEventListener('mousedown', handleBlur, true);
};
}, []);
return (
<div
{...{
[`data-${DATASET_PARENT_DATA_KEY_ID}`]: true,
}}
className={`base-item-name-input ${MOUSEUP_IGNORE_CLASS_NAME} ${
errorMsg ? 'base-item-name-input-error' : ''
}`}
style={{ width: '100%' }}
>
<Input
className={config?.input?.className || ''}
style={{ padding: 0, ...config?.input?.style }}
ref={ref}
placeholder={config?.input?.placeholder}
onMouseDown={e => {
e.stopPropagation();
}}
onKeyDown={e => {
e.stopPropagation();
if (e.code === 'Escape') {
onSave('');
}
}}
onEnterPress={e => {
if (!loaded.current) {
return;
}
e.stopPropagation();
e.preventDefault();
handleSave();
}}
onChange={v => {
setValue(v);
handleChangeName(v);
}}
value={value}
autoFocus
/>
{errorMsg ? (
validateConfig?.errorMsgRender ? (
validateConfig?.errorMsgRender?.(errorMsg, resource)
) : (
<div
style={validateConfig?.errorMsgStyle || {}}
className={`base-item-name-input-error-msg-absolute ${validateConfig?.errorMsgClassName}`}
>
{errorMsg}
</div>
)
) : null}
</div>
);
};
export { NameInput };

View File

@@ -0,0 +1,64 @@
/*
* 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 { type CommonComponentProps } from '../type';
import { ItemRender } from './components/item-render';
const FileRender: React.FC<CommonComponentProps> = ({
resource,
path,
...props
}) => {
const { isDragging, draggingError, isSelected, isTempSelected, iconRender } =
props;
const cursor = (() => {
if (draggingError) {
return 'not-allowed';
} else if (isDragging) {
return 'grabbing';
}
return 'pointer';
})();
return (
<div
key={`file-${resource.id}`}
style={{
cursor,
}}
>
<ItemRender
resource={resource}
path={path}
icon={
resource?.type
? iconRender?.({
resource,
isSelected,
isTempSelected,
})
: undefined
}
{...props}
/>
</div>
);
};
export { FileRender };

View File

@@ -0,0 +1,114 @@
/*
* 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 { type CommonComponentProps } from '../type';
import { ItemRender } from './components/item-render';
const FolderRender: React.FC<CommonComponentProps> = ({
resource,
path,
style,
...props
}) => {
const { id } = resource;
const {
iconRender,
isSelected,
isTempSelected,
isExpand,
draggingError,
isDragging,
} = props;
const cursor = (() => {
if (draggingError) {
return 'not-allowed';
} else if (isDragging) {
return 'grabbing';
}
return 'default';
})();
const isRoot = path.length === 1;
// const { parentId: createResourceParentId, type: createResourceType } =
// createResourceInfo || {};
// const { renderCreateNode, appendIndex, itemElm } = (() => {
// if (String(createResourceParentId) === String(id)) {
// return {
// renderCreateNode: true,
// appendIndex:
// createResourceType === ResourceTypeEnum.Folder
// ? 0
// : children?.findIndex(
// child => child.type !== ResourceTypeEnum.Folder,
// ),
// itemElm: (
// <BaseRender
// resource={{
// id: CREATE_RESOURCE_ID,
// name: '',
// type: createResourceType,
// }}
// path={path}
// />
// ),
// };
// }
// return {
// renderCreateNode: false,
// appendIndex: -1,
// itemElm: null,
// };
// })();
return (
<>
<div
key={`folder-${id}`}
style={{
...(style || {}),
cursor,
}}
>
{!isRoot && (
<ItemRender
resource={resource}
path={path}
icon={
iconRender
? iconRender({
resource,
isSelected,
isTempSelected,
isExpand,
})
: undefined
}
{...props}
/>
)}
</div>
</>
);
};
export { FolderRender };

View File

@@ -0,0 +1,356 @@
/*
* 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.
*/
/* eslint-disable @typescript-eslint/no-explicit-any */
import type React from 'react';
import { type MutableRefObject } from 'react';
import {
type TooltipProps,
type ButtonProps,
type SemiButton,
} from '@coze-arch/coze-design';
import { type URI } from '@coze-project-ide/client';
import { type BaseResourceContextMenuBtnType } from './hooks/use-right-click-panel/constant';
import { type CREATE_RESOURCE_ID } from './hooks/use-create-edit-resource';
export type IdType = string;
export enum ResourceTypeEnum {
Folder = 'folder',
}
export type ItemType = ResourceTypeEnum | string;
export interface ResourceStatusType {
// 是否是草稿态
draft?: boolean;
// 错误内容 & 个数
problem?: {
status?: 'normal' | 'error' | 'warning';
number?: number;
};
}
export interface ResourceType {
id: IdType;
type?: ItemType;
name: string;
description?: string;
children?: ResourceType[];
path?: string[];
maxDeep?: number;
[T: string]: any;
}
export type ResourceMapType<
T = {
[T: string]: any;
},
> = Record<IdType, ResourceType & ResourceStatusType & T>;
export interface CustomResourceConfigType extends ResourceStatusType {
[T: string]: any;
}
export type CustomResourceConfigMapType = Record<
IdType,
CustomResourceConfigType
>;
export type RenderMoreSuffixType =
| boolean
| {
className?: string;
style?: React.CSSProperties;
extraProps?: ButtonProps & React.RefAttributes<SemiButton>;
render?: (
v: {
onActive: (e: React.MouseEventHandler<HTMLButtonElement>) => void;
baseBtn: React.ReactElement;
} & CommonRenderProps,
) => React.ReactElement;
tooltip?: string | TooltipProps;
};
export interface CommonComponentProps {
resource: ResourceType;
path: Array<IdType>;
style?: React.CSSProperties;
isTempSelected?: boolean;
isSelected?: boolean;
isInEdit?: boolean;
isDraggingHover?: boolean;
icon?: React.ReactElement;
searchConfig?: {
searchKey?: string;
highlightStyle?: React.CSSProperties;
};
suffixRender?: {
width: number;
render: (v: CommonRenderProps) => React.ReactElement | undefined;
};
config?: ConfigType;
renderMoreSuffix?: RenderMoreSuffixType;
textRender?: (v: CommonRenderProps) => React.ReactElement | undefined;
isDragging: boolean;
isExpand?: boolean;
/**
* 是否处于乐观 ui 的保存阶段
*/
isOptimismSaving?: boolean;
useOptimismUI?:
| boolean
| {
loadingRender?: () => React.ReactElement;
};
draggingError?: string;
contextMenuCallback: (e: any, resources?: ResourceType[]) => () => void;
resourceTreeWrapperRef: React.MutableRefObject<HTMLDivElement | null>;
iconRender?: (v: CommonRenderProps) => React.ReactElement | undefined;
currentHoverItem: ResourceType | null;
validateConfig?: ValidatorConfigType;
errorMsg?: string;
errorMsgRef?: MutableRefObject<string>;
editResourceId: IdType | undefined;
handleChangeName: (v: string) => void;
handleSave: (v?: string) => void;
}
export interface DragPropType {
resourceList?: ResourceType[];
toId?: IdType;
errorMsg?: string;
}
export interface ChangeNameType {
id: IdType;
name: string;
type?: ItemType;
path?: IdType[];
resource?: ResourceType;
}
export interface DragAndDropType {
isDragging: boolean;
draggingError?: string;
isFocus: boolean;
tempSelectedMapRef: React.MutableRefObject<Record<string, ResourceType>>;
dataHandler: (resource: ResourceType) => Record<string, IdType>;
currentHoverItem: ResourceType | null;
highlightItemMap: ResourceMapType;
}
export interface EditItemType {
isInEditMode: boolean;
errorMsg?: string;
errorMsgRef?: MutableRefObject<string>;
editResourceId: IdType | undefined;
createResourceInfo: {
parentId: IdType;
type: ItemType;
index: number;
} | null;
handleChangeName: (v: string) => void;
handleSave: (v?: string) => void;
}
export interface CommonRenderProps {
resource: ResourceType;
isSelected?: boolean;
isTempSelected?: boolean;
isExpand?: boolean /** 只有 resource.type === folder 的时候才会有该字段 */;
}
export interface ContextType {
selected?: IdType;
disabled?: boolean;
collapsedMapRef: React.MutableRefObject<Record<IdType, boolean> | null>;
setCollapsed: (id: IdType, v: boolean) => void;
searchConfig?: {
searchKey?: string;
highlightStyle?: React.CSSProperties;
};
resourceTreeWrapperRef: React.MutableRefObject<HTMLDivElement | null>;
resourceMap: React.MutableRefObject<ResourceMapType>;
dragAndDropContext: DragAndDropType;
createEditResourceContext: EditItemType;
renderMoreSuffix?:
| boolean
| {
className?: string;
style?: React.CSSProperties;
render?: () => React.ReactElement;
};
validateConfig?: ValidatorConfigType;
contextMenuCallback: (e: any, resources?: ResourceType[]) => () => void;
iconRender?: (v: CommonRenderProps) => React.ReactElement | undefined;
suffixRender?: {
width: number;
render: (v: CommonRenderProps) => React.ReactElement | undefined;
};
textRender?: (v: CommonRenderProps) => React.ReactElement | undefined;
config?: ConfigType;
optimismSavingMap: Record<IdType, true>;
useOptimismUI?:
| boolean
| {
loadingRender?: () => React.ReactElement;
};
}
export interface CustomValidatorPropsType {
type: 'create' | 'edit';
label: string;
parentPath: string[];
resourceTree: ResourceType;
id: IdType | typeof CREATE_RESOURCE_ID;
}
export interface ValidatorConfigType {
/**
* @param label 当前输入框的文本
* @param parentPath 从 root 开始到 父级的路径
* @param resourceTree 当前的资源树
* @returns Promise<string>
*/
/** */
customValidator?: (
data: CustomValidatorPropsType,
) => string | Promise<string>;
/**
* 默认: absolute;
* absolute: 不占用树的文档流,错误文案覆盖上去;
*/
errorMsgPosition?: 'absolute';
errorMsgStyle?: React.CSSProperties;
errorMsgClassName?: string;
errorMsgRender?: (msg: string, resource: ResourceType) => React.ReactElement;
}
export interface RightOptionsType {
id: string;
label: string;
icon?: React.ReactElement;
onClick?: () => void;
disabled?: boolean;
disabledMsg?: boolean;
}
export interface CreateResourcePropType {
type: ItemType;
parentId: IdType;
path: IdType[];
name: string;
schema?: any;
description?: string;
}
export interface CommandOption {
id: string;
label: string;
disabled?: boolean;
disabledMsg?: string;
onClick?: () => void;
}
/** 内置的顶部工具栏的按钮组 */
export enum BuildInToolOperations {
/** 搜索过滤按钮 */
CreateFile = 'CreateFile',
CreateFolder = 'CreateFolder',
/** 展开收起文件夹 */
ExpandFolder = 'ExpandFolder',
}
export type RightPanelConfigType =
| {
// command id
id: BaseResourceContextMenuBtnType | string;
label?: string;
shortLabel?: string;
disabled?: boolean;
tooltip?: string;
/**
* 这里有两类 命令。
* 1. 不在外部插件中提前注册好的,需要组件内部动态注册的。往往是该组件树定制化的菜单项。比如 校验并运行该资源
* 那这个 execute 字段是一个必填项
* 2. 在外部插件中提前注册好的,往往是需要配合快捷键一起使用的,并且具有一定的普适性的命令。比如创建资源,复制资源等。
* 那这个 execute 不需要配置,的回调在 plugin 中注册的地方触发
* 上下文可以通过 RESOURCE_FOLDER_CONTEXT_KEY 这个 key 从 ide 上下文中获取
*/
execute?: () => void;
}
| {
// 分割线
type: 'separator';
};
export interface ConfigType {
/**
* 每个资源的高度
*/
itemHeight?: number;
/**
* 半个 icon 的宽度,用于左侧折线的计算。
*/
halfIconWidth?: number;
/**
* 每个文件夹下缩进的宽度
*/
tabSize?: number;
/**
* 文件夹下钻最大的深度
*/
maxDeep?: number;
/**
* 资源 name 输入框配置
*/
input?: {
className?: string;
style?: React.CSSProperties;
placeholder?: string;
};
/**
* 拖拽过程中的预览框配置项
*/
dragUi?: {
disable?: boolean;
wrapperClassName?: string;
/**
* 特别说明: 可以通过配置这里的 top 和 left 来设置相对鼠标的偏移量
*/
wrapperStyle?: React.CSSProperties;
};
resourceUriHandler?: (resource: ResourceType) => URI | null;
}
export interface ResourceFolderContextType {
id?: string; // folder 组件唯一的 id
currentSelectedId?: IdType; // 当前选中的资源 id
tempSelectedMap?: Record<string, ResourceType>; // 当前临时选中的资源 map
onEnter?: () => void;
onDelete?: () => void;
onCreateFolder?: () => void;
onCreateResource?: (type?: ResourceTypeEnum) => void;
}

View File

@@ -0,0 +1,66 @@
/*
* 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.
*/
/* eslint-disable @typescript-eslint/no-explicit-any */
type Any = any;
type BaseEventParamsType = Record<string, Any>;
export class BaseEvent<T extends string, O extends BaseEventParamsType> {
protected deps: Record<T, ((data?: Any) => Any)[]>;
constructor() {
this.deps = {} as any;
}
on(name: T, func: (data?: O[T]) => Any): void {
if (this.deps[name]) {
this.deps[name].push(func);
} else {
this.deps[name] = [func];
}
}
once(name: T, func: (data?: O[T]) => Any): void {
const f = (data: Any) => {
func(data);
this.un(name, f);
};
if (this.deps[name]) {
this.deps[name].push(f);
} else {
this.deps[name] = [f];
}
}
un(name?: T, func?: (data?: O[T]) => Any): void {
if (!name) {
this.deps = {} as any;
return;
}
if (func) {
this.deps[name] = this.deps[name].filter(fn => fn !== func);
} else {
delete this.deps[name];
}
}
emit(name: T, data?: O[T]): void {
if (this.deps[name]) {
this.deps[name].forEach(fn => fn(data));
}
}
}

View File

@@ -0,0 +1,539 @@
/*
* 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 IdType,
type ResourceType,
type ResourceMapType,
ResourceTypeEnum,
} from '../type';
import { ROOT_KEY, ROOT_NODE } from '../constant';
export const RESOURCE_FOLDER_COMMAND_PREFIX = 'resource-folder-command-prefix';
export const createUniqId = (key: string, suffix: string) =>
`${RESOURCE_FOLDER_COMMAND_PREFIX}_${key}_${suffix}`;
export const findResourceByPath = (
resourceTree: ResourceType,
path: IdType[],
): ResourceType | undefined => {
let currentIndex = 0;
let currentResource: undefined | ResourceType = resourceTree;
if (String(currentResource.id) !== String(path[currentIndex])) {
return undefined;
}
currentIndex += 1;
while (currentIndex < path.length && currentResource) {
currentResource = (currentResource.children || []).find(
child => String(child.id) === String(path[currentIndex]),
);
currentIndex += 1;
}
return currentResource;
};
export const getParentResource = (
resourceTree: ResourceType,
targetResource: ResourceType,
) => getResourceById(resourceTree, targetResource.id)?.parent;
export const getResourceById = (
resourceTree: ResourceType,
id: IdType,
): {
resource: ResourceType | null;
parent: ResourceType | null;
path: IdType[];
} | null => {
let parent: ResourceType | null = null;
let result: {
resource: ResourceType | null;
parent: ResourceType | null;
path: IdType[];
} | null = null;
const dfs = (resource: ResourceType, _path: IdType[]) => {
if (result) {
return;
}
if (String(resource.id) === String(id)) {
result = {
resource,
parent,
path: _path,
};
return;
}
if (resource.children) {
const currentParent = parent;
parent = resource;
resource.children.forEach(child => {
dfs(child, [..._path, child.id]);
});
parent = currentParent;
}
return null;
};
dfs(resourceTree, [resourceTree.id]);
return result;
};
/**
* 用 shift 修饰键的时候, 从 from 的到 to 的中间全部选中,包括下钻文件。 dfs
*/
export const getResourceListFromIdToId = ({
resourceTree,
from,
to,
options,
}: {
resourceTree: ResourceType;
from: IdType;
to: IdType;
options?: { collapsedMap?: Record<string, boolean> };
}): ResourceType[] | IdType[] => {
const { collapsedMap } = options || {};
const result: Array<IdType> = [];
let isStart = false;
const dfs = (resource: ResourceType, _path: IdType[]) => {
const shot =
String(resource.id) === String(from) ||
String(resource.id) === String(to);
if (!isStart && shot) {
isStart = true;
result.push(resource.id);
} else if (isStart) {
result.push(resource.id);
if (shot) {
isStart = false;
}
}
if (resource.children && !collapsedMap?.[resource.id]) {
resource.children.forEach(child => {
dfs(child, [..._path, child.id]);
});
}
};
dfs(resourceTree, [resourceTree.id]);
return result;
};
export const getAllResourcesInFolder = (
resourceTree: ResourceType | ResourceType[],
) => {
const folders = resourceTree instanceof Array ? resourceTree : [resourceTree];
const result: ResourceType[] = [];
const dfs = (resource: ResourceType) => {
if (!resource) {
return;
}
if (resource.children) {
resource.children.forEach(child => {
dfs(child);
});
}
if (resource.type !== 'folder') {
result.push(resource);
}
};
folders.forEach(folder => {
dfs(folder);
});
return result;
};
export const sortResourceList = (
resourceList: ResourceType[],
): ResourceType[] => {
const sortFunc = (a, b) => {
const leftName = a.name?.toLowerCase?.() || '';
const rightName = b.name?.toLowerCase?.() || '';
if (leftName < rightName) {
return -1;
}
if (leftName > rightName) {
return 1;
}
return 0;
};
const folderList = resourceList
.filter(source => source.type === ResourceTypeEnum.Folder)
.sort((a, b) => sortFunc(a, b));
const sourceList = resourceList
.filter(source => source.type !== ResourceTypeEnum.Folder)
.sort((a, b) => sortFunc(a, b));
return folderList.concat(sourceList) as ResourceType[];
};
// 后续要优化算法的话 ,得在树打平的算法中,记录每个文件夹的高度,这样在 change 的时候不需要重复计算。 packages/api-builder/base/src/utils/resource-folder/index.ts mapResourceTree
export const calcOffsetTopByCollapsedMap = (props: {
selectedId: string;
resourceTree: ResourceType;
collapsedMap: Record<IdType, boolean>;
itemHeight: number;
}) => {
const { selectedId, resourceTree, collapsedMap, itemHeight } = props;
let num = -1; // 因为从 root 开始, root 不展示,所以从 -1 开始算
let finish = false;
const dfs = (resource: ResourceType) => {
if (!resource || finish) {
return;
}
num += 1;
if (selectedId === resource.id) {
finish = true;
return;
}
if (resource.children && !collapsedMap[resource.id]) {
resource.children.forEach(child => {
dfs(child);
});
}
};
dfs(resourceTree);
return (num - 1) * itemHeight;
};
export const travelResource = (
resource: ResourceType,
cb: (item: ResourceType) => boolean,
) => {
if (resource) {
const shouldContinue = cb(resource);
if (!shouldContinue) {
return;
}
if (resource.children) {
resource.children.forEach(child => {
travelResource(child, cb);
});
}
}
};
export const getResourceTravelIds = (ctx: {
resource: ResourceType;
resourceMap: Record<string, ResourceType>;
collapsedMap: Record<string, boolean>;
}): string[] => {
const { resource, resourceMap, collapsedMap } = ctx;
const ids: string[] = [];
travelResource(resource, item => {
const info = resourceMap[item.id];
// 被删除的资源、文件夹不展示
if (!info || info.status === 'deprecated') {
return false;
}
// 折叠的文件夹折叠后,不遍历只节点
if (info.type === ResourceTypeEnum.Folder && collapsedMap[info.id]) {
ids.push(item.id);
return false;
}
ids.push(item.id);
return true;
});
return ids.filter(id => id !== ROOT_KEY);
};
export const findLastResource = (ctx: {
id: string;
resource: ResourceType;
resourceMap: Record<string, ResourceType>;
collapsedMap: Record<string, boolean>;
}) => {
const { resource, resourceMap, collapsedMap, id } = ctx;
const ids = getResourceTravelIds({
resource,
resourceMap,
collapsedMap,
});
const index = ids.findIndex(item => item === id);
if (index < 0) {
return;
}
const finalIndex = (index - 1 + ids.length) % ids.length;
return {
id: ids[finalIndex],
info: resourceMap[ids[finalIndex]],
};
};
export const findNextResource = (ctx: {
id: string;
resource: ResourceType;
resourceMap: Record<string, ResourceType>;
collapsedMap: Record<string, boolean>;
}) => {
const { resource, resourceMap, collapsedMap, id } = ctx;
const ids = getResourceTravelIds({
resource,
resourceMap,
collapsedMap,
});
const index = ids.findIndex(item => item === id);
if (index < 0) {
return;
}
const final = (index + 1) % ids.length;
return {
id: ids[final],
info: resourceMap[ids[final]],
};
};
export const validateSameNameInFolder = ({
folder,
editResource,
}: {
folder: ResourceType;
editResource: ResourceType;
}): string => {
if (!folder || !editResource) {
return '';
}
const children = (folder.children || []).filter(
child => child.id !== editResource.id,
);
const hasSameName = children.some(child => child.name === editResource.name);
return hasSameName
? `有一个文件或文件夹 ${editResource.name} 已经存在在当前位置,请使用一个不同的名称`
: '';
};
export const mapResourceTree = (resourceTree): ResourceMapType => {
if (!resourceTree) {
return {};
}
const fullTree = {
...ROOT_NODE,
children: resourceTree instanceof Array ? resourceTree : [resourceTree],
};
const result: ResourceMapType = {};
const dfs = (
resource,
path: string[],
): { maxDeep: number; editDraft?: boolean } => {
if (!resource) {
return { maxDeep: path.length - 1 };
}
// 文件夹要加一,因为能加文件
let maxDeep = path.length + (resource.type === 'folder' ? 1 : 0);
// 当前资源是否处于提交状态
let editDraft = resource.edit_status === 'draft';
if (resource.children) {
resource.children.forEach(child => {
const { maxDeep: deep, editDraft: status } = dfs(child, [
...path,
child.id,
]);
maxDeep = Math.max(maxDeep, deep);
// 文件夹 editDraft 跟随草稿走,只要内部有一个为草稿,本文件夹也为草稿
editDraft = !!(editDraft || status);
});
}
result[String(resource.id)] = {
...resource,
path,
maxDeep: maxDeep - path.length,
/**
* 随业务放开
*/
// draft: editDraft,
// problem: {
// status: 'warning',
// number: 12,
// },
};
return { maxDeep, editDraft };
};
dfs(fullTree, [fullTree.id]);
return result;
};
export const combineExtraObject = (
mainObj: Record<IdType, any>,
extraMap: Record<IdType, any>,
) =>
Object.keys(mainObj).reduce(
(pre, cur) => ({
...pre,
[cur]: {
...mainObj[cur],
...extraMap[cur],
},
}),
{},
);
export const flatTree = (
tree: ResourceType,
map: ResourceMapType,
collapsedMap: Record<string, boolean>,
) => {
const result: ResourceType[] = [];
const dfs = (resource: ResourceType) => {
result.push(map[resource.id]);
if (resource.children && !collapsedMap[resource.id]) {
resource.children.forEach(child => {
dfs(child);
});
}
return null;
};
dfs(tree);
return result;
};
/**
* 计算当前文件夹下,新建的资源所处的位置。
* 文件夹:在当前文件夹下顶部
* 资源:在当前文件夹下的文件夹末尾,所有资源顶部
*/
export const getCreateResourceIndex = ({
resourceList,
parentId,
type,
}: {
resourceList: ResourceType[];
parentId: string;
type: ResourceTypeEnum | string;
}) => {
let i = 0;
let inFolder = false;
let parentPath: string[] = [];
while (i < resourceList.length) {
const resource = resourceList[i];
if (inFolder && (resource.path?.length || 0) <= parentPath.length) {
return i;
}
if (
inFolder &&
resource.type !== ResourceTypeEnum.Folder &&
(resource.path?.length || 0) - 1 === parentPath.length
) {
return i;
}
if (resource.id === parentId) {
inFolder = true;
parentPath = resource.path || [];
if (type === ResourceTypeEnum.Folder) {
return i + 1;
}
}
i += 1;
}
return i;
};
export function baseValidateNames(props: { label: string; nameTitle: string }) {
const { label, nameTitle } = props;
const simple = true;
// 设定默认值,避免 propExtra 只传入一个配置
// 检测 name 是否空
if (!label) {
return simple ? 'Empty Key' : `${nameTitle} name can not be empty`;
}
if (label.length > 64) {
return simple ? 'Length exceeds' : `${nameTitle} name length exceeds limit`;
}
// 必须由字母开头
if (!/^[A-Za-z]/.test(label)) {
return simple
? 'Must start with letter'
: `${nameTitle} name must start with a letter`;
}
// 检测 name 的命名规则
if (!/^[A-Za-z][0-9a-zA-Z_]*$/.test(label)) {
return simple
? 'only ASCII letters, digits, and _'
: `${nameTitle} name can only contain ASCII letters, digits, and _`;
}
return '';
}

View File

@@ -0,0 +1,5 @@
.content-container {
display: flex;
align-items: center;
column-gap: 6px;
}

View File

@@ -0,0 +1,61 @@
/*
* 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, { useMemo } from 'react';
import { I18n } from '@coze-arch/i18n';
import { Button } from '@coze-arch/coze-design';
import {
ResourceCopyScene,
type ResType,
} from '@coze-arch/bot-api/plugin_develop';
import { useNavigateResource } from './use-navigate-resource';
import styles from './styles.module.less';
export const SuccessContent = ({
spaceId,
scene,
resourceId,
resourceType,
}: {
spaceId?: string;
scene?: ResourceCopyScene;
resourceId?: string;
resourceType?: ResType;
}) => {
const handleNavigateResource = useNavigateResource({
resourceType,
resourceId,
spaceId,
});
const content = useMemo(() => {
if (scene === ResourceCopyScene.MoveResourceToLibrary) {
return I18n.t('resource_toast_move_to_library_success');
}
return I18n.t('resource_toast_copy_to_library_success');
}, [scene]);
return (
<div className={styles['content-container']}>
{content}
<Button color="primary" size="small" onClick={handleNavigateResource}>
{I18n.t('resource_toast_view_resource')}
</Button>
</div>
);
};

View File

@@ -0,0 +1,52 @@
/*
* 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 { ResType } from '@coze-arch/bot-api/plugin_develop';
export const useNavigateResource =
({
resourceId,
resourceType,
spaceId,
}: {
resourceId?: string;
resourceType?: ResType;
spaceId?: string;
}) =>
() => {
switch (resourceType) {
case ResType.Plugin:
window.open(`/space/${spaceId}/plugin/${resourceId}`);
break;
case ResType.Workflow:
case ResType.Imageflow:
window.open(`/work_flow?workflow_id=${resourceId}&space_id=${spaceId}`);
break;
case ResType.Knowledge:
window.open(`/space/${spaceId}/knowledge/${resourceId}`);
break;
case ResType.UI:
window.open(`/space/${spaceId}/widget/${resourceId}`);
break;
case ResType.Database:
window.open(
`/space/${spaceId}/database/${resourceId}?page_mode=normal`,
);
break;
default:
return;
}
};

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 enum CustomCommand {
RELOAD = 'reload',
}

View File

@@ -0,0 +1,18 @@
/*
* 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 * from './uri';
export { CustomCommand } from './commands';

View File

@@ -0,0 +1,38 @@
/*
* 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 { URI } from '@coze-project-ide/client';
export const URI_SCHEME = 'coze-project';
export const TOP_BAR_URI = new URI(`${URI_SCHEME}:///top-bar`);
export const MAIN_PANEL_DEFAULT_URI = new URI(`${URI_SCHEME}:///default`);
export const SIDEBAR_URI = new URI(`${URI_SCHEME}:///side-bar`);
export const SECONDARY_SIDEBAR_URI = new URI(
`${URI_SCHEME}:///secondary-sidebar`,
);
export const SIDEBAR_RESOURCE_URI = new URI(
`${URI_SCHEME}:///side-bar/resource`,
);
export const SIDEBAR_CONFIG_URI = new URI(`${URI_SCHEME}:///side-bar/config`);
export const UI_BUILDER_URI = new URI(`${URI_SCHEME}:///ui-builder`);
export const UI_BUILDER_CONTENT = new URI(
`${URI_SCHEME}:///ui-builder/content`,
);
export const CONVERSATION_URI = new URI(`${URI_SCHEME}:///session`);

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 {
IDEGlobalProvider,
useIDEGlobalContext,
useIDEGlobalStore,
} from '@coze-project-ide/base-adapter';
export { WidgetContext } from './widget-context';

View File

@@ -0,0 +1,30 @@
/*
* 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 URI } from '@coze-project-ide/client';
import { type ProjectIDEServices } from '../types';
import { type WidgetService } from '../plugins/create-preset-plugin/widget-service';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export interface WidgetContext<T = any> {
uri?: URI; // 当前 widget 的 uri
store: T; // 当前 widget 的 store
widget: WidgetService;
services: ProjectIDEServices; // 全局的 ide 服务
}
export const WidgetContext = Symbol('WidgetContext');

View File

@@ -0,0 +1,17 @@
/*
* 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.
*/
/// <reference types='@coze-arch/bot-typings' />

View File

@@ -0,0 +1,36 @@
/*
* 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 { useSpaceId } from './use-space-id';
export { useProjectIDEServices } from './use-project-ide-services';
export { useCurrentWidgetContext } from './use-current-widget-context';
export { useActivateWidgetContext } from './use-activate-widget-context';
export { useIDENavigate } from './use-ide-navigate';
export { useCurrentModeType } from './use-current-mode-type';
export { useProjectId } from './use-project-id';
export { useSplitScreenArea } from './use-current-split-screen';
export { useTitle } from './use-title';
export { useIDELocation, useIDEParams } from './use-ide-location';
export { useIDEServiceInBiz } from './use-ide-service-in-biz';
export { useShortcuts } from './use-shortcuts';
export { useCommitVersion } from './use-commit-version';
export { useWsListener } from './use-ws-listener';
export {
useSendMessageEvent,
useListenMessageEvent,
} from './use-message-event';
export { useViewService } from './use-view-service';
export { useGetUIWidgetFromId } from './use-get-ui-widget-from-id';

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 {
useCurrentWidgetFromArea,
LayoutPanelType,
} from '@coze-project-ide/client';
import { type ProjectIDEWidget } from '@/widgets/project-ide-widget';
import { type WidgetContext } from '@/context/widget-context';
/**
* 用于提供当前 focus 的 widget 上下文
*/
export const useActivateWidgetContext = (): WidgetContext => {
const currentWidget = useCurrentWidgetFromArea(LayoutPanelType.MAIN_PANEL);
return (currentWidget as ProjectIDEWidget)?.context;
};

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 { useIDEGlobalStore } from '../context';
export const useCommitVersion = () => {
// 内置了 shallow 操作,无需 useShallow
// eslint-disable-next-line @coze-arch/zustand/prefer-shallow
const { version, patch } = useIDEGlobalStore(store => ({
version: store.version,
patch: store.patch,
}));
return {
version,
patch,
};
};

View File

@@ -0,0 +1,35 @@
/*
* 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 { useMemo } from 'react';
import { useLocation } from 'react-router-dom';
import { type ModeType } from '../types';
import { UI_BUILDER_URI } from '../constants';
export const useCurrentModeType = () => {
const { pathname } = useLocation();
const type: ModeType = useMemo(() => {
if (pathname.includes(UI_BUILDER_URI.path.toString())) {
return 'ui-builder';
}
return 'dev';
}, [pathname]);
return type;
};

View File

@@ -0,0 +1,92 @@
/*
* 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, useState } from 'react';
import {
ApplicationShell,
useIDEService,
type URI,
type DockLayout,
type ReactWidget,
type TabBar,
type Widget,
} from '@coze-project-ide/client';
import { compareURI } from '@/utils';
type Area = 'left' | 'right';
const getTabArea = (shell: ApplicationShell, uri?: URI): Area | undefined => {
let currentTabIndex = -1;
const area = (shell.mainPanel?.layout as DockLayout)?.saveLayout?.().main;
const children = (area as DockLayout.ISplitAreaConfig)?.children || [area];
children.forEach((child, idx) => {
const containCurrent =
uri &&
((child as DockLayout.ITabAreaConfig)?.widgets || []).some(
widget => (widget as ReactWidget).uri?.toString?.() === uri.toString(),
);
if (containCurrent) {
currentTabIndex = idx;
}
});
// 右边分屏不展示 hover icon
if (children?.length === 1) {
return undefined;
} else if (currentTabIndex === 1) {
return 'right';
} else {
return 'left';
}
};
/**
* 获取当前 uri 的资源在哪个分屏下
* left: 左边分屏
* right: 右边分屏
* undefined: 未分屏
*/
export const useSplitScreenArea = (
uri?: URI,
tabBar?: TabBar<Widget>,
): Area | undefined => {
const shell = useIDEService<ApplicationShell>(ApplicationShell);
const [area, setArea] = useState(getTabArea(shell, uri));
useEffect(() => {
setArea(getTabArea(shell, uri));
const listener = () => {
// 本次 uri 是否在当前 tab不是不执行
// 分屏过程中会出现中间态,布局变更时盲目执行会导致时序异常问题
const uriInCurrentTab = tabBar?.titles.some(title =>
compareURI((title.owner as ReactWidget)?.uri, uri),
);
if (uriInCurrentTab) {
setArea(getTabArea(shell, uri));
}
};
shell.mainPanel.layoutModified.connect(listener);
return () => {
shell.mainPanel.layoutModified.disconnect(listener);
};
}, [uri?.toString?.()]);
return area;
};

View File

@@ -0,0 +1,35 @@
/*
* 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 { useCurrentWidget } from '@coze-project-ide/client';
import { type ProjectIDEWidget } from '@/widgets/project-ide-widget';
import { type WidgetContext } from '../context/widget-context';
/**
* 获取当前的 WidgetContext
* 在 registry 的 renderContent 内调用
*/
export function useCurrentWidgetContext<T>(): WidgetContext<T> {
const currentWidget = useCurrentWidget() as ProjectIDEWidget;
if (!currentWidget.context) {
throw new Error(
'[useWidgetContext] Undefined widgetContext from ide context',
);
}
return currentWidget.context as WidgetContext<T>;
}

View File

@@ -0,0 +1,29 @@
/*
* 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 { URI, useIDEService, WidgetManager } from '@coze-project-ide/client';
import { type ProjectIDEWidget } from '../widgets/project-ide-widget';
import { URI_SCHEME } from '../constants';
export const useGetUIWidgetFromId = (
value: string,
): ProjectIDEWidget | undefined => {
const widgetManager = useIDEService<WidgetManager>(WidgetManager);
const uri = new URI(`${URI_SCHEME}://${value}`);
const widget = widgetManager.getWidgetFromURI(uri) as ProjectIDEWidget;
return widget;
};

View File

@@ -0,0 +1,94 @@
/*
* 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 { useCallback, useLayoutEffect, useRef, useState } from 'react';
import { type URI, useCurrentWidget } from '@coze-project-ide/client';
import { type ProjectIDEWidget } from '../widgets/project-ide-widget';
type ActivateCallback = (widget: ProjectIDEWidget) => void;
interface WidgetLocation {
uri: URI;
pathname: string;
params: { [key: string]: string | undefined };
}
const genLocationByURI = (uri: URI): WidgetLocation => ({
uri,
pathname: uri.path.toString(),
params: uri.queryObject,
});
const useCurrentWidgetActivate = (cb: ActivateCallback) => {
const currentWidget = useCurrentWidget() as ProjectIDEWidget;
useLayoutEffect(() => {
const dispose = currentWidget.onActivate(() => {
cb(currentWidget);
});
return () => dispose.dispose();
}, [currentWidget, cb]);
};
/**
* 获取当前 widget 的 location
*/
export const useIDELocation = () => {
const currentWidget = useCurrentWidget() as ProjectIDEWidget;
const [location, setLocation] = useState(
genLocationByURI(currentWidget.uri!),
);
const uriRef = useRef(currentWidget.uri?.toString());
const callback = useCallback<ActivateCallback>(
widget => {
if (uriRef.current !== widget.uri?.toString()) {
uriRef.current = widget.uri?.toString();
setLocation(genLocationByURI(widget.uri!));
}
},
[setLocation, uriRef],
);
useCurrentWidgetActivate(callback);
return location;
};
/**
* 获取当前 widget 的 query 参数
*/
export const useIDEParams = () => {
const currentWidget = useCurrentWidget() as ProjectIDEWidget;
const [params, setParams] = useState(currentWidget.uri?.queryObject || {});
const queryRef = useRef(currentWidget.uri?.query);
const callback = useCallback<ActivateCallback>(
widget => {
const query = widget.uri?.query;
if (queryRef.current !== query) {
queryRef.current = query;
setParams(widget.uri?.queryObject || {});
}
},
[queryRef, setParams],
);
useCurrentWidgetActivate(callback);
return params;
};

View File

@@ -0,0 +1,56 @@
/*
* 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 { useNavigate, type NavigateOptions } from 'react-router-dom';
import { useCallback } from 'react';
import { URI } from '@coze-project-ide/client';
import { addPreservedSearchParams } from '../utils';
import { URI_SCHEME, UI_BUILDER_URI } from '../constants';
import { useSpaceId } from './use-space-id';
import { useProjectIDEServices } from './use-project-ide-services';
import { useProjectId } from './use-project-id';
export const useIDENavigate = () => {
const { view } = useProjectIDEServices();
const spaceId = useSpaceId();
const projectId = useProjectId();
const navigate = useNavigate();
/**
* value(string): /:resourceType/:resourceId?a=a&b=b
*/
const IDENavigate = useCallback(
(value: string, options?: NavigateOptions) => {
const url = `/space/${spaceId}/project-ide/${projectId}${value}`;
const uri = new URI(`${URI_SCHEME}://${value}`);
const isUIBuilder = uri.displayName === UI_BUILDER_URI.displayName;
if (value && value !== '/' && !isUIBuilder) {
// 调用 openService
view.open(uri);
} else {
// 如果没有要打开的 widget就只打开主面板
view.openPanel(isUIBuilder ? 'ui-builder' : 'dev');
}
navigate(addPreservedSearchParams(url), options);
},
[spaceId, projectId, view, navigate],
);
return IDENavigate;
};

View File

@@ -0,0 +1,34 @@
/*
* 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 interfaces } from 'inversify';
import { useIDEContainer } from '@coze-project-ide/client';
/**
* 获取 IDE 的 IOC 模块
* 和 flow-ide/client 包内容相同,但可以支持在业务侧如 workflow 内调用
* @param identifier
*/
export function useIDEServiceInBiz<T>(
identifier: interfaces.ServiceIdentifier,
): T | undefined {
const container = useIDEContainer();
if (container.isBound(identifier)) {
return container.get(identifier) as T;
} else {
return undefined;
}
}

View File

@@ -0,0 +1,100 @@
/*
* 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 { useCallback, useEffect, useRef } from 'react';
import { useMemoizedFn } from 'ahooks';
import { URI, useIDEService } from '@coze-project-ide/client';
import { getURLByURI } from '../utils';
import { MessageEventService, type MessageEvent } from '../services';
import { URI_SCHEME } from '../constants';
import { useIDENavigate } from './use-ide-navigate';
export const useMessageEventService = () =>
useIDEService<MessageEventService>(MessageEventService);
/**
* 获取向 widget 发送信息函数的 hooks
*/
export const useSendMessageEvent = () => {
const messageEventService = useMessageEventService();
const navigate = useIDENavigate();
/**
* 向以 uri 为索引的 widget 发送信息
*/
const send = useCallback(
<T>(target: string | URI, data: MessageEvent<T>) => {
const uri =
typeof target === 'string'
? new URI(`${URI_SCHEME}://${target}`)
: target;
messageEventService.send(uri, data);
},
[messageEventService],
);
/**
* 向以 uri 为索引的 widget 发送信息,并且打开/激活此 widget
* 此函数比较常用
*/
const sendOpen = useCallback(
<T>(target: string | URI, data: MessageEvent<T>) => {
const uri =
typeof target === 'string'
? new URI(`${URI_SCHEME}://${target}`)
: target;
messageEventService.send(uri, data);
navigate(getURLByURI(uri));
},
[messageEventService, navigate],
);
return { send, sendOpen };
};
/**
* 监听向指定 uri 对应的唯一 widget 发送消息的 hook
* 监听消息的 widget 一定是知道 this.uri所以入参无须支持 string
* 注:虽然 widget.uri 的值是会变得,但其 withoutQuery().toString() 一定是不变的,所以 uri 可以认定为不变
*/
export const useListenMessageEvent = (
uri: URI,
cb: (e: MessageEvent) => void,
) => {
const messageEventService = useMessageEventService();
// 尽管 uri 对应的唯一 key 不会变化,但 uri 内存地址仍然会变化,这里显式的固化 uri 的不变性
const uriRef = useRef(uri);
// 保证 callback 函数的可变性
const listener = useMemoizedFn(() => {
const queue = messageEventService.on(uri);
queue.forEach(cb);
});
useEffect(() => {
// 组件挂在时去队列中取一次,有可能在组件未挂载前已经被发送了消息
listener();
const disposable = messageEventService.onSend(e => {
if (messageEventService.compare(e.uri, uriRef.current)) {
listener();
}
});
return () => disposable.dispose();
}, [messageEventService, listener, uriRef]);
};

View File

@@ -0,0 +1,24 @@
/*
* 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 { useIDEGlobalContext } from '../context';
export const useProjectId = () => {
const store = useIDEGlobalContext();
const projectId = store(state => state.projectId);
return projectId;
};

View File

@@ -0,0 +1,26 @@
/*
* 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 { useIDEService } from '@coze-project-ide/client';
import { ProjectIDEServices } from '../plugins/create-preset-plugin/project-ide-services';
export const useProjectIDEServices = (): ProjectIDEServices => {
const projectIDEServices =
useIDEService<ProjectIDEServices>(ProjectIDEServices);
return projectIDEServices;
};

View File

@@ -0,0 +1,35 @@
/*
* 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 {
useIDEService,
ShortcutsService,
CommandRegistry,
} from '@coze-project-ide/client';
export const useShortcuts = (commandId: string) => {
const commandRegistry = useIDEService<CommandRegistry>(CommandRegistry);
const shortcutsService = useIDEService<ShortcutsService>(ShortcutsService);
const shortcut = shortcutsService.getShortcutByCommandId(commandId);
const keybinding = shortcut.map(item => item.join(' ')).join('/');
const label = commandRegistry.getCommand(commandId)?.label;
return {
keybinding,
label,
};
};

View File

@@ -0,0 +1,24 @@
/*
* 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 { useIDEGlobalContext } from '../context';
export const useSpaceId = () => {
const store = useIDEGlobalContext();
const spaceId = store(state => state.spaceId);
return spaceId;
};

View File

@@ -0,0 +1,34 @@
/*
* 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, useState } from 'react';
import { useCurrentWidgetContext } from './use-current-widget-context';
export const useTitle = () => {
const currentWidgetContext = useCurrentWidgetContext();
const { widget } = currentWidgetContext;
const [title, setTitle] = useState(widget.getTitle());
useEffect(() => {
const disposable = widget.onTitleChanged(_title => {
setTitle(_title);
});
return () => {
disposable?.dispose?.();
};
}, []);
return title;
};

View File

@@ -0,0 +1,27 @@
/*
* 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 ViewService } from '@/plugins/create-preset-plugin/view-service';
import { useProjectIDEServices } from './use-project-ide-services';
/**
* 获取 ProjectIDE 所有视图操作
*/
export const useViewService = (): ViewService => {
const projectIDEServices = useProjectIDEServices();
return projectIDEServices.view;
};

View File

@@ -0,0 +1,44 @@
/*
* 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 { useCallback, useEffect } from 'react';
import { useIDEService } from '@coze-project-ide/client';
import { type WsMessageProps } from '@/types';
import { WsService } from '@/services';
export const useWsListener = (listener: (props: WsMessageProps) => void) => {
const wsService = useIDEService<WsService>(WsService);
useEffect(() => {
const disposable = wsService.onMessageSend(listener);
return () => {
disposable.dispose();
};
}, []);
const send = useCallback(
data => {
wsService.send(data);
},
[wsService],
);
return {
send,
};
};

View File

@@ -0,0 +1,141 @@
/*
* 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.
*/
/** 透传 sdk */
export {
IDEClient,
ReactWidget,
LayoutPanelType,
URI,
definePluginCreator,
bindContributions,
ViewContribution,
LifecycleContribution,
Emitter,
Event,
Disposable,
DisposableCollection,
useIDEService,
useNavigation,
LabelHandler,
CommandContribution,
ShortcutsContribution,
OpenerService,
useCurrentWidget,
DISABLE_HANDLE_EVENT,
ViewService,
LayoutRestorer,
ApplicationShell,
WidgetManager,
ViewRenderer,
type PluginCreator,
type ViewOptionRegisterService,
type BoxPanel,
ShortcutsService,
CommandRegistry,
useIDEContainer,
TabBarToolbar,
ContextKeyService,
type ShortcutsRegistry,
SplitWidget,
Command,
WindowService,
type CustomTitleType,
} from '@coze-project-ide/client';
export { useCommitVersion } from '@coze-project-ide/base-adapter';
export {
useCurrentWidgetContext,
useSpaceId,
useProjectId,
useProjectIDEServices,
useActivateWidgetContext,
useIDENavigate,
useCurrentModeType,
useSplitScreenArea,
useTitle,
useIDELocation,
useIDEParams,
useIDEServiceInBiz,
useShortcuts,
useListenMessageEvent,
useWsListener,
useSendMessageEvent,
useViewService,
useGetUIWidgetFromId,
} from './hooks';
export { IDEGlobalProvider, WidgetContext } from './context';
export {
UI_BUILDER_URI,
MAIN_PANEL_DEFAULT_URI,
SIDEBAR_URI,
URI_SCHEME,
SIDEBAR_CONFIG_URI,
CONVERSATION_URI,
SECONDARY_SIDEBAR_URI,
CustomCommand,
} from './constants';
export type { TitlePropsType, WidgetRegistry } from './types';
export {
withLazyLoad,
getResourceByPathname,
getURIByResource,
getResourceByURI,
getURIPathByPathname,
getURLByURI,
getURIByPath,
getPathnameByURI,
compareURI,
addPreservedSearchParams,
} from './utils';
export { ProjectIDEServices } from './plugins/create-preset-plugin/project-ide-services';
export { WidgetService } from './plugins/create-preset-plugin/widget-service';
export {
ProjectIDEClient,
ResourceFolder,
mapResourceTree,
ResourceTypeEnum,
BaseResourceContextMenuBtnType,
type CommonRenderProps,
type ResourceType,
type ResourceMapType,
type ResourceFolderRefType,
type RightPanelConfigType,
type ResourceFolderShortCutContextType,
type ResourceFolderProps,
type RenderMoreSuffixType,
type CreateResourcePropType,
RESOURCE_FOLDER_CONTEXT_KEY,
ROOT_KEY,
type IdType,
} from './components';
export { useIDEGlobalStore, useIDEGlobalContext } from './context';
export { ProjectIDEWidget } from './widgets/project-ide-widget';
export { CloseConfirmContribution } from './plugins/close-confirm-plugin/close-confirm-contribution';
export {
ModalService,
ModalType,
OptionsService,
ErrorService,
type MessageEvent,
} from './services';

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

View File

@@ -0,0 +1,28 @@
/*
* 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 { injectable } from 'inversify';
import { Emitter, type Event } from '@coze-project-ide/client';
@injectable()
export class ErrorService {
private readonly onErrorEmitter = new Emitter<void>();
readonly onError: Event<void> = this.onErrorEmitter.event;
toErrorPage() {
this.onErrorEmitter.fire();
}
}

View File

@@ -0,0 +1,23 @@
/*
* 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 { ModalService, ModalType } from './modal-service';
export { ErrorService } from './error-service';
export {
MessageEventService,
type MessageEvent,
} from './message-event-service';
export { WsService, OptionsService } from '@coze-project-ide/base-adapter';

View File

@@ -0,0 +1,74 @@
/*
* 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 { injectable, inject } from 'inversify';
import { Emitter, type URI, WidgetManager } from '@coze-project-ide/client';
export interface MessageEvent<T = any> {
name: string;
data?: T;
}
/**
* widget 的通信服务
*/
@injectable()
export class MessageEventService {
@inject(WidgetManager) widgetManager: WidgetManager;
/**
* 消息队列
*/
events = new Map<string, MessageEvent[]>();
onSendEmitter = new Emitter<MessageEvent & { uri: URI }>();
onSend = this.onSendEmitter.event;
private toKey(uri: URI) {
// 通过 uri 获取 widget 的唯一索引
return this.widgetManager.uriToWidgetID(uri);
}
/** 通过 uri 获取消息队列 */
private get(uri: URI): MessageEvent[] {
const key = this.toKey(uri);
if (this.events.has(key)) {
return this.events.get(key)!;
}
const queue = [];
this.events.set(key, queue);
return queue;
}
private delete(uri: URI) {
const key = this.toKey(uri);
return this.events.delete(key);
}
send(uri: URI, msg: MessageEvent) {
this.get(uri).push(msg);
this.onSendEmitter.fire({ uri, ...msg });
}
on(uri: URI) {
const queue = this.get(uri);
this.delete(uri);
return queue;
}
compare(uriA: URI, uriB: URI) {
return this.toKey(uriA) === this.toKey(uriB);
}
}

View File

@@ -0,0 +1,207 @@
/*
* 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.
*/
/* eslint-disable @coze-arch/use-error-in-catch */
import { inject, injectable } from 'inversify';
import { Emitter, type Event } from '@coze-project-ide/client';
import { OptionsService } from '@coze-project-ide/base-adapter';
import { I18n } from '@coze-arch/i18n';
import { sleep } from '@coze-arch/bot-utils';
import {
type ResourceCopyDispatchRequest,
type ResourceCopyScene,
type ResourceCopyTaskDetail,
ResType,
TaskStatus,
} from '@coze-arch/bot-api/plugin_develop';
import { PluginDevelopApi } from '@coze-arch/bot-api';
export enum ModalType {
RESOURCE = 'resource',
CLOSE_CONFIRM = 'close-confirm',
}
interface EmitterProps {
type: ModalType;
options?: any;
// 不传默认打开窗口
visible?: boolean;
scene?: ResourceCopyScene;
resourceName?: string;
}
const POLLING_DELAY = 1000;
/**
* 弹窗轮询服务
* startPolling 开始轮询
* retry 重试
* onCloseResourceModal 关闭轮询弹窗
*/
@injectable()
export class ModalService {
@inject(OptionsService) options: OptionsService;
readonly onModalVisibleChangeEmitter = new Emitter<EmitterProps>();
readonly onModalVisibleChange: Event<EmitterProps> =
this.onModalVisibleChangeEmitter.event;
readonly onCancelEmitter: Emitter<void> = new Emitter<void>();
readonly onCancel: Event<void> = this.onCancelEmitter.event;
protected readonly pollingDelay = 1000;
readonly onErrorEmitter = new Emitter<boolean | string>();
readonly onError: Event<boolean | string> = this.onErrorEmitter.event;
readonly onSuccessEmitter = new Emitter<ResourceCopyTaskDetail | undefined>();
readonly onSuccess: Event<ResourceCopyTaskDetail | undefined> =
this.onSuccessEmitter.event;
protected _stopPolling = false;
private _taskId?: string;
// 开始轮询
async startPolling(props: ResourceCopyDispatchRequest) {
this._stopPolling = false;
const resourceName = props.res_name;
try {
// 1. 请求接口,获取 taskId
this.onModalVisibleChangeEmitter.fire({
type: ModalType.RESOURCE,
scene: props.scene,
resourceName,
});
const { task_id, failed_reasons } =
await PluginDevelopApi.ResourceCopyDispatch(props);
this._taskId = task_id;
if (failed_reasons?.length) {
let errorInfo = '';
// workflow 特定文案
if (
failed_reasons.some(reason => reason.res_type === ResType.Workflow)
) {
errorInfo = I18n.t('resource_copy_move_notify');
} else {
errorInfo = failed_reasons.reduce((allInfo, item) => {
const currentError = `${item.res_name || ''}${item.reason || ''}`;
if (allInfo) {
return `${allInfo}\n${currentError}`;
} else {
return currentError;
}
}, '');
}
this.onErrorEmitter.fire(errorInfo);
return;
}
// 2. 轮询接口,根据 taskId 获取任务状态
if (task_id) {
this.doPolling(task_id);
} else {
this.onErrorEmitter.fire('no_task_id');
}
} catch (e) {
this.onErrorEmitter.fire(true);
}
}
async retry() {
this._stopPolling = false;
if (this._taskId) {
// 1. retry 接口
await PluginDevelopApi.ResourceCopyRetry({
task_id: this._taskId,
});
this.onErrorEmitter.fire(false);
// 2. 开始轮询
this.doPolling(this._taskId);
}
}
async doPolling(taskId: string) {
this._taskId = taskId;
if (this._stopPolling) {
return;
}
try {
const taskInfo = await this.polling();
const { status } = taskInfo || {};
await sleep(POLLING_DELAY);
if (this._taskId && !this._stopPolling) {
// 更新弹窗内 info 信息
this.onModalVisibleChangeEmitter.fire({
type: ModalType.RESOURCE,
scene: taskInfo?.scene,
resourceName: taskInfo?.res_name,
});
}
if (status === TaskStatus.Processing) {
this.doPolling(taskId);
} else if (status === TaskStatus.Successed) {
this._stopPolling = true;
this.onModalVisibleChangeEmitter.fire({
type: ModalType.RESOURCE,
visible: false,
});
this.onSuccessEmitter.fire(taskInfo);
} else {
this.onErrorEmitter.fire(true);
}
} catch (_e) {
this._stopPolling = true;
this.onErrorEmitter.fire(true);
}
}
/**
* 轮询请求接口,返回轮询状态
*/
private async polling(): Promise<ResourceCopyTaskDetail | undefined> {
try {
const { task_detail } = await PluginDevelopApi.ResourceCopyDetail({
task_id: this._taskId,
});
return task_detail;
} catch (e) {
this._stopPolling = true;
this.onErrorEmitter.fire(true);
return {
status: TaskStatus.Failed,
};
}
}
async onCloseResourceModal() {
this._stopPolling = true;
// 关闭 modal
this.onModalVisibleChangeEmitter.fire({
type: ModalType.RESOURCE,
visible: false,
});
this.onCancelEmitter.fire();
if (this._taskId) {
// 停止轮询的请求
await PluginDevelopApi.ResourceCopyCancel({
task_id: this._taskId,
});
this._taskId = undefined;
}
}
}

View File

@@ -0,0 +1,92 @@
/*
* 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 ReactElement } from 'react';
import {
type CommandRegistry,
type ReactWidget,
} from '@coze-project-ide/client';
import { type ProjectIDEWidget } from '@/widgets/project-ide-widget';
import { type WidgetUIState } from './widget';
import { type WidgetRegistry } from './registry';
export const ProjectIDEClientProps = Symbol('ProjectIDEClientProps');
export interface TitlePropsType {
commandRegistry: CommandRegistry;
registry: WidgetRegistry;
title: string;
widget?: ReactWidget;
uiState: WidgetUIState;
}
export type WidgetTitleRender = (
props: TitlePropsType,
) => ReactElement<any, any>;
export interface ProjectIDEClientProps {
view: {
/**
* 主编辑区域渲染内容
*/
widgetRegistries: WidgetRegistry[];
/**
* 默认渲染页
*/
widgetDefaultRender: () => ReactElement<any, any>;
/**
* widget 兜底报错,如果 widget 挂掉会渲染该组件,发送埋点
*/
widgetFallbackRender?: (props: {
widget: ReactWidget;
}) => ReactElement<any, any>;
/**
* 统一标题渲染
*/
widgetTitleRender: WidgetTitleRender;
/**
* 主侧边栏渲染
*/
primarySideBar: () => ReactElement<any, any>;
/**
* 辅助侧边栏渲染
*/
secondarySidebar?: () => ReactElement<any, any>;
/**
* 主侧边栏底部分区 configuration 配置渲染
*/
configuration?: () => ReactElement<any, any>;
/**
* 前置工具栏渲染
*/
preToolbar?: () => ReactElement<any, any>;
/**
* 后置工具栏渲染
*/
toolbar?: (widget: ProjectIDEWidget) => ReactElement<any, any>;
/**
* 顶部导航栏
*/
topBar: () => ReactElement<any, any>;
/**
* uibuilder
*/
uiBuilder: () => ReactElement<any, any> | null;
};
}

View File

@@ -0,0 +1,23 @@
/*
* 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 { type WidgetRegistry, RegistryHandler } from './registry';
export type { WidgetUIState } from './widget';
export { type ProjectIDEServices } from './services';
export { ProjectIDEClientProps } from './client';
export type { TitlePropsType } from './client';
export { type ResourceType, type ModeType } from './resource';
export { type WsMessageProps } from '@coze-project-ide/base-interface';

View File

@@ -0,0 +1,61 @@
/*
* 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 URI,
type ReactWidget,
type LayoutPanelType,
} from '@coze-project-ide/client';
import { type WidgetContext } from '@/context/widget-context';
import { type CommandItem, type MenuItem, type ShortcutItem } from './services';
export interface WidgetRegistry<T = any> {
// widget 渲染区域
area?: LayoutPanelType;
// 规则匹配
match: RegExp;
canClose?: () => boolean;
// 数据存储
createStore?: (uri?: URI) => T;
// 注册
registerCommands?: () => CommandItem<T>[];
registerShortcuts?: () => ShortcutItem[];
registerContextMenu?: () => MenuItem[];
renderStatusbar?: (ctx: WidgetContext<T>) => void;
renderIcon?: (ctx: WidgetContext<T>) => React.ReactElement<any, any>;
renderContent: (
ctx: WidgetContext<T>,
widget?: ReactWidget,
) => React.ReactElement<any, any>;
// 生命周期
load?: (ctx: WidgetContext<T>) => Promise<void>;
/**
* 注意:分屏场景,如果有一个面板之前未展示,会先 focus 那个面板,然后 focus 当前选中的面板。
*/
onFocus?: (ctx: WidgetContext<T>) => void;
/**
* 业务侧销毁逻辑
* createStore 的销毁逻辑由业务侧自行处理
*/
onDispose?: (ctx: WidgetContext<T>) => void;
}
export const RegistryHandler = Symbol('RegistryHandler');
export type RegistryHandler<T = any> = WidgetRegistry<T>;

View File

@@ -0,0 +1,24 @@
/*
* 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 type ResourceType = 'workflow' | 'knowledge' | 'ui-builder';
/**
* 模式类型
*/
export type ModeType = 'dev' | 'ui-builder';

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 { type ViewService } from '@/plugins/create-preset-plugin/view-service';
import { type WidgetContext } from '@/context/widget-context';
export interface CommandItem<T> {
id: string;
label: string;
when?: 'widgetFocus';
execute: (ctx?: WidgetContext, props?: T) => void;
isEnable: (ctx?: WidgetContext, props?: T) => boolean;
}
export interface ShortcutItem {
// 命令系统中绑定的 id
commandId: string;
// 快捷键
keybinding: string;
// 是否阻止浏览器原生行为
preventDefault: boolean;
}
export interface CommandService {
execute: (id: string, ...args: any[]) => void; // 执行命令
}
export interface MenuItem {
/**
* 使用已经注册的 command 的 id
*/
commandId: string;
/**
* 元素选择器
* 类:.class
* id#id
*/
selector: string;
/**
* 子菜单
*/
submenu?: MenuItem[];
}
export interface ContextMenuService {
open: (e: React.MouseEvent) => boolean; // 没有任何菜单注册项,返回 false
registerContextMenu: (options: MenuItem[], match?: RegExp) => void; // 入参形同 widgetRegistry 里的 registerContextMenu
}
export interface ProjectIDEServices {
contextmenu: ContextMenuService; // 右键菜单服务
command: CommandService; // 命令服务
view: ViewService;
}

View File

@@ -0,0 +1,17 @@
/*
* 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 type WidgetUIState = 'loading' | 'saving' | 'error' | 'normal';

View File

@@ -0,0 +1,43 @@
/*
* 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.
*/
/** 保留的查询参数 */
const PRESERVED_SEARCH_PARAMS = ['commit_version'];
/**
* 给指定 url 添加特定的 search params
* @param url 当前 url
* @returns
*/
export function addPreservedSearchParams(url: string) {
if (!url) {
return url;
}
const searchParams = new URLSearchParams(window.location.search);
const newSearchParams = new URLSearchParams();
for (const param of PRESERVED_SEARCH_PARAMS) {
const value = searchParams.get(param);
if (value && !url.includes(`${param}=`)) {
newSearchParams.append(param, value);
}
}
const separator = url.includes('?') ? '&' : '?';
const qs = newSearchParams.toString();
return qs ? `${url}${separator}${qs}` : url;
}

View File

@@ -0,0 +1,64 @@
/*
* 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 ApplicationShell,
createBoxLayout,
type BoxLayout,
createSplitLayout,
SplitPanel,
BoxPanel,
} from '@coze-project-ide/client';
export const customLayout = (
shell: ApplicationShell,
uiBuilderPanel: BoxPanel,
): BoxLayout => {
const bottomSplitLayout = createSplitLayout([shell.mainPanel], [1], {
orientation: 'vertical',
spacing: 0,
});
shell.bottomSplitLayout = bottomSplitLayout;
const middleContentPanel = new SplitPanel({ layout: bottomSplitLayout });
const leftRightSplitLayout = createBoxLayout(
[
// 左边的不可伸缩 bar
shell.primarySidebar,
middleContentPanel,
],
[0, 1],
{
direction: 'left-to-right',
spacing: 6,
},
);
const mainDockPanel = new BoxPanel({ layout: leftRightSplitLayout });
const centerLayout = createBoxLayout(
[mainDockPanel, uiBuilderPanel, shell.secondarySidebar],
[1, 0, 0],
{
direction: 'left-to-right',
},
);
const centerPanel = new BoxPanel({ layout: centerLayout });
return createBoxLayout([shell.topPanel, centerPanel], [0, 1], {
direction: 'top-to-bottom',
spacing: 0,
});
};

View File

@@ -0,0 +1,29 @@
/*
* 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 { customLayout } from './custom-layout';
export { withLazyLoad } from './with-lazy-load';
export {
getResourceByPathname,
getResourceByURI,
getURIByResource,
getURIPathByPathname,
getURIByPath,
getPathnameByURI,
compareURI,
getURLByURI,
} from './resource-tools';
export { addPreservedSearchParams } from './add-preserved-search-params';

View File

@@ -0,0 +1,96 @@
/*
* 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 { URI } from '@coze-project-ide/client';
import { URI_SCHEME } from '../constants';
/**
* 从给定的 url 字符串中解析出 resourceType 和 resourceId;
*/
export const getResourceByPathname = (pathname: string) => {
let resourceType: undefined | string;
let resourceId: undefined | string;
const regex = /space\/\d+\/project-ide\/\d+\/([^\/]+)(?:\/([^\/]+))?/;
const match = pathname.match(regex);
if (match) {
resourceType = match[1];
resourceId = match[2];
}
return {
resourceType,
resourceId,
};
};
export const getURIPathByPathname = (pathname: string) => {
const match = pathname.match(/space\/[^/]+\/project-ide\/[^/]+\/(.*)/);
return match ? match[1] : null;
};
/**
* 从 uri 上解析 resourceType 和 resourceId
*/
export const getResourceByURI = (uri: URI) => {
/**
* TODO: 这样解析有些粗暴了,后面要调整一下
*/
const resourceType = uri.path.dir.base;
const resourceId = uri.path.base;
return {
resourceType,
resourceId,
};
};
export const getPathnameByURI = (uri: URI) => uri.path.toString();
/**
* 根据 resourceType 和 resourceId 生成 URI
*/
export const getURIByResource = (
resourceType: string,
resourceId: string,
query?: string,
) =>
new URI(
`${URI_SCHEME}:///${resourceType}/${resourceId}${query ? `?${query}` : ''}`,
);
export const getURIByPath = (path: string) =>
new URI(`${URI_SCHEME}:///${path}`);
/**
* 将 uri 转化为 url
*/
export const getURLByURI = (uri: URI) =>
`${uri.path.toString()}${uri.query ? `${uri.query}` : ''}${
uri.fragment ? `#${uri.fragment}` : ''
}`;
/**
* 执行 URI 比对,完全一致返回 true否则返回 false
*/
export const compareURI = (uri1?: URI, uri2?: URI) => {
if (!uri1 || !uri2) {
return false;
}
return uri1.toString() === uri2.toString();
};

View File

@@ -0,0 +1,32 @@
/*
* 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, { Suspense, lazy } from 'react';
const withLazyLoad = (
importFunc: () => Promise<{ default: React.ComponentType<any> }>,
fallback?: React.ReactNode,
) => {
const Component = lazy(importFunc);
const LazyComponent = () => (
<Suspense fallback={fallback}>
<Component />
</Suspense>
);
return LazyComponent;
};
export { withLazyLoad };

View File

@@ -0,0 +1,36 @@
/*
* 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 } from 'inversify';
import { ProjectIDEClientProps } from '@/types';
import { ProjectIDEWidget } from '../project-ide-widget';
@injectable()
export class ConfigWidget extends ProjectIDEWidget {
@inject(ProjectIDEClientProps) props: ProjectIDEClientProps;
render() {
const Component = this.props.view.configuration;
if (Component) {
return <Component />;
}
return null;
}
}

View File

@@ -0,0 +1,53 @@
/*
* 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 { injectable, type interfaces } from 'inversify';
import { SplitWidget, type URI } from '@coze-project-ide/client';
import { type WidgetContext } from '@/context';
import { SIDEBAR_RESOURCE_URI, SIDEBAR_CONFIG_URI } from '../../constants/uri';
import { ResourceWidget } from './resource-widget';
import { ConfigWidget } from './config-widget';
@injectable()
export class PrimarySidebarWidget extends SplitWidget {
context: WidgetContext;
container: interfaces.Container;
render(): any {
return null;
}
init(uri: URI) {
this.orientation = 'vertical';
this.defaultStretch = [0.7, 0.3];
this.splitPanels = [
{
widgetUri: SIDEBAR_RESOURCE_URI,
widget: ResourceWidget,
order: 1,
},
{
widgetUri: SIDEBAR_CONFIG_URI,
widget: ConfigWidget,
order: 2,
},
];
super.init(uri);
}
}

View File

@@ -0,0 +1,36 @@
/*
* 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 } from 'inversify';
import { ProjectIDEClientProps } from '@/types';
import { ProjectIDEWidget } from '../project-ide-widget';
@injectable()
export class ResourceWidget extends ProjectIDEWidget {
@inject(ProjectIDEClientProps) props: ProjectIDEClientProps;
render(): any {
const Component = this.props.view.primarySideBar;
if (Component) {
return <Component />;
}
return null;
}
}

View File

@@ -0,0 +1,45 @@
/*
* 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 interfaces } from 'inversify';
import { Emitter, ReactWidget } from '@coze-project-ide/client';
import { type WidgetContext } from '@/context/widget-context';
export class ProjectIDEWidget extends ReactWidget {
context: WidgetContext;
container: interfaces.Container;
private onRefreshEmitter = new Emitter<void>();
onRefresh = this.onRefreshEmitter.event;
refresh() {
this.onRefreshEmitter.fire();
}
constructor(props) {
super(props);
this.scrollOptions = {
minScrollbarLength: 35,
};
}
render(): any {
return null;
}
}