fix(skill): address code review issues for tab management

1. Add confirmation dialog when closing dirty tabs
2. Fix file double-click race condition with useDelayedClick hook
3. Fix previewTabId orphan state in closeTab
4. Remove auto-pin on every keystroke (VS Code behavior)
5. Extract shared MenuItem component to eliminate duplication
6. Make nodeId optional when node is provided (reduce props drilling)
This commit is contained in:
yyh
2026-01-16 11:17:19 +08:00
parent 17990512ce
commit f1ce933b33
11 changed files with 174 additions and 92 deletions

View File

@@ -2,12 +2,16 @@
import type { FC } from 'react'
import * as React from 'react'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Confirm from '@/app/components/base/confirm'
import { cn } from '@/utils/classnames'
import EditorTabItem from './editor-tab-item'
import { useSkillAssetNodeMap } from './hooks/use-skill-asset-tree'
import { useSkillEditorStore, useSkillEditorStoreApi } from './store'
const EditorTabs: FC = () => {
const { t } = useTranslation('workflow')
const openTabIds = useSkillEditorStore(s => s.openTabIds)
const activeTabId = useSkillEditorStore(s => s.activeTabId)
const previewTabId = useSkillEditorStore(s => s.previewTabId)
@@ -15,50 +19,82 @@ const EditorTabs: FC = () => {
const storeApi = useSkillEditorStoreApi()
const { data: nodeMap } = useSkillAssetNodeMap()
const handleTabClick = (fileId: string) => {
const [pendingCloseId, setPendingCloseId] = useState<string | null>(null)
const handleTabClick = useCallback((fileId: string) => {
storeApi.getState().activateTab(fileId)
}
}, [storeApi])
const handleTabDoubleClick = (fileId: string) => {
const handleTabDoubleClick = useCallback((fileId: string) => {
storeApi.getState().pinTab(fileId)
}
}, [storeApi])
const handleTabClose = (fileId: string) => {
const closeTab = useCallback((fileId: string) => {
storeApi.getState().closeTab(fileId)
storeApi.getState().clearDraftContent(fileId)
}
}, [storeApi])
const handleTabClose = useCallback((fileId: string) => {
if (dirtyContents.has(fileId)) {
setPendingCloseId(fileId)
return
}
closeTab(fileId)
}, [dirtyContents, closeTab])
const handleConfirmClose = useCallback(() => {
if (pendingCloseId) {
closeTab(pendingCloseId)
setPendingCloseId(null)
}
}, [pendingCloseId, closeTab])
const handleCancelClose = useCallback(() => {
setPendingCloseId(null)
}, [])
if (openTabIds.length === 0)
return null
return (
<div
className={cn(
'flex items-center overflow-hidden rounded-t-lg border-b border-components-panel-border-subtle bg-components-panel-bg-alt',
)}
>
{openTabIds.map((fileId) => {
const node = nodeMap?.get(fileId)
const name = node?.name ?? fileId
const isActive = activeTabId === fileId
const isDirty = dirtyContents.has(fileId)
const isPreview = previewTabId === fileId
<>
<div
className={cn(
'flex items-center overflow-hidden rounded-t-lg border-b border-components-panel-border-subtle bg-components-panel-bg-alt',
)}
>
{openTabIds.map((fileId) => {
const node = nodeMap?.get(fileId)
const name = node?.name ?? fileId
const isActive = activeTabId === fileId
const isDirty = dirtyContents.has(fileId)
const isPreview = previewTabId === fileId
return (
<EditorTabItem
key={fileId}
fileId={fileId}
name={name}
isActive={isActive}
isDirty={isDirty}
isPreview={isPreview}
onClick={handleTabClick}
onClose={handleTabClose}
onDoubleClick={handleTabDoubleClick}
/>
)
})}
</div>
return (
<EditorTabItem
key={fileId}
fileId={fileId}
name={name}
isActive={isActive}
isDirty={isDirty}
isPreview={isPreview}
onClick={handleTabClick}
onClose={handleTabClose}
onDoubleClick={handleTabDoubleClick}
/>
)
})}
</div>
<Confirm
isShow={pendingCloseId !== null}
type="warning"
title={t('skillSidebar.unsavedChanges.title')}
content={t('skillSidebar.unsavedChanges.content')}
confirmText={t('skillSidebar.unsavedChanges.confirmClose')}
onConfirm={handleConfirmClose}
onCancel={handleCancelClose}
/>
</>
)
}

View File

@@ -12,34 +12,10 @@ import { useTranslation } from 'react-i18next'
import Confirm from '@/app/components/base/confirm'
import { cn } from '@/utils/classnames'
import { useFileOperations } from '../hooks/use-file-operations'
type MenuItemProps = {
icon: React.ElementType
label: string
onClick: () => void
disabled?: boolean
}
const MenuItem: React.FC<MenuItemProps> = ({ icon: Icon, label, onClick, disabled }) => (
<button
type="button"
onClick={onClick}
disabled={disabled}
className={cn(
'flex w-full items-center gap-2 rounded-lg px-3 py-2',
'hover:bg-state-base-hover disabled:cursor-not-allowed disabled:opacity-50',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-components-input-border-active',
)}
>
<Icon className="size-4 text-text-tertiary" aria-hidden="true" />
<span className="system-sm-regular text-text-secondary">
{label}
</span>
</button>
)
import MenuItem from './menu-item'
type FileItemMenuProps = {
nodeId: string
nodeId?: string
onClose: () => void
className?: string
treeRef?: React.RefObject<TreeApi<TreeNodeData> | null>

View File

@@ -16,34 +16,10 @@ import { useTranslation } from 'react-i18next'
import Confirm from '@/app/components/base/confirm'
import { cn } from '@/utils/classnames'
import { useFileOperations } from '../hooks/use-file-operations'
type MenuItemProps = {
icon: React.ElementType
label: string
onClick: () => void
disabled?: boolean
}
const MenuItem: React.FC<MenuItemProps> = ({ icon: Icon, label, onClick, disabled }) => (
<button
type="button"
onClick={onClick}
disabled={disabled}
className={cn(
'flex w-full items-center gap-2 rounded-lg px-3 py-2',
'hover:bg-state-base-hover disabled:cursor-not-allowed disabled:opacity-50',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-components-input-border-active',
)}
>
<Icon className="size-4 text-text-tertiary" aria-hidden="true" />
<span className="system-sm-regular text-text-secondary">
{label}
</span>
</button>
)
import MenuItem from './menu-item'
type FileOperationsMenuProps = {
nodeId: string
nodeId?: string
onClose: () => void
className?: string
treeRef?: React.RefObject<TreeApi<TreeNodeData> | null>

View File

@@ -0,0 +1,32 @@
'use client'
import type { FC } from 'react'
import * as React from 'react'
import { cn } from '@/utils/classnames'
export type MenuItemProps = {
icon: React.ElementType
label: string
onClick: () => void
disabled?: boolean
}
const MenuItem: FC<MenuItemProps> = ({ icon: Icon, label, onClick, disabled }) => (
<button
type="button"
onClick={onClick}
disabled={disabled}
className={cn(
'flex w-full items-center gap-2 rounded-lg px-3 py-2',
'hover:bg-state-base-hover disabled:cursor-not-allowed disabled:opacity-50',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-components-input-border-active',
)}
>
<Icon className="size-4 text-text-tertiary" aria-hidden="true" />
<span className="system-sm-regular text-text-secondary">
{label}
</span>
</button>
)
export default React.memo(MenuItem)

View File

@@ -15,6 +15,7 @@ import {
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import { cn } from '@/utils/classnames'
import { useDelayedClick } from '../hooks/use-delayed-click'
import { useSkillEditorStore, useSkillEditorStoreApi } from '../store'
import { getFileIconType } from '../utils/file-utils'
import FileNodeMenu from './file-node-menu'
@@ -40,13 +41,26 @@ const TreeNode = ({ node, style, dragHandle }: NodeRendererProps<TreeNodeData>)
[node],
)
const openFilePreview = useCallback(() => {
storeApi.getState().openTab(node.data.id, { pinned: false })
}, [node.data.id, storeApi])
const openFilePinned = useCallback(() => {
storeApi.getState().openTab(node.data.id, { pinned: true })
}, [node.data.id, storeApi])
const { handleClick: handleFileClick, handleDoubleClick: handleFileDoubleClick } = useDelayedClick({
onSingleClick: openFilePreview,
onDoubleClick: openFilePinned,
})
const handleClick = (e: React.MouseEvent) => {
e.stopPropagation()
node.select()
if (isFolder)
throttledToggle()
else
storeApi.getState().openTab(node.data.id, { pinned: false })
handleFileClick()
}
const handleDoubleClick = (e: React.MouseEvent) => {
@@ -54,7 +68,7 @@ const TreeNode = ({ node, style, dragHandle }: NodeRendererProps<TreeNodeData>)
if (isFolder)
throttledToggle()
else
storeApi.getState().openTab(node.data.id, { pinned: true })
handleFileDoubleClick()
}
const handleToggle = (e: React.MouseEvent) => {
@@ -181,14 +195,12 @@ const TreeNode = ({ node, style, dragHandle }: NodeRendererProps<TreeNodeData>)
{isFolder
? (
<FolderNodeMenu
nodeId={node.data.id}
onClose={() => setShowDropdown(false)}
node={node}
/>
)
: (
<FileNodeMenu
nodeId={node.data.id}
onClose={() => setShowDropdown(false)}
node={node}
/>

View File

@@ -0,0 +1,40 @@
import { useCallback, useRef } from 'react'
type UseDelayedClickOptions = {
delay?: number
onSingleClick: () => void
onDoubleClick: () => void
}
/**
* Hook to distinguish between single-click and double-click events.
* Single-click is delayed to allow double-click detection.
* Double-click cancels any pending single-click.
*/
export function useDelayedClick({
delay = 200,
onSingleClick,
onDoubleClick,
}: UseDelayedClickOptions) {
const timeoutRef = useRef<NodeJS.Timeout | null>(null)
const handleClick = useCallback(() => {
if (timeoutRef.current)
clearTimeout(timeoutRef.current)
timeoutRef.current = setTimeout(() => {
onSingleClick()
timeoutRef.current = null
}, delay)
}, [delay, onSingleClick])
const handleDoubleClick = useCallback(() => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
timeoutRef.current = null
}
onDoubleClick()
}, [onDoubleClick])
return { handleClick, handleDoubleClick }
}

View File

@@ -16,18 +16,19 @@ import { getAllDescendantFileIds } from '../utils/tree-utils'
import { useSkillAssetTreeData } from './use-skill-asset-tree'
type UseFileOperationsOptions = {
nodeId: string
nodeId?: string
onClose: () => void
treeRef?: React.RefObject<TreeApi<TreeNodeData> | null>
node?: NodeApi<TreeNodeData>
}
export function useFileOperations({
nodeId,
nodeId: explicitNodeId,
onClose,
treeRef,
node,
}: UseFileOperationsOptions) {
const nodeId = node?.data.id ?? explicitNodeId ?? ''
const { t } = useTranslation('workflow')
const fileInputRef = useRef<HTMLInputElement>(null)
const folderInputRef = useRef<HTMLInputElement>(null)

View File

@@ -69,7 +69,6 @@ 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

@@ -77,10 +77,14 @@ export const createTabSlice: StateCreator<TabSliceShape> = (set, get) => ({
newActiveTabId = null
}
const newPreviewTabId = previewTabId === fileId
? null
: (previewTabId && newOpenTabIds.includes(previewTabId) ? previewTabId : null)
set({
openTabIds: newOpenTabIds,
activeTabId: newActiveTabId,
previewTabId: previewTabId === fileId ? null : previewTabId,
previewTabId: newPreviewTabId,
})
},

View File

@@ -1031,6 +1031,9 @@
"skillSidebar.newFolder": "New folder",
"skillSidebar.searchPlaceholder": "Search files…",
"skillSidebar.toggleFolder": "Toggle folder",
"skillSidebar.unsavedChanges.confirmClose": "Discard",
"skillSidebar.unsavedChanges.content": "You have unsaved changes. Do you want to discard them?",
"skillSidebar.unsavedChanges.title": "Unsaved changes",
"skillSidebar.uploading": "Uploading…",
"tabs.-": "Default",
"tabs.addAll": "Add all",

View File

@@ -1023,6 +1023,9 @@
"skillSidebar.menu.uploadFolder": "上传文件夹",
"skillSidebar.newFolder": "新建文件夹",
"skillSidebar.searchPlaceholder": "搜索文件...",
"skillSidebar.unsavedChanges.confirmClose": "放弃",
"skillSidebar.unsavedChanges.content": "您有未保存的更改,是否放弃?",
"skillSidebar.unsavedChanges.title": "未保存的更改",
"skillSidebar.uploading": "上传中...",
"tabs.-": "默认",
"tabs.addAll": "添加全部",