diff --git a/web/app/components/workflow/skill/editor-tab-item.tsx b/web/app/components/workflow/skill/editor-tab-item.tsx index 1f6815ee94..9356ecb9e4 100644 --- a/web/app/components/workflow/skill/editor-tab-item.tsx +++ b/web/app/components/workflow/skill/editor-tab-item.tsx @@ -15,8 +15,10 @@ type EditorTabItemProps = { name: string isActive: boolean isDirty: boolean + isPreview: boolean onClick: (fileId: string) => void onClose: (fileId: string) => void + onDoubleClick: (fileId: string) => void } const EditorTabItem: FC = ({ @@ -24,8 +26,10 @@ const EditorTabItem: FC = ({ name, isActive, isDirty, + isPreview, onClick, onClose, + onDoubleClick, }) => { const { t } = useTranslation() const iconType = getFileIconType(name) @@ -34,6 +38,11 @@ const EditorTabItem: FC = ({ onClick(fileId) }, [onClick, fileId]) + const handleDoubleClick = useCallback(() => { + if (isPreview) + onDoubleClick(fileId) + }, [onDoubleClick, fileId, isPreview]) + const handleClose = useCallback((e: React.MouseEvent) => { e.stopPropagation() onClose(fileId) @@ -53,6 +62,7 @@ const EditorTabItem: FC = ({ 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-components-input-border-active', )} onClick={handleClick} + onDoubleClick={handleDoubleClick} >
@@ -64,6 +74,7 @@ const EditorTabItem: FC = ({ { const openTabIds = useSkillEditorStore(s => s.openTabIds) const activeTabId = useSkillEditorStore(s => s.activeTabId) + const previewTabId = useSkillEditorStore(s => s.previewTabId) const dirtyContents = useSkillEditorStore(s => s.dirtyContents) const storeApi = useSkillEditorStoreApi() const { data: nodeMap } = useSkillAssetNodeMap() @@ -18,6 +19,10 @@ const EditorTabs: FC = () => { storeApi.getState().activateTab(fileId) } + const handleTabDoubleClick = (fileId: string) => { + storeApi.getState().pinTab(fileId) + } + const handleTabClose = (fileId: string) => { storeApi.getState().closeTab(fileId) storeApi.getState().clearDraftContent(fileId) @@ -37,6 +42,7 @@ const EditorTabs: FC = () => { const name = node?.name ?? fileId const isActive = activeTabId === fileId const isDirty = dirtyContents.has(fileId) + const isPreview = previewTabId === fileId return ( { name={name} isActive={isActive} isDirty={isDirty} + isPreview={isPreview} onClick={handleTabClick} onClose={handleTabClose} + onDoubleClick={handleTabDoubleClick} /> ) })} diff --git a/web/app/components/workflow/skill/file-tree/index.tsx b/web/app/components/workflow/skill/file-tree/index.tsx index 7c825d1fd0..df2d280fc9 100644 --- a/web/app/components/workflow/skill/file-tree/index.tsx +++ b/web/app/components/workflow/skill/file-tree/index.tsx @@ -67,7 +67,7 @@ const FileTree: React.FC = ({ className }) => { const handleActivate = useCallback((node: NodeApi) => { if (node.data.node_type === 'file') - storeApi.getState().openTab(node.data.id) + storeApi.getState().openTab(node.data.id, { pinned: true }) else node.toggle() }, [storeApi]) diff --git a/web/app/components/workflow/skill/file-tree/tree-node.tsx b/web/app/components/workflow/skill/file-tree/tree-node.tsx index ddd52614bf..c24e12a03a 100644 --- a/web/app/components/workflow/skill/file-tree/tree-node.tsx +++ b/web/app/components/workflow/skill/file-tree/tree-node.tsx @@ -36,13 +36,19 @@ const TreeNode = ({ node, style, dragHandle }: NodeRendererProps) const handleClick = (e: React.MouseEvent) => { e.stopPropagation() - node.handleClick(e) + node.select() + if (isFolder) + node.toggle() + else + storeApi.getState().openTab(node.data.id, { pinned: false }) } const handleDoubleClick = (e: React.MouseEvent) => { e.stopPropagation() - if (!isFolder) - node.activate() + if (isFolder) + node.toggle() + else + storeApi.getState().openTab(node.data.id, { pinned: true }) } const handleToggle = (e: React.MouseEvent) => { @@ -72,9 +78,9 @@ const TreeNode = ({ node, style, dragHandle }: NodeRendererProps) if (isFolder) node.toggle() else - node.activate() + storeApi.getState().openTab(node.data.id, { pinned: true }) } - }, [isFolder, node]) + }, [isFolder, node, storeApi]) return (
{ if (!activeTabId || !isEditable) return storeApi.getState().setDraftContent(activeTabId, value ?? '') + storeApi.getState().pinTab(activeTabId) }, [activeTabId, isEditable, storeApi]) const handleSave = useCallback(async () => { diff --git a/web/app/components/workflow/skill/store/index.ts b/web/app/components/workflow/skill/store/index.ts index 50d52d8ffd..d3a555bf86 100644 --- a/web/app/components/workflow/skill/store/index.ts +++ b/web/app/components/workflow/skill/store/index.ts @@ -4,15 +4,28 @@ import { useContext } from 'react' import { useStore as useZustandStore } from 'zustand' import { createStore } from 'zustand/vanilla' +export type OpenTabOptions = { + /** true = Pinned (permanent), false/undefined = Preview (temporary) */ + pinned?: boolean +} + export type TabSliceShape = { /** Ordered list of open tab file IDs */ openTabIds: string[] /** Currently active tab file ID */ activeTabId: string | null + /** Current preview tab file ID (at most one) */ previewTabId: string | null - openTab: (fileId: string) => void + /** Open a file tab with optional pinned mode */ + openTab: (fileId: string, options?: OpenTabOptions) => void + /** Close a tab */ closeTab: (fileId: string) => void + /** Activate an existing tab */ activateTab: (fileId: string) => void + /** Convert preview tab to pinned tab */ + pinTab: (fileId: string) => void + /** Check if a tab is in preview mode */ + isPreviewTab: (fileId: string) => boolean } export const createTabSlice: StateCreator = (set, get) => ({ @@ -20,41 +33,54 @@ export const createTabSlice: StateCreator = (set, get) => ({ activeTabId: null, previewTabId: null, - openTab: (fileId: string) => { - const { openTabIds, activeTabId } = get() - // If already open, just activate + openTab: (fileId: string, options?: OpenTabOptions) => { + const { openTabIds, activeTabId, previewTabId } = get() + const isPinned = options?.pinned ?? false + if (openTabIds.includes(fileId)) { - if (activeTabId !== fileId) + if (isPinned && previewTabId === fileId) + set({ activeTabId: fileId, previewTabId: null }) + else if (activeTabId !== fileId) set({ activeTabId: fileId }) return } - // Add to tabs and activate - set({ - openTabIds: [...openTabIds, fileId], - activeTabId: fileId, - }) + + let newOpenTabIds = [...openTabIds] + + if (!isPinned) { + if (previewTabId && openTabIds.includes(previewTabId)) + newOpenTabIds = newOpenTabIds.filter(id => id !== previewTabId) + set({ + openTabIds: [...newOpenTabIds, fileId], + activeTabId: fileId, + previewTabId: fileId, + }) + } + else { + set({ + openTabIds: [...newOpenTabIds, fileId], + activeTabId: fileId, + }) + } }, closeTab: (fileId: string) => { - const { openTabIds, activeTabId } = get() + const { openTabIds, activeTabId, previewTabId } = get() const newOpenTabIds = openTabIds.filter(id => id !== fileId) - // If closing the active tab, activate adjacent tab let newActiveTabId = activeTabId if (activeTabId === fileId) { const closedIndex = openTabIds.indexOf(fileId) - if (newOpenTabIds.length > 0) { - // Prefer next, fallback to previous + if (newOpenTabIds.length > 0) newActiveTabId = newOpenTabIds[Math.min(closedIndex, newOpenTabIds.length - 1)] - } - else { + else newActiveTabId = null - } } set({ openTabIds: newOpenTabIds, activeTabId: newActiveTabId, + previewTabId: previewTabId === fileId ? null : previewTabId, }) }, @@ -63,6 +89,16 @@ export const createTabSlice: StateCreator = (set, get) => ({ if (openTabIds.includes(fileId)) set({ activeTabId: fileId }) }, + + pinTab: (fileId: string) => { + const { previewTabId } = get() + if (previewTabId === fileId) + set({ previewTabId: null }) + }, + + isPreviewTab: (fileId: string) => { + return get().previewTabId === fileId + }, }) export type OpensObject = Record