mirror of
https://github.com/langgenius/dify.git
synced 2026-04-27 05:58:14 +08:00
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:
@ -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',
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
||||
@ -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])
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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>
|
||||
|
||||
Reference in New Issue
Block a user