Skip to content

Commit 405b425

Browse files
committed
getting vtabbar into shape, share mock between the tab bars
1 parent 896c89f commit 405b425

6 files changed

Lines changed: 365 additions & 235 deletions

File tree

frontend/app/tab/vtabbar.tsx

Lines changed: 131 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,22 @@
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";
511
import { useEffect, useMemo, useRef, useState } from "react";
612
import { VTab, VTabItem } from "./vtab";
13+
import { VTabBarEnv } from "./vtabbarenv";
714
export type { VTabItem } from "./vtab";
815

916
interface 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

2022
function 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"

frontend/app/tab/vtabbarenv.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
// Copyright 2026, Command Line Inc.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
import { SettingsKeyAtomFnType, WaveEnv, WaveEnvSubset } from "@/app/waveenv/waveenv";
5+
6+
export type VTabBarEnv = WaveEnvSubset<{
7+
electron: {
8+
createTab: WaveEnv["electron"]["createTab"];
9+
closeTab: WaveEnv["electron"]["closeTab"];
10+
setActiveTab: WaveEnv["electron"]["setActiveTab"];
11+
};
12+
rpc: {
13+
UpdateWorkspaceTabIdsCommand: WaveEnv["rpc"]["UpdateWorkspaceTabIdsCommand"];
14+
UpdateTabNameCommand: WaveEnv["rpc"]["UpdateTabNameCommand"];
15+
};
16+
atoms: {
17+
staticTabId: WaveEnv["atoms"]["staticTabId"];
18+
fullConfigAtom: WaveEnv["atoms"]["fullConfigAtom"];
19+
reinitVersion: WaveEnv["atoms"]["reinitVersion"];
20+
};
21+
wos: WaveEnv["wos"];
22+
getSettingsKeyAtom: SettingsKeyAtomFnType<"tab:confirmclose">;
23+
mockSetWaveObj: WaveEnv["mockSetWaveObj"];
24+
isWindows: WaveEnv["isWindows"];
25+
isMacOS: WaveEnv["isMacOS"];
26+
}>;

0 commit comments

Comments
 (0)