import { forwardRef, useRef, useState, useEffect, useCallback } from 'react' import { useTabStore, type Tab } from '../../stores/tabStore' import { useChatStore } from '../../stores/chatStore' import { useWorkspacePanelStore } from '../../stores/workspacePanelStore' import { useTranslation } from '../../i18n' import { WindowControls, showWindowControls } from './WindowControls' import { Folder, FolderOpen, SquareTerminal } from 'lucide-react' const TAB_WIDTH = 180 const DRAG_START_THRESHOLD = 4 const isTauri = typeof window !== 'undefined' && ('__TAURI_INTERNALS__' in window || '__TAURI__' in window) export function TabBar() { const tabs = useTabStore((s) => s.tabs) const activeTabId = useTabStore((s) => s.activeTabId) const setActiveTab = useTabStore((s) => s.setActiveTab) const closeTab = useTabStore((s) => s.closeTab) const disconnectSession = useChatStore((s) => s.disconnectSession) const activeTab = tabs.find((tab) => tab.sessionId === activeTabId) ?? null const isActiveSessionTab = activeTab?.type === 'session' const isWorkspacePanelOpen = useWorkspacePanelStore((state) => activeTabId && isActiveSessionTab ? state.isPanelOpen(activeTabId) : false, ) const moveTab = useTabStore((s) => s.moveTab) const scrollRef = useRef(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(null) const [dragOverIndex, setDragOverIndex] = useState(null) const [draggingSessionId, setDraggingSessionId] = useState(null) const [dragOffsetX, setDragOffsetX] = useState(0) const dragIndexRef = useRef(null) const pendingDragRef = useRef<{ index: number; startX: number; startY: number } | null>(null) const suppressClickRef = useRef(false) const tabRefs = useRef(new Map()) const startDraggingRef = useRef<(() => Promise) | null>(null) const t = useTranslation() useEffect(() => { if (!isTauri) return import(/* @vite-ignore */ '@tauri-apps/api/window') .then(({ getCurrentWindow }) => { const win = getCurrentWindow() startDraggingRef.current = () => win.startDragging() }) .catch(() => {}) }, []) const updateScrollState = useCallback(() => { const el = scrollRef.current if (!el) return setCanScrollLeft(el.scrollLeft > 0) setCanScrollRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 1) }, []) useEffect(() => { updateScrollState() const el = scrollRef.current if (!el) return el.addEventListener('scroll', updateScrollState) const ro = new ResizeObserver(updateScrollState) ro.observe(el) return () => { el.removeEventListener('scroll', updateScrollState) ro.disconnect() } }, [updateScrollState, tabs.length]) useEffect(() => { if (!contextMenu) return const close = () => setContextMenu(null) document.addEventListener('click', close) return () => document.removeEventListener('click', close) }, [contextMenu]) const scroll = (direction: 'left' | 'right') => { const el = scrollRef.current if (!el) return el.scrollBy({ left: direction === 'left' ? -TAB_WIDTH : TAB_WIDTH, behavior: 'smooth' }) } const closeTabWithCleanup = useCallback((tab: Tab) => { if (tab.type === 'session') { useWorkspacePanelStore.getState().clearSession(tab.sessionId) } closeTab(tab.sessionId) }, [closeTab]) const handleClose = (sessionId: string) => { // Special tabs can always be closed directly const tab = tabs.find((t) => t.sessionId === sessionId) if (!tab) return if (tab.type !== 'session') { closeTabWithCleanup(tab) return } const sessionState = useChatStore.getState().sessions[sessionId] const isRunning = sessionState && sessionState.chatState !== 'idle' if (isRunning) { setClosingTabId(sessionId) return } disconnectSession(sessionId) closeTabWithCleanup(tab) } const handleContextMenu = (e: React.MouseEvent, sessionId: string) => { e.preventDefault() setContextMenu({ sessionId, x: e.clientX, y: e.clientY }) } const handleCloseOthers = (sessionId: string) => { setContextMenu(null) const otherTabs = tabs.filter((t) => t.sessionId !== sessionId) for (const tab of otherTabs) { if (tab.type === 'session') disconnectSession(tab.sessionId) closeTabWithCleanup(tab) } } const handleCloseLeft = (sessionId: string) => { setContextMenu(null) const idx = tabs.findIndex((t) => t.sessionId === sessionId) const leftTabs = tabs.slice(0, idx) for (const tab of leftTabs) { if (tab.type === 'session') disconnectSession(tab.sessionId) closeTabWithCleanup(tab) } } const handleCloseRight = (sessionId: string) => { setContextMenu(null) const idx = tabs.findIndex((t) => t.sessionId === sessionId) const rightTabs = tabs.slice(idx + 1) for (const tab of rightTabs) { if (tab.type === 'session') disconnectSession(tab.sessionId) closeTabWithCleanup(tab) } } const handleCloseAll = () => { setContextMenu(null) for (const tab of tabs) { if (tab.type === 'session') disconnectSession(tab.sessionId) closeTabWithCleanup(tab) } } const getTargetIndexFromClientX = useCallback((clientX: number) => { for (let index = 0; index < tabs.length; index++) { const tab = tabs[index] if (!tab) continue const el = tabRefs.current.get(tab.sessionId) if (!el) continue const rect = el.getBoundingClientRect() if (clientX < rect.left + rect.width / 2) return index } return tabs.length > 0 ? tabs.length - 1 : null }, [tabs]) const finalizeDrag = useCallback((targetIndex: number | null) => { if (dragIndexRef.current !== null && targetIndex !== null && dragIndexRef.current !== targetIndex) { moveTab(dragIndexRef.current, targetIndex) } dragIndexRef.current = null pendingDragRef.current = null setDraggingSessionId(null) setDragOffsetX(0) setDragOverIndex(null) }, [moveTab]) const handlePointerMove = useCallback((event: MouseEvent) => { const pending = pendingDragRef.current if (!pending) return const deltaX = Math.abs(event.clientX - pending.startX) const deltaY = Math.abs(event.clientY - pending.startY) if (dragIndexRef.current === null) { if (Math.max(deltaX, deltaY) < DRAG_START_THRESHOLD) return dragIndexRef.current = pending.index suppressClickRef.current = true setDraggingSessionId(tabs[pending.index]?.sessionId ?? null) } setDragOffsetX(event.clientX - pending.startX) const targetIndex = getTargetIndexFromClientX(event.clientX) if (targetIndex === null || targetIndex === dragIndexRef.current) { setDragOverIndex(null) return } setDragOverIndex(targetIndex) }, [getTargetIndexFromClientX]) const handlePointerUp = useCallback(() => { finalizeDrag(dragOverIndex) }, [dragOverIndex, finalizeDrag]) useEffect(() => { window.addEventListener('mousemove', handlePointerMove) window.addEventListener('mouseup', handlePointerUp) return () => { window.removeEventListener('mousemove', handlePointerMove) window.removeEventListener('mouseup', handlePointerUp) } }, [handlePointerMove, handlePointerUp]) useEffect(() => { if (!draggingSessionId) return const previousCursor = document.body.style.cursor document.body.style.cursor = 'grabbing' return () => { document.body.style.cursor = previousCursor } }, [draggingSessionId]) const handleTabMouseDown = (event: React.MouseEvent, index: number) => { if (event.button !== 0) return pendingDragRef.current = { index, startX: event.clientX, startY: event.clientY } } const handleTabClick = (sessionId: string) => { if (suppressClickRef.current) { suppressClickRef.current = false return } setActiveTab(sessionId) } const handleScrollRegionMouseDown = useCallback((event: React.MouseEvent) => { if (event.button !== 0 || event.target !== scrollRef.current) return const startDragging = startDraggingRef.current if (!startDragging) return void startDragging().catch(() => {}) }, []) if (tabs.length === 0 && !showWindowControls) return null return (
{canScrollLeft && ( )}
e.preventDefault()} onMouseDown={handleScrollRegionMouseDown} > {tabs.map((tab, index) => ( { tabRefs.current.set(tab.sessionId, node) }} tab={tab} isActive={tab.sessionId === activeTabId} isDragOver={dragOverIndex === index} isDragging={tab.sessionId === draggingSessionId} dragOffsetX={tab.sessionId === draggingSessionId ? dragOffsetX : 0} onClick={() => handleTabClick(tab.sessionId)} onClose={() => handleClose(tab.sessionId)} onContextMenu={(e) => handleContextMenu(e, tab.sessionId)} onMouseDown={(event) => handleTabMouseDown(event, index)} /> ))}
} label={t('tabs.openTerminal')} onClick={() => useTabStore.getState().openTerminalTab()} /> {isActiveSessionTab && activeTabId && ( : } label={t(isWorkspacePanelOpen ? 'tabs.hideWorkspace' : 'tabs.showWorkspace')} onClick={() => useWorkspacePanelStore.getState().togglePanel(activeTabId)} active={isWorkspacePanelOpen} /> )}
{isTauri && (