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 type { FC } from 'react'
import * as React 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 { cn } from '@/utils/classnames'
import EditorTabItem from './editor-tab-item' import EditorTabItem from './editor-tab-item'
import { useSkillAssetNodeMap } from './hooks/use-skill-asset-tree' import { useSkillAssetNodeMap } from './hooks/use-skill-asset-tree'
import { useSkillEditorStore, useSkillEditorStoreApi } from './store' import { useSkillEditorStore, useSkillEditorStoreApi } from './store'
const EditorTabs: FC = () => { const EditorTabs: FC = () => {
const { t } = useTranslation('workflow')
const openTabIds = useSkillEditorStore(s => s.openTabIds) const openTabIds = useSkillEditorStore(s => s.openTabIds)
const activeTabId = useSkillEditorStore(s => s.activeTabId) const activeTabId = useSkillEditorStore(s => s.activeTabId)
const previewTabId = useSkillEditorStore(s => s.previewTabId) const previewTabId = useSkillEditorStore(s => s.previewTabId)
@ -15,50 +19,82 @@ const EditorTabs: FC = () => {
const storeApi = useSkillEditorStoreApi() const storeApi = useSkillEditorStoreApi()
const { data: nodeMap } = useSkillAssetNodeMap() 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.getState().activateTab(fileId)
} }, [storeApi])
const handleTabDoubleClick = (fileId: string) => { const handleTabDoubleClick = useCallback((fileId: string) => {
storeApi.getState().pinTab(fileId) storeApi.getState().pinTab(fileId)
} }, [storeApi])
const handleTabClose = (fileId: string) => { const closeTab = useCallback((fileId: string) => {
storeApi.getState().closeTab(fileId) storeApi.getState().closeTab(fileId)
storeApi.getState().clearDraftContent(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) if (openTabIds.length === 0)
return null return null
return ( return (
<div <>
className={cn( <div
'flex items-center overflow-hidden rounded-t-lg border-b border-components-panel-border-subtle bg-components-panel-bg-alt', 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) {openTabIds.map((fileId) => {
const name = node?.name ?? fileId const node = nodeMap?.get(fileId)
const isActive = activeTabId === fileId const name = node?.name ?? fileId
const isDirty = dirtyContents.has(fileId) const isActive = activeTabId === fileId
const isPreview = previewTabId === fileId const isDirty = dirtyContents.has(fileId)
const isPreview = previewTabId === fileId
return ( return (
<EditorTabItem <EditorTabItem
key={fileId} key={fileId}
fileId={fileId} fileId={fileId}
name={name} name={name}
isActive={isActive} isActive={isActive}
isDirty={isDirty} isDirty={isDirty}
isPreview={isPreview} isPreview={isPreview}
onClick={handleTabClick} onClick={handleTabClick}
onClose={handleTabClose} onClose={handleTabClose}
onDoubleClick={handleTabDoubleClick} onDoubleClick={handleTabDoubleClick}
/> />
) )
})} })}
</div> </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 Confirm from '@/app/components/base/confirm'
import { cn } from '@/utils/classnames' import { cn } from '@/utils/classnames'
import { useFileOperations } from '../hooks/use-file-operations' import { useFileOperations } from '../hooks/use-file-operations'
import MenuItem from './menu-item'
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>
)
type FileItemMenuProps = { type FileItemMenuProps = {
nodeId: string nodeId?: string
onClose: () => void onClose: () => void
className?: string className?: string
treeRef?: React.RefObject<TreeApi<TreeNodeData> | null> 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 Confirm from '@/app/components/base/confirm'
import { cn } from '@/utils/classnames' import { cn } from '@/utils/classnames'
import { useFileOperations } from '../hooks/use-file-operations' import { useFileOperations } from '../hooks/use-file-operations'
import MenuItem from './menu-item'
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>
)
type FileOperationsMenuProps = { type FileOperationsMenuProps = {
nodeId: string nodeId?: string
onClose: () => void onClose: () => void
className?: string className?: string
treeRef?: React.RefObject<TreeApi<TreeNodeData> | null> 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, PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem' } from '@/app/components/base/portal-to-follow-elem'
import { cn } from '@/utils/classnames' import { cn } from '@/utils/classnames'
import { useDelayedClick } from '../hooks/use-delayed-click'
import { useSkillEditorStore, useSkillEditorStoreApi } from '../store' import { useSkillEditorStore, useSkillEditorStoreApi } from '../store'
import { getFileIconType } from '../utils/file-utils' import { getFileIconType } from '../utils/file-utils'
import FileNodeMenu from './file-node-menu' import FileNodeMenu from './file-node-menu'
@ -40,13 +41,26 @@ const TreeNode = ({ node, style, dragHandle }: NodeRendererProps<TreeNodeData>)
[node], [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) => { const handleClick = (e: React.MouseEvent) => {
e.stopPropagation() e.stopPropagation()
node.select() node.select()
if (isFolder) if (isFolder)
throttledToggle() throttledToggle()
else else
storeApi.getState().openTab(node.data.id, { pinned: false }) handleFileClick()
} }
const handleDoubleClick = (e: React.MouseEvent) => { const handleDoubleClick = (e: React.MouseEvent) => {
@ -54,7 +68,7 @@ const TreeNode = ({ node, style, dragHandle }: NodeRendererProps<TreeNodeData>)
if (isFolder) if (isFolder)
throttledToggle() throttledToggle()
else else
storeApi.getState().openTab(node.data.id, { pinned: true }) handleFileDoubleClick()
} }
const handleToggle = (e: React.MouseEvent) => { const handleToggle = (e: React.MouseEvent) => {
@ -181,14 +195,12 @@ const TreeNode = ({ node, style, dragHandle }: NodeRendererProps<TreeNodeData>)
{isFolder {isFolder
? ( ? (
<FolderNodeMenu <FolderNodeMenu
nodeId={node.data.id}
onClose={() => setShowDropdown(false)} onClose={() => setShowDropdown(false)}
node={node} node={node}
/> />
) )
: ( : (
<FileNodeMenu <FileNodeMenu
nodeId={node.data.id}
onClose={() => setShowDropdown(false)} onClose={() => setShowDropdown(false)}
node={node} 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' import { useSkillAssetTreeData } from './use-skill-asset-tree'
type UseFileOperationsOptions = { type UseFileOperationsOptions = {
nodeId: string nodeId?: string
onClose: () => void onClose: () => void
treeRef?: React.RefObject<TreeApi<TreeNodeData> | null> treeRef?: React.RefObject<TreeApi<TreeNodeData> | null>
node?: NodeApi<TreeNodeData> node?: NodeApi<TreeNodeData>
} }
export function useFileOperations({ export function useFileOperations({
nodeId, nodeId: explicitNodeId,
onClose, onClose,
treeRef, treeRef,
node, node,
}: UseFileOperationsOptions) { }: UseFileOperationsOptions) {
const nodeId = node?.data.id ?? explicitNodeId ?? ''
const { t } = useTranslation('workflow') const { t } = useTranslation('workflow')
const fileInputRef = useRef<HTMLInputElement>(null) const fileInputRef = useRef<HTMLInputElement>(null)
const folderInputRef = useRef<HTMLInputElement>(null) const folderInputRef = useRef<HTMLInputElement>(null)

View File

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

View File

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

View File

@ -1031,6 +1031,9 @@
"skillSidebar.newFolder": "New folder", "skillSidebar.newFolder": "New folder",
"skillSidebar.searchPlaceholder": "Search files…", "skillSidebar.searchPlaceholder": "Search files…",
"skillSidebar.toggleFolder": "Toggle folder", "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…", "skillSidebar.uploading": "Uploading…",
"tabs.-": "Default", "tabs.-": "Default",
"tabs.addAll": "Add all", "tabs.addAll": "Add all",

View File

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