File
Blob: src/client/components/tab-strip.tsx
| 1 | import { X, Plus } from "lucide-react"; |
| 2 | import { useWorkspaceStore } from "../workspace/store"; |
| 3 | import type { SocketState } from "../terminal/socket"; |
| 4 | |
| 5 | const STATE_DOT_CLASS: Record<SocketState, string> = { |
| 6 | disconnected: "bg-zinc-500", |
| 7 | connecting: "bg-amber-500", |
| 8 | connected: "bg-emerald-500", |
| 9 | reconnecting: "bg-amber-500", |
| 10 | ended: "bg-red-500", |
| 11 | error: "bg-red-500", |
| 12 | }; |
| 13 | |
| 14 | export function TabStrip() { |
| 15 | const tabs = useWorkspaceStore((s) => s.tabs); |
| 16 | const activeTabId = useWorkspaceStore((s) => s.activeTabId); |
| 17 | const connectionStates = useWorkspaceStore((s) => s.connectionStates); |
| 18 | const switchTab = useWorkspaceStore((s) => s.switchTab); |
| 19 | const closeTab = useWorkspaceStore((s) => s.closeTab); |
| 20 | const newTab = useWorkspaceStore((s) => s.newTab); |
| 21 | |
| 22 | return ( |
| 23 | <nav |
| 24 | className="flex items-center bg-zinc-900 border-b border-zinc-800/60 min-h-8 px-2 shrink-0" |
| 25 | aria-label="Terminal tabs" |
| 26 | > |
| 27 | <div className="flex overflow-x-auto gap-0.5 scrollbar-none" role="tablist"> |
| 28 | {tabs.map((tab) => { |
| 29 | const isActive = tab.id === activeTabId; |
| 30 | const state: SocketState = connectionStates[tab.id] ?? "disconnected"; |
| 31 | |
| 32 | return ( |
| 33 | <div |
| 34 | key={tab.id} |
| 35 | role="presentation" |
| 36 | className={`group flex items-center rounded-t max-w-[180px] min-w-[80px] transition-colors ${ |
| 37 | isActive ? "bg-canvas" : "hover:bg-zinc-800/50" |
| 38 | }`} |
| 39 | > |
| 40 | <button |
| 41 | type="button" |
| 42 | role="tab" |
| 43 | aria-selected={isActive} |
| 44 | tabIndex={isActive ? 0 : -1} |
| 45 | className={`flex min-w-0 flex-1 items-center gap-1.5 px-2.5 py-1 rounded-t text-xs whitespace-nowrap transition-colors ${ |
| 46 | isActive ? "text-zinc-300" : "text-zinc-500 hover:text-zinc-400" |
| 47 | }`} |
| 48 | onClick={() => switchTab(tab.id)} |
| 49 | > |
| 50 | <span className={`size-1.5 rounded-full shrink-0 ${STATE_DOT_CLASS[state]}`} /> |
| 51 | <span className="truncate">{tab.title}</span> |
| 52 | </button> |
| 53 | <button |
| 54 | type="button" |
| 55 | className="shrink-0 size-4 mr-1 flex items-center justify-center rounded opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 text-zinc-500 hover:text-red-400 hover:bg-red-500/15 transition-opacity" |
| 56 | aria-label={`Close ${tab.title}`} |
| 57 | title="Detach tab (session stays alive)" |
| 58 | onClick={(e) => { |
| 59 | e.stopPropagation(); |
| 60 | closeTab(tab.id); |
| 61 | }} |
| 62 | > |
| 63 | <X size={12} aria-hidden="true" /> |
| 64 | </button> |
| 65 | </div> |
| 66 | ); |
| 67 | })} |
| 68 | </div> |
| 69 | <button |
| 70 | type="button" |
| 71 | className="shrink-0 size-7 flex items-center justify-center rounded border border-zinc-800/60 text-zinc-500 hover:text-zinc-300 hover:bg-zinc-800/50 ml-1 transition-colors" |
| 72 | onClick={newTab} |
| 73 | aria-label="New terminal tab" |
| 74 | > |
| 75 | <Plus size={14} aria-hidden="true" /> |
| 76 | </button> |
| 77 | </nav> |
| 78 | ); |
| 79 | } |