feat: add tab drag-to-reorder and complete context menu actions

- Tab drag-and-drop reordering with visual drop indicator
- Add moveTab action to tabStore with localStorage persistence
- Context menu: add "Close Left" and "Close All" options
- Add i18n translations for new menu items (en/zh)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
程序员阿江(Relakkes) 2026-04-09 10:45:28 +08:00
parent 5c4a1e8994
commit 2c19bb77ac
4 changed files with 98 additions and 4 deletions

View File

@ -12,11 +12,15 @@ export function TabBar() {
const closeTab = useTabStore((s) => s.closeTab)
const disconnectSession = useChatStore((s) => s.disconnectSession)
const moveTab = useTabStore((s) => s.moveTab)
const scrollRef = useRef<HTMLDivElement>(null)
const [canScrollLeft, setCanScrollLeft] = useState(false)
const [canScrollRight, setCanScrollRight] = useState(false)
const [contextMenu, setContextMenu] = useState<{ sessionId: string; x: number; y: number } | null>(null)
const [closingTabId, setClosingTabId] = useState<string | null>(null)
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null)
const dragIndexRef = useRef<number | null>(null)
const t = useTranslation()
const updateScrollState = useCallback(() => {
@ -86,6 +90,16 @@ export function TabBar() {
}
}
const handleCloseLeft = (sessionId: string) => {
setContextMenu(null)
const idx = tabs.findIndex((t) => t.sessionId === sessionId)
const leftIds = tabs.slice(0, idx).map((t) => t.sessionId)
for (const id of leftIds) {
disconnectSession(id)
closeTab(id)
}
}
const handleCloseRight = (sessionId: string) => {
setContextMenu(null)
const idx = tabs.findIndex((t) => t.sessionId === sessionId)
@ -96,6 +110,41 @@ export function TabBar() {
}
}
const handleCloseAll = () => {
setContextMenu(null)
const allIds = tabs.map((t) => t.sessionId)
for (const id of allIds) {
disconnectSession(id)
closeTab(id)
}
}
const handleDragStart = (index: number) => {
dragIndexRef.current = index
}
const handleDragOver = (e: React.DragEvent, index: number) => {
e.preventDefault()
if (dragIndexRef.current === null || dragIndexRef.current === index) {
setDragOverIndex(null)
return
}
setDragOverIndex(index)
}
const handleDrop = (index: number) => {
if (dragIndexRef.current !== null && dragIndexRef.current !== index) {
moveTab(dragIndexRef.current, index)
}
dragIndexRef.current = null
setDragOverIndex(null)
}
const handleDragEnd = () => {
dragIndexRef.current = null
setDragOverIndex(null)
}
if (tabs.length === 0) return null
return (
@ -107,15 +156,20 @@ export function TabBar() {
</button>
)}
<div ref={scrollRef} className="flex-1 flex items-center overflow-x-hidden" data-tauri-drag-region>
{tabs.map((tab) => (
<div ref={scrollRef} className="flex-1 flex items-center overflow-x-hidden" data-tauri-drag-region onDragOver={(e) => e.preventDefault()}>
{tabs.map((tab, index) => (
<TabItem
key={tab.sessionId}
tab={tab}
isActive={tab.sessionId === activeTabId}
isDragOver={dragOverIndex === index}
onClick={() => setActiveTab(tab.sessionId)}
onClose={() => handleClose(tab.sessionId)}
onContextMenu={(e) => handleContextMenu(e, tab.sessionId)}
onDragStart={() => handleDragStart(index)}
onDragOver={(e) => handleDragOver(e, index)}
onDrop={() => handleDrop(index)}
onDragEnd={handleDragEnd}
/>
))}
</div>
@ -143,12 +197,25 @@ export function TabBar() {
>
{t('tabs.closeOthers')}
</button>
<button
onClick={() => handleCloseLeft(contextMenu.sessionId)}
className="w-full px-3 py-1.5 text-xs text-left text-[var(--color-text-primary)] hover:bg-[var(--color-surface-hover)]"
>
{t('tabs.closeLeft')}
</button>
<button
onClick={() => handleCloseRight(contextMenu.sessionId)}
className="w-full px-3 py-1.5 text-xs text-left text-[var(--color-text-primary)] hover:bg-[var(--color-surface-hover)]"
>
{t('tabs.closeRight')}
</button>
<div className="my-1 border-t border-[var(--color-border)]" />
<button
onClick={handleCloseAll}
className="w-full px-3 py-1.5 text-xs text-left text-[var(--color-text-primary)] hover:bg-[var(--color-surface-hover)]"
>
{t('tabs.closeAll')}
</button>
</div>
)}
@ -186,23 +253,34 @@ export function TabBar() {
)
}
function TabItem({ tab, isActive, onClick, onClose, onContextMenu }: {
function TabItem({ tab, isActive, isDragOver, onClick, onClose, onContextMenu, onDragStart, onDragOver, onDrop, onDragEnd }: {
tab: Tab
isActive: boolean
isDragOver: boolean
onClick: () => void
onClose: () => void
onContextMenu: (e: React.MouseEvent) => void
onDragStart: () => void
onDragOver: (e: React.DragEvent) => void
onDrop: () => void
onDragEnd: () => void
}) {
return (
<div
draggable
onClick={onClick}
onContextMenu={onContextMenu}
onDragStart={onDragStart}
onDragOver={onDragOver}
onDrop={onDrop}
onDragEnd={onDragEnd}
className={`
flex-shrink-0 flex items-center gap-1.5 px-3 cursor-pointer group transition-colors
flex-shrink-0 flex items-center gap-1.5 px-3 cursor-pointer group transition-colors relative
${isActive
? 'h-[37px] bg-[var(--color-surface)] border-t-2 border-t-[var(--color-brand)]'
: 'h-[37px] bg-transparent hover:bg-[var(--color-surface-hover)]'
}
${isDragOver ? 'before:absolute before:left-0 before:top-[6px] before:bottom-[6px] before:w-[2px] before:bg-[var(--color-brand)] before:rounded-full' : ''}
`}
style={{ width: TAB_WIDTH, maxWidth: TAB_WIDTH }}
>

View File

@ -438,7 +438,9 @@ export const en = {
// ─── Tabs ──────────────────────────────────────
'tabs.close': 'Close',
'tabs.closeOthers': 'Close Others',
'tabs.closeLeft': 'Close to the Left',
'tabs.closeRight': 'Close to the Right',
'tabs.closeAll': 'Close All',
'tabs.closeConfirmTitle': 'Session Running',
'tabs.closeConfirmMessage': 'This session is still running. What would you like to do?',
'tabs.closeConfirmKeep': 'Keep Running',

View File

@ -440,7 +440,9 @@ export const zh: Record<TranslationKey, string> = {
// ─── Tabs ──────────────────────────────────────
'tabs.close': '关闭',
'tabs.closeOthers': '关闭其他',
'tabs.closeLeft': '关闭左侧',
'tabs.closeRight': '关闭右侧',
'tabs.closeAll': '关闭所有',
'tabs.closeConfirmTitle': '会话运行中',
'tabs.closeConfirmMessage': '此会话仍在运行,你想要怎么做?',
'tabs.closeConfirmKeep': '保持运行',

View File

@ -29,6 +29,7 @@ type TabStore = {
setActiveTab: (sessionId: string) => void
updateTabTitle: (sessionId: string, title: string) => void
updateTabStatus: (sessionId: string, status: Tab['status']) => void
moveTab: (fromIndex: number, toIndex: number) => void
saveTabs: () => void
restoreTabs: () => Promise<void>
@ -92,6 +93,17 @@ export const useTabStore = create<TabStore>((set, get) => ({
}))
},
moveTab: (fromIndex, toIndex) => {
if (fromIndex === toIndex) return
const { tabs } = get()
if (fromIndex < 0 || fromIndex >= tabs.length || toIndex < 0 || toIndex >= tabs.length) return
const newTabs = [...tabs]
const [moved] = newTabs.splice(fromIndex, 1)
newTabs.splice(toIndex, 0, moved!)
set({ tabs: newTabs })
get().saveTabs()
},
saveTabs: () => {
const { tabs, activeTabId } = get()
const data: TabPersistence = {