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