feat(skill): implement VS Code-style preview/pinned tab management

- Single-click file in tree opens in preview mode (temporary, replaceable)
- Double-click file opens in pinned mode (permanent)
- Preview tabs display with italic filename
- Editing content auto-converts preview tab to pinned
- Double-clicking preview tab header converts to pinned
- Only one preview tab can exist at a time
This commit is contained in:
yyh
2026-01-16 10:51:32 +08:00
parent 3dea5adf5c
commit a30fb5909b
6 changed files with 85 additions and 23 deletions

View File

@ -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<EditorTabItemProps> = ({
@ -24,8 +26,10 @@ const EditorTabItem: FC<EditorTabItemProps> = ({
name,
isActive,
isDirty,
isPreview,
onClick,
onClose,
onDoubleClick,
}) => {
const { t } = useTranslation()
const iconType = getFileIconType(name)
@ -34,6 +38,11 @@ const EditorTabItem: FC<EditorTabItemProps> = ({
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<EditorTabItemProps> = ({
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-components-input-border-active',
)}
onClick={handleClick}
onDoubleClick={handleDoubleClick}
>
<div className="relative flex size-5 shrink-0 items-center justify-center">
<FileTypeIcon type={iconType as FileAppearanceType} size="sm" />
@ -64,6 +74,7 @@ const EditorTabItem: FC<EditorTabItemProps> = ({
<span
className={cn(
'max-w-40 truncate text-[13px] font-normal leading-4',
isPreview && 'italic',
isActive
? 'text-text-primary'
: 'text-text-tertiary',

View File

@ -10,6 +10,7 @@ import { useSkillEditorStore, useSkillEditorStoreApi } from './store'
const EditorTabs: 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 (
<EditorTabItem
@ -45,8 +51,10 @@ const EditorTabs: FC = () => {
name={name}
isActive={isActive}
isDirty={isDirty}
isPreview={isPreview}
onClick={handleTabClick}
onClose={handleTabClose}
onDoubleClick={handleTabDoubleClick}
/>
)
})}

View File

@ -67,7 +67,7 @@ const FileTree: React.FC<FileTreeProps> = ({ className }) => {
const handleActivate = useCallback((node: NodeApi<TreeNodeData>) => {
if (node.data.node_type === 'file')
storeApi.getState().openTab(node.data.id)
storeApi.getState().openTab(node.data.id, { pinned: true })
else
node.toggle()
}, [storeApi])

View File

@ -36,13 +36,19 @@ const TreeNode = ({ node, style, dragHandle }: NodeRendererProps<TreeNodeData>)
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<TreeNodeData>)
if (isFolder)
node.toggle()
else
node.activate()
storeApi.getState().openTab(node.data.id, { pinned: true })
}
}, [isFolder, node])
}, [isFolder, node, storeApi])
return (
<div

View File

@ -69,6 +69,7 @@ const SkillDocEditor: FC = () => {
if (!activeTabId || !isEditable)
return
storeApi.getState().setDraftContent(activeTabId, value ?? '')
storeApi.getState().pinTab(activeTabId)
}, [activeTabId, isEditable, storeApi])
const handleSave = useCallback(async () => {

View File

@ -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<TabSliceShape> = (set, get) => ({
@ -20,41 +33,54 @@ export const createTabSlice: StateCreator<TabSliceShape> = (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<TabSliceShape> = (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<string, boolean>