11// Copyright 2026, Command Line Inc.
22// SPDX-License-Identifier: Apache-2.0
33
4- import { cn } from "@/util/util" ;
4+ import { getTabBadgeAtom } from "@/app/store/badge" ;
5+ import { makeORef } from "@/app/store/wos" ;
6+ import { TabRpcClient } from "@/app/store/wshrpcutil" ;
7+ import { useWaveEnv } from "@/app/waveenv/waveenv" ;
8+ import { validateCssColor } from "@/util/color-validator" ;
9+ import { cn , fireAndForget } from "@/util/util" ;
10+ import { useAtomValue } from "jotai" ;
511import { useEffect , useMemo , useRef , useState } from "react" ;
612import { VTab , VTabItem } from "./vtab" ;
13+ import { VTabBarEnv } from "./vtabbarenv" ;
714export type { VTabItem } from "./vtab" ;
815
916interface VTabBarProps {
10- tabs : VTabItem [ ] ;
11- activeTabId ?: string ;
17+ workspace : Workspace ;
1218 width ?: number ;
1319 className ?: string ;
14- onSelectTab ?: ( tabId : string ) => void ;
15- onCloseTab ?: ( tabId : string ) => void ;
16- onRenameTab ?: ( tabId : string , newName : string ) => void ;
17- onReorderTabs ?: ( tabIds : string [ ] ) => void ;
1820}
1921
2022function clampWidth ( width ?: number ) : number {
@@ -30,8 +32,83 @@ function clampWidth(width?: number): number {
3032 return width ;
3133}
3234
33- export function VTabBar ( { tabs, activeTabId, width, className, onSelectTab, onCloseTab, onRenameTab, onReorderTabs } : VTabBarProps ) {
34- const [ orderedTabs , setOrderedTabs ] = useState < VTabItem [ ] > ( tabs ) ;
35+ interface VTabWrapperProps {
36+ tabId : string ;
37+ active : boolean ;
38+ isDragging : boolean ;
39+ isReordering : boolean ;
40+ hoverResetVersion : number ;
41+ index : number ;
42+ onSelect : ( ) => void ;
43+ onClose : ( ) => void ;
44+ onRename : ( newName : string ) => void ;
45+ onDragStart : ( event : React . DragEvent < HTMLDivElement > ) => void ;
46+ onDragOver : ( event : React . DragEvent < HTMLDivElement > ) => void ;
47+ onDrop : ( event : React . DragEvent < HTMLDivElement > ) => void ;
48+ onDragEnd : ( ) => void ;
49+ }
50+
51+ function VTabWrapper ( {
52+ tabId,
53+ active,
54+ isDragging,
55+ isReordering,
56+ hoverResetVersion,
57+ onSelect,
58+ onClose,
59+ onRename,
60+ onDragStart,
61+ onDragOver,
62+ onDrop,
63+ onDragEnd,
64+ } : VTabWrapperProps ) {
65+ const env = useWaveEnv < VTabBarEnv > ( ) ;
66+ const [ tabData ] = env . wos . useWaveObjectValue < Tab > ( makeORef ( "tab" , tabId ) ) ;
67+ const badges = useAtomValue ( getTabBadgeAtom ( tabId , env ) ) ;
68+
69+ const rawFlagColor = tabData ?. meta ?. [ "tab:flagcolor" ] ;
70+ let flagColor : string | null = null ;
71+ if ( rawFlagColor ) {
72+ try {
73+ validateCssColor ( rawFlagColor ) ;
74+ flagColor = rawFlagColor ;
75+ } catch {
76+ flagColor = null ;
77+ }
78+ }
79+
80+ const tab : VTabItem = {
81+ id : tabId ,
82+ name : tabData ?. name ?? "" ,
83+ badges,
84+ flagColor,
85+ } ;
86+
87+ return (
88+ < VTab
89+ key = { `${ tabId } :${ hoverResetVersion } ` }
90+ tab = { tab }
91+ active = { active }
92+ isDragging = { isDragging }
93+ isReordering = { isReordering }
94+ onSelect = { onSelect }
95+ onClose = { onClose }
96+ onRename = { onRename }
97+ onDragStart = { onDragStart }
98+ onDragOver = { onDragOver }
99+ onDrop = { onDrop }
100+ onDragEnd = { onDragEnd }
101+ />
102+ ) ;
103+ }
104+
105+ export function VTabBar ( { workspace, width, className } : VTabBarProps ) {
106+ const env = useWaveEnv < VTabBarEnv > ( ) ;
107+ const activeTabId = useAtomValue ( env . atoms . staticTabId ) ;
108+ const reinitVersion = useAtomValue ( env . atoms . reinitVersion ) ;
109+ const tabIds = workspace ?. tabids ?? [ ] ;
110+
111+ const [ orderedTabIds , setOrderedTabIds ] = useState < string [ ] > ( tabIds ) ;
35112 const [ dragTabId , setDragTabId ] = useState < string | null > ( null ) ;
36113 const [ dropIndex , setDropIndex ] = useState < number | null > ( null ) ;
37114 const [ dropLineTop , setDropLineTop ] = useState < number | null > ( null ) ;
@@ -40,8 +117,14 @@ export function VTabBar({ tabs, activeTabId, width, className, onSelectTab, onCl
40117 const didResetHoverForDragRef = useRef ( false ) ;
41118
42119 useEffect ( ( ) => {
43- setOrderedTabs ( tabs ) ;
44- } , [ tabs ] ) ;
120+ setOrderedTabIds ( tabIds ) ;
121+ } , [ workspace ?. tabids ] ) ;
122+
123+ useEffect ( ( ) => {
124+ if ( reinitVersion > 0 ) {
125+ setOrderedTabIds ( workspace ?. tabids ?? [ ] ) ;
126+ }
127+ } , [ reinitVersion ] ) ;
45128
46129 const barWidth = useMemo ( ( ) => clampWidth ( width ) , [ width ] ) ;
47130
@@ -61,33 +144,36 @@ export function VTabBar({ tabs, activeTabId, width, className, onSelectTab, onCl
61144 if ( sourceTabId == null ) {
62145 return ;
63146 }
64- const sourceIndex = orderedTabs . findIndex ( ( tab ) => tab . id === sourceTabId ) ;
147+ const sourceIndex = orderedTabIds . findIndex ( ( id ) => id === sourceTabId ) ;
65148 if ( sourceIndex === - 1 ) {
66149 return ;
67150 }
68- const boundedTargetIndex = Math . max ( 0 , Math . min ( targetIndex , orderedTabs . length ) ) ;
151+ const boundedTargetIndex = Math . max ( 0 , Math . min ( targetIndex , orderedTabIds . length ) ) ;
69152 const adjustedTargetIndex = sourceIndex < boundedTargetIndex ? boundedTargetIndex - 1 : boundedTargetIndex ;
70153 if ( sourceIndex === adjustedTargetIndex ) {
71154 return ;
72155 }
73- const nextTabs = [ ...orderedTabs ] ;
74- const [ movedTab ] = nextTabs . splice ( sourceIndex , 1 ) ;
75- nextTabs . splice ( adjustedTargetIndex , 0 , movedTab ) ;
76- setOrderedTabs ( nextTabs ) ;
77- onReorderTabs ?. ( nextTabs . map ( ( tab ) => tab . id ) ) ;
156+ const nextTabIds = [ ...orderedTabIds ] ;
157+ const [ movedId ] = nextTabIds . splice ( sourceIndex , 1 ) ;
158+ nextTabIds . splice ( adjustedTargetIndex , 0 , movedId ) ;
159+ setOrderedTabIds ( nextTabIds ) ;
160+ fireAndForget ( ( ) => env . rpc . UpdateWorkspaceTabIdsCommand ( TabRpcClient , workspace . oid , nextTabIds ) ) ;
78161 } ;
79162
80163 return (
81164 < div
82- className = { cn ( "flex h-full min-w-[100px] max-w-[400px] flex-col overflow-hidden border-r border-border bg-panel" , className ) }
165+ className = { cn (
166+ "flex h-full min-w-[100px] max-w-[400px] flex-col overflow-hidden border-r border-border bg-panel" ,
167+ className
168+ ) }
83169 style = { { width : barWidth } }
84170 >
85171 < div
86172 className = "relative flex min-h-0 flex-1 flex-col overflow-y-auto"
87173 onDragOver = { ( event ) => {
88174 event . preventDefault ( ) ;
89175 if ( event . target === event . currentTarget ) {
90- setDropIndex ( orderedTabs . length ) ;
176+ setDropIndex ( orderedTabIds . length ) ;
91177 setDropLineTop ( event . currentTarget . scrollHeight ) ;
92178 }
93179 } }
@@ -99,22 +185,26 @@ export function VTabBar({ tabs, activeTabId, width, className, onSelectTab, onCl
99185 clearDragState ( ) ;
100186 } }
101187 >
102- { orderedTabs . map ( ( tab , index ) => (
103- < VTab
104- key = { `${ tab . id } :${ hoverResetVersion } ` }
105- tab = { tab }
106- active = { tab . id === activeTabId }
107- isDragging = { dragTabId === tab . id }
188+ { orderedTabIds . map ( ( tabId , index ) => (
189+ < VTabWrapper
190+ key = { `${ tabId } :${ hoverResetVersion } ` }
191+ tabId = { tabId }
192+ active = { tabId === activeTabId }
193+ isDragging = { dragTabId === tabId }
108194 isReordering = { dragTabId != null }
109- onSelect = { ( ) => onSelectTab ?.( tab . id ) }
110- onClose = { onCloseTab ? ( ) => onCloseTab ( tab . id ) : undefined }
111- onRename = { onRenameTab ? ( newName ) => onRenameTab ( tab . id , newName ) : undefined }
195+ hoverResetVersion = { hoverResetVersion }
196+ index = { index }
197+ onSelect = { ( ) => env . electron . setActiveTab ( tabId ) }
198+ onClose = { ( ) => fireAndForget ( ( ) => env . electron . closeTab ( workspace . oid , tabId , false ) ) }
199+ onRename = { ( newName ) =>
200+ fireAndForget ( ( ) => env . rpc . UpdateTabNameCommand ( TabRpcClient , tabId , newName ) )
201+ }
112202 onDragStart = { ( event ) => {
113203 didResetHoverForDragRef . current = false ;
114- dragSourceRef . current = tab . id ;
204+ dragSourceRef . current = tabId ;
115205 event . dataTransfer . effectAllowed = "move" ;
116- event . dataTransfer . setData ( "text/plain" , tab . id ) ;
117- setDragTabId ( tab . id ) ;
206+ event . dataTransfer . setData ( "text/plain" , tabId ) ;
207+ setDragTabId ( tabId ) ;
118208 setDropIndex ( index ) ;
119209 setDropLineTop ( event . currentTarget . offsetTop ) ;
120210 } }
@@ -141,6 +231,15 @@ export function VTabBar({ tabs, activeTabId, width, className, onSelectTab, onCl
141231 onDragEnd = { clearDragState }
142232 />
143233 ) ) }
234+ < button
235+ type = "button"
236+ className = "my-1 flex shrink-0 cursor-pointer items-center gap-1.5 rounded-sm pr-3 pl-2 py-1.5 text-xs text-secondary/60 transition-colors hover:bg-hover hover:text-primary"
237+ onClick = { ( ) => env . electron . createTab ( ) }
238+ aria-label = "New Tab"
239+ >
240+ < i className = "fa fa-solid fa-plus" style = { { fontSize : "10px" } } />
241+ < span > New Tab</ span >
242+ </ button >
144243 { dragTabId != null && dropIndex != null && dropLineTop != null && (
145244 < div
146245 className = "pointer-events-none absolute left-0 right-0 border-t-2 border-accent/80"
0 commit comments