mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
The previous tab strip height made session titles and toolbar affordances feel cramped. Increase the shared titlebar height token for tabs, overflow controls, and the native drag gutter so the top chrome reads as a deliberate desktop titlebar while preserving the existing drag, close, and toolbar interactions. Constraint: Keep the change scoped to the desktop tab strip and avoid changing session state, ordering, or window-control behavior Rejected: Increase only the outer bar height | tab hit targets and overflow controls would remain visually misaligned Confidence: high Scope-risk: narrow Directive: Keep tab, overflow button, and drag-gutter heights aligned when changing this titlebar again Tested: cd desktop && bunx vitest run src/components/layout/TabBar.test.tsx Tested: bun run check:desktop Tested: Browser smoke on Vite with tab bar measured at about 44px and toolbar controls centered Not-tested: Native packaged Tauri window rendering on Windows titlebar controls
547 lines
21 KiB
TypeScript
547 lines
21 KiB
TypeScript
import { forwardRef, useRef, useState, useEffect, useCallback } from 'react'
|
|
import {
|
|
SCHEDULED_TAB_ID,
|
|
SETTINGS_TAB_ID,
|
|
TERMINAL_TAB_PREFIX,
|
|
useTabStore,
|
|
type Tab,
|
|
} from '../../stores/tabStore'
|
|
import { useChatStore } from '../../stores/chatStore'
|
|
import { useSessionStore } from '../../stores/sessionStore'
|
|
import { useWorkspacePanelStore } from '../../stores/workspacePanelStore'
|
|
import { useTerminalPanelStore } from '../../stores/terminalPanelStore'
|
|
import { useTranslation } from '../../i18n'
|
|
import { WindowControls, showWindowControls } from './WindowControls'
|
|
import { OpenProjectMenu } from './OpenProjectMenu'
|
|
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)
|
|
|
|
function isSessionTab(tab: Tab | null) {
|
|
if (!tab) return false
|
|
const tabType = (tab as Partial<Tab>).type
|
|
if (tabType === 'session') return true
|
|
if (tabType) return false
|
|
return isSessionTabId(tab.sessionId)
|
|
}
|
|
|
|
function isSessionTabId(tabId: string | null) {
|
|
if (!tabId) return false
|
|
return tabId !== SETTINGS_TAB_ID &&
|
|
tabId !== SCHEDULED_TAB_ID &&
|
|
!tabId.startsWith(TERMINAL_TAB_PREFIX)
|
|
}
|
|
|
|
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 = isSessionTab(activeTab) || isSessionTabId(activeTabId)
|
|
const activeSession = useSessionStore((state) =>
|
|
activeTabId ? state.sessions.find((session) => session.id === activeTabId) : undefined,
|
|
)
|
|
const openProjectPath = isActiveSessionTab && activeSession?.workDirExists !== false
|
|
? activeSession?.workDir ?? null
|
|
: null
|
|
const isWorkspacePanelOpen = useWorkspacePanelStore((state) =>
|
|
activeTabId && isActiveSessionTab ? state.isPanelOpen(activeTabId) : false,
|
|
)
|
|
const isTerminalPanelOpen = useTerminalPanelStore((state) =>
|
|
activeTabId && isActiveSessionTab ? state.isPanelOpen(activeTabId) : false,
|
|
)
|
|
|
|
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 [draggingSessionId, setDraggingSessionId] = useState<string | null>(null)
|
|
const [dragOffsetX, setDragOffsetX] = useState(0)
|
|
const dragIndexRef = useRef<number | null>(null)
|
|
const pendingDragRef = useRef<{ index: number; startX: number; startY: number } | null>(null)
|
|
const suppressClickRef = useRef(false)
|
|
const tabRefs = useRef(new Map<string, HTMLDivElement | null>())
|
|
const startDraggingRef = useRef<(() => Promise<void>) | null>(null)
|
|
const t = useTranslation()
|
|
|
|
useEffect(() => {
|
|
if (!isTauri) return
|
|
import('@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 (!activeTabId) return
|
|
const activeTabEl = tabRefs.current.get(activeTabId)
|
|
if (!activeTabEl) return
|
|
|
|
activeTabEl.scrollIntoView({
|
|
block: 'nearest',
|
|
inline: 'nearest',
|
|
behavior: 'smooth',
|
|
})
|
|
|
|
const frame = window.requestAnimationFrame(updateScrollState)
|
|
return () => window.cancelAnimationFrame(frame)
|
|
}, [activeTabId, tabs.length, updateScrollState])
|
|
|
|
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 (isSessionTab(tab)) {
|
|
useWorkspacePanelStore.getState().clearSession(tab.sessionId)
|
|
useTerminalPanelStore.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 (!isSessionTab(tab)) {
|
|
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 (isSessionTab(tab)) 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 (isSessionTab(tab)) 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 (isSessionTab(tab)) disconnectSession(tab.sessionId)
|
|
closeTabWithCleanup(tab)
|
|
}
|
|
}
|
|
|
|
const handleCloseAll = () => {
|
|
setContextMenu(null)
|
|
for (const tab of tabs) {
|
|
if (isSessionTab(tab)) 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<HTMLDivElement>) => {
|
|
if (event.button !== 0 || event.target !== scrollRef.current) return
|
|
const startDragging = startDraggingRef.current
|
|
if (!startDragging) return
|
|
void startDragging().catch(() => {})
|
|
}, [])
|
|
|
|
return (
|
|
<div
|
|
data-testid="tab-bar"
|
|
className="flex min-h-11 items-stretch bg-[var(--color-surface-container)] select-none border-b border-[var(--color-border)]"
|
|
>
|
|
|
|
{canScrollLeft && (
|
|
<button onClick={() => scroll('left')} className="flex h-11 w-7 flex-shrink-0 items-center justify-center text-[var(--color-text-tertiary)] hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)]">
|
|
<span className="material-symbols-outlined text-[16px]">chevron_left</span>
|
|
</button>
|
|
)}
|
|
|
|
<div
|
|
ref={scrollRef}
|
|
className="tab-bar-hit-area flex-1 flex items-stretch overflow-x-hidden"
|
|
onDragOver={(e) => e.preventDefault()}
|
|
onMouseDown={handleScrollRegionMouseDown}
|
|
>
|
|
{tabs.map((tab, index) => (
|
|
<TabItem
|
|
key={tab.sessionId}
|
|
ref={(node) => { 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)}
|
|
/>
|
|
))}
|
|
</div>
|
|
|
|
<div className="flex shrink-0 items-center gap-1 border-l border-[var(--color-border)]/70 px-2">
|
|
{isTauri && isActiveSessionTab && (
|
|
<OpenProjectMenu path={openProjectPath} />
|
|
)}
|
|
<ToolbarIconButton
|
|
icon={<SquareTerminal size={17} strokeWidth={1.9} />}
|
|
label={t('tabs.openTerminal')}
|
|
onClick={() => {
|
|
if (activeTabId && isActiveSessionTab) {
|
|
useTerminalPanelStore.getState().togglePanel(activeTabId)
|
|
return
|
|
}
|
|
useTabStore.getState().openTerminalTab()
|
|
}}
|
|
active={isTerminalPanelOpen}
|
|
/>
|
|
{isActiveSessionTab && activeTabId && (
|
|
<ToolbarIconButton
|
|
icon={isWorkspacePanelOpen ? <FolderOpen size={18} strokeWidth={1.9} /> : <Folder size={18} strokeWidth={1.9} />}
|
|
label={t(isWorkspacePanelOpen ? 'tabs.hideWorkspace' : 'tabs.showWorkspace')}
|
|
onClick={() => useWorkspacePanelStore.getState().togglePanel(activeTabId)}
|
|
active={isWorkspacePanelOpen}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
{isTauri && (
|
|
<div
|
|
data-testid="tab-bar-drag-gutter"
|
|
data-tauri-drag-region
|
|
aria-hidden="true"
|
|
className={`min-h-11 flex-shrink-0 ${showWindowControls ? 'w-3' : 'w-4'}`}
|
|
/>
|
|
)}
|
|
|
|
{canScrollRight && (
|
|
<button onClick={() => scroll('right')} className="flex h-11 w-7 flex-shrink-0 items-center justify-center text-[var(--color-text-tertiary)] hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)]">
|
|
<span className="material-symbols-outlined text-[16px]">chevron_right</span>
|
|
</button>
|
|
)}
|
|
|
|
<WindowControls />
|
|
|
|
{contextMenu && (
|
|
<div
|
|
className="fixed z-50 bg-[var(--color-surface)] border border-[var(--color-border)] rounded-[var(--radius-md)] py-1 min-w-[160px]"
|
|
style={{ left: contextMenu.x, top: contextMenu.y, boxShadow: 'var(--shadow-dropdown)' }}
|
|
>
|
|
<button
|
|
onClick={() => { handleClose(contextMenu.sessionId); setContextMenu(null) }}
|
|
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.close')}
|
|
</button>
|
|
<button
|
|
onClick={() => handleCloseOthers(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.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>
|
|
)}
|
|
|
|
{closingTabId && (
|
|
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/30">
|
|
<div className="bg-[var(--color-surface)] rounded-xl border border-[var(--color-border)] p-6 max-w-sm w-full mx-4" style={{ boxShadow: 'var(--shadow-dropdown)' }}>
|
|
<h3 className="text-sm font-semibold text-[var(--color-text-primary)] mb-2">{t('tabs.closeConfirmTitle')}</h3>
|
|
<p className="text-xs text-[var(--color-text-secondary)] mb-4">{t('tabs.closeConfirmMessage')}</p>
|
|
<div className="flex justify-end gap-2">
|
|
<button onClick={() => setClosingTabId(null)} className="px-3 py-1.5 text-xs rounded-lg border border-[var(--color-border)] text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)]">
|
|
{t('common.cancel')}
|
|
</button>
|
|
<button
|
|
onClick={() => {
|
|
const tab = tabs.find((item) => item.sessionId === closingTabId)
|
|
if (tab) closeTabWithCleanup(tab)
|
|
setClosingTabId(null)
|
|
}}
|
|
className="px-3 py-1.5 text-xs rounded-lg border border-[var(--color-border)] text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)]"
|
|
>
|
|
{t('tabs.closeConfirmKeep')}
|
|
</button>
|
|
<button
|
|
onClick={() => {
|
|
useChatStore.getState().stopGeneration(closingTabId)
|
|
disconnectSession(closingTabId)
|
|
const tab = tabs.find((item) => item.sessionId === closingTabId)
|
|
if (tab) closeTabWithCleanup(tab)
|
|
setClosingTabId(null)
|
|
}}
|
|
className="px-3 py-1.5 text-xs rounded-lg bg-[var(--color-brand)] text-white hover:opacity-90"
|
|
>
|
|
{t('tabs.closeConfirmStop')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const TabItem = forwardRef<HTMLDivElement, {
|
|
tab: Tab
|
|
isActive: boolean
|
|
isDragOver: boolean
|
|
isDragging: boolean
|
|
dragOffsetX: number
|
|
onClick: () => void
|
|
onClose: () => void
|
|
onContextMenu: (e: React.MouseEvent) => void
|
|
onMouseDown: (event: React.MouseEvent) => void
|
|
}>(({ tab, isActive, isDragOver, isDragging, dragOffsetX, onClick, onClose, onContextMenu, onMouseDown }, ref) => {
|
|
return (
|
|
<div
|
|
ref={ref}
|
|
data-dragging={isDragging ? 'true' : 'false'}
|
|
onClick={onClick}
|
|
onMouseDown={onMouseDown}
|
|
onContextMenu={onContextMenu}
|
|
className={`
|
|
tab-bar-hit-area group relative flex min-h-11 flex-shrink-0 items-center gap-1.5 px-3
|
|
${isDragging ? 'z-20 cursor-grabbing' : 'cursor-grab'}
|
|
transition-[background-color,box-shadow,opacity,transform] duration-150 ease-out
|
|
${isActive
|
|
? 'bg-[var(--color-surface)] shadow-[inset_0_-2px_0_var(--color-brand)]'
|
|
: 'bg-transparent hover:bg-[var(--color-surface-hover)]'
|
|
}
|
|
${isDragging ? 'opacity-95 shadow-[0_10px_24px_rgba(0,0,0,0.18)] ring-1 ring-[var(--color-border)]' : ''}
|
|
${isDragOver ? 'before:absolute before:left-0 before:top-[4px] before:bottom-[4px] before:w-[3px] before:bg-[var(--color-brand)] before:rounded-full before:shadow-[0_0_0_1px_rgba(255,255,255,0.25)]' : ''}
|
|
`}
|
|
style={{
|
|
width: TAB_WIDTH,
|
|
maxWidth: TAB_WIDTH,
|
|
transform: isDragging ? `translateX(${dragOffsetX}px) scale(1.02)` : undefined,
|
|
}}
|
|
>
|
|
{tab.type === 'session' && tab.status === 'running' && (
|
|
<span className="w-1.5 h-1.5 rounded-full bg-[var(--color-success)] animate-pulse flex-shrink-0" />
|
|
)}
|
|
{tab.type === 'session' && tab.status === 'error' && (
|
|
<span className="w-1.5 h-1.5 rounded-full bg-[var(--color-error)] flex-shrink-0" />
|
|
)}
|
|
{tab.type === 'settings' && (
|
|
<span className="material-symbols-outlined text-[14px] flex-shrink-0 text-[var(--color-text-tertiary)]">settings</span>
|
|
)}
|
|
{tab.type === 'scheduled' && (
|
|
<span className="material-symbols-outlined text-[14px] flex-shrink-0 text-[var(--color-text-tertiary)]">schedule</span>
|
|
)}
|
|
{tab.type === 'terminal' && (
|
|
<span className="material-symbols-outlined text-[14px] flex-shrink-0 text-[var(--color-text-tertiary)]">terminal</span>
|
|
)}
|
|
|
|
<span className={`flex-1 truncate text-xs ${isActive ? 'text-[var(--color-text-primary)] font-medium' : 'text-[var(--color-text-secondary)]'}`}>
|
|
{tab.title || 'Untitled'}
|
|
</span>
|
|
|
|
<button
|
|
type="button"
|
|
aria-label={`Close ${tab.title || 'Untitled'}`}
|
|
onMouseDown={(e) => { e.stopPropagation() }}
|
|
onClick={(e) => { e.stopPropagation(); onClose() }}
|
|
className="flex-shrink-0 -mr-0.5 inline-flex h-3 w-3 items-center justify-center bg-transparent p-0 opacity-0 group-hover:opacity-100 focus-visible:opacity-100 transition-[opacity,color] text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)] focus-visible:outline-none"
|
|
>
|
|
<span className="material-symbols-outlined text-[11px] leading-none">close</span>
|
|
</button>
|
|
</div>
|
|
)
|
|
})
|
|
TabItem.displayName = 'TabItem'
|
|
|
|
function ToolbarIconButton({
|
|
icon,
|
|
label,
|
|
onClick,
|
|
active = false,
|
|
}: {
|
|
icon: React.ReactNode
|
|
label: string
|
|
onClick: () => void
|
|
active?: boolean
|
|
}) {
|
|
return (
|
|
<button
|
|
type="button"
|
|
aria-label={label}
|
|
title={label}
|
|
onClick={onClick}
|
|
data-active={active ? 'true' : 'false'}
|
|
className={`inline-flex h-8 w-8 items-center justify-center rounded-[10px] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)] ${
|
|
active
|
|
? 'bg-[var(--color-surface-hover)] text-[var(--color-text-primary)]'
|
|
: 'text-[var(--color-text-tertiary)] hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)]'
|
|
}`}
|
|
>
|
|
{icon}
|
|
</button>
|
|
)
|
|
}
|