Skip to content

Commit 147f445

Browse files
committed
hook up contextmenu to vertical tabs
1 parent 91b2144 commit 147f445

5 files changed

Lines changed: 121 additions & 85 deletions

File tree

frontend/app/tab/tab.tsx

Lines changed: 3 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
// SPDX-License-Identifier: Apache-2.0
33

44
import { getTabBadgeAtom } from "@/app/store/badge";
5-
import { getOrefMetaKeyAtom, globalStore, recordTEvent, refocusNode } from "@/app/store/global";
5+
import { refocusNode } from "@/app/store/global";
66
import { TabRpcClient } from "@/app/store/wshrpcutil";
77
import { WaveEnv, WaveEnvSubset, useWaveEnv } from "@/app/waveenv/waveenv";
88
import { Button } from "@/element/button";
@@ -14,8 +14,9 @@ import { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useRef,
1414
import { makeORef } from "../store/wos";
1515
import { TabBadges } from "./tabbadges";
1616
import "./tab.scss";
17+
import { buildTabContextMenu } from "./tabcontextmenu";
1718

18-
type TabEnv = WaveEnvSubset<{
19+
export type TabEnv = WaveEnvSubset<{
1920
rpc: {
2021
ActivityCommand: WaveEnv["rpc"]["ActivityCommand"];
2122
SetMetaCommand: WaveEnv["rpc"]["SetMetaCommand"];
@@ -216,88 +217,6 @@ const TabV = forwardRef<HTMLDivElement, TabVProps>((props, ref) => {
216217

217218
TabV.displayName = "TabV";
218219

219-
const FlagColors: { label: string; value: string }[] = [
220-
{ label: "Green", value: "#58C142" },
221-
{ label: "Teal", value: "#00FFDB" },
222-
{ label: "Blue", value: "#429DFF" },
223-
{ label: "Purple", value: "#BF55EC" },
224-
{ label: "Red", value: "#FF453A" },
225-
{ label: "Orange", value: "#FF9500" },
226-
{ label: "Yellow", value: "#FFE900" },
227-
];
228-
229-
function buildTabContextMenu(
230-
id: string,
231-
renameRef: React.RefObject<(() => void) | null>,
232-
onClose: (event: React.MouseEvent<HTMLButtonElement, MouseEvent> | null) => void,
233-
env: TabEnv
234-
): ContextMenuItem[] {
235-
const menu: ContextMenuItem[] = [];
236-
menu.push(
237-
{ label: "Rename Tab", click: () => renameRef.current?.() },
238-
{
239-
label: "Copy TabId",
240-
click: () => fireAndForget(() => navigator.clipboard.writeText(id)),
241-
},
242-
{ type: "separator" }
243-
);
244-
const tabORef = makeORef("tab", id);
245-
const currentFlagColor = globalStore.get(getOrefMetaKeyAtom(tabORef, "tab:flagcolor")) ?? null;
246-
const flagSubmenu: ContextMenuItem[] = [
247-
{
248-
label: "None",
249-
type: "checkbox",
250-
checked: currentFlagColor == null,
251-
click: () =>
252-
fireAndForget(() =>
253-
env.rpc.SetMetaCommand(TabRpcClient, { oref: tabORef, meta: { "tab:flagcolor": null } })
254-
),
255-
},
256-
...FlagColors.map((fc) => ({
257-
label: fc.label,
258-
type: "checkbox" as const,
259-
checked: currentFlagColor === fc.value,
260-
click: () =>
261-
fireAndForget(() =>
262-
env.rpc.SetMetaCommand(TabRpcClient, { oref: tabORef, meta: { "tab:flagcolor": fc.value } })
263-
),
264-
})),
265-
];
266-
menu.push({ label: "Flag Tab", type: "submenu", submenu: flagSubmenu }, { type: "separator" });
267-
const fullConfig = globalStore.get(env.atoms.fullConfigAtom);
268-
const bgPresets: string[] = [];
269-
for (const key in fullConfig?.presets ?? {}) {
270-
if (key.startsWith("bg@") && fullConfig.presets[key] != null) {
271-
bgPresets.push(key);
272-
}
273-
}
274-
bgPresets.sort((a, b) => {
275-
const aOrder = fullConfig.presets[a]["display:order"] ?? 0;
276-
const bOrder = fullConfig.presets[b]["display:order"] ?? 0;
277-
return aOrder - bOrder;
278-
});
279-
if (bgPresets.length > 0) {
280-
const submenu: ContextMenuItem[] = [];
281-
const oref = makeORef("tab", id);
282-
for (const presetName of bgPresets) {
283-
// preset cannot be null (filtered above)
284-
const preset = fullConfig.presets[presetName];
285-
submenu.push({
286-
label: preset["display:name"] ?? presetName,
287-
click: () =>
288-
fireAndForget(async () => {
289-
await env.rpc.SetMetaCommand(TabRpcClient, { oref, meta: preset });
290-
env.rpc.ActivityCommand(TabRpcClient, { settabtheme: 1 }, { noresponse: true });
291-
recordTEvent("action:settabtheme");
292-
}),
293-
});
294-
}
295-
menu.push({ label: "Backgrounds", type: "submenu", submenu }, { type: "separator" });
296-
}
297-
menu.push({ label: "Close Tab", click: () => onClose(null) });
298-
return menu;
299-
}
300-
301220
interface TabProps {
302221
id: string;
303222
active: boolean;

frontend/app/tab/tabcontextmenu.ts

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
// Copyright 2026, Command Line Inc.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
import { getOrefMetaKeyAtom, globalStore, recordTEvent } from "@/app/store/global";
5+
import { TabRpcClient } from "@/app/store/wshrpcutil";
6+
import { fireAndForget } from "@/util/util";
7+
import { makeORef } from "../store/wos";
8+
import type { TabEnv } from "./tab";
9+
10+
const FlagColors: { label: string; value: string }[] = [
11+
{ label: "Green", value: "#58C142" },
12+
{ label: "Teal", value: "#00FFDB" },
13+
{ label: "Blue", value: "#429DFF" },
14+
{ label: "Purple", value: "#BF55EC" },
15+
{ label: "Red", value: "#FF453A" },
16+
{ label: "Orange", value: "#FF9500" },
17+
{ label: "Yellow", value: "#FFE900" },
18+
];
19+
20+
function buildTabContextMenu(
21+
id: string,
22+
renameRef: React.RefObject<(() => void) | null>,
23+
onClose: (event: React.MouseEvent<HTMLButtonElement, MouseEvent> | null) => void,
24+
env: TabEnv
25+
): ContextMenuItem[] {
26+
const menu: ContextMenuItem[] = [];
27+
menu.push(
28+
{ label: "Rename Tab", click: () => renameRef.current?.() },
29+
{
30+
label: "Copy TabId",
31+
click: () => fireAndForget(() => navigator.clipboard.writeText(id)),
32+
},
33+
{ type: "separator" }
34+
);
35+
const tabORef = makeORef("tab", id);
36+
const currentFlagColor = globalStore.get(getOrefMetaKeyAtom(tabORef, "tab:flagcolor")) ?? null;
37+
const flagSubmenu: ContextMenuItem[] = [
38+
{
39+
label: "None",
40+
type: "checkbox",
41+
checked: currentFlagColor == null,
42+
click: () =>
43+
fireAndForget(() =>
44+
env.rpc.SetMetaCommand(TabRpcClient, { oref: tabORef, meta: { "tab:flagcolor": null } })
45+
),
46+
},
47+
...FlagColors.map((fc) => ({
48+
label: fc.label,
49+
type: "checkbox" as const,
50+
checked: currentFlagColor === fc.value,
51+
click: () =>
52+
fireAndForget(() =>
53+
env.rpc.SetMetaCommand(TabRpcClient, { oref: tabORef, meta: { "tab:flagcolor": fc.value } })
54+
),
55+
})),
56+
];
57+
menu.push({ label: "Flag Tab", type: "submenu", submenu: flagSubmenu }, { type: "separator" });
58+
const fullConfig = globalStore.get(env.atoms.fullConfigAtom);
59+
const bgPresets: string[] = [];
60+
for (const key in fullConfig?.presets ?? {}) {
61+
if (key.startsWith("bg@") && fullConfig.presets[key] != null) {
62+
bgPresets.push(key);
63+
}
64+
}
65+
bgPresets.sort((a, b) => {
66+
const aOrder = fullConfig.presets[a]["display:order"] ?? 0;
67+
const bOrder = fullConfig.presets[b]["display:order"] ?? 0;
68+
return aOrder - bOrder;
69+
});
70+
if (bgPresets.length > 0) {
71+
const submenu: ContextMenuItem[] = [];
72+
const oref = makeORef("tab", id);
73+
for (const presetName of bgPresets) {
74+
// preset cannot be null (filtered above)
75+
const preset = fullConfig.presets[presetName];
76+
submenu.push({
77+
label: preset["display:name"] ?? presetName,
78+
click: () =>
79+
fireAndForget(async () => {
80+
await env.rpc.SetMetaCommand(TabRpcClient, { oref, meta: preset });
81+
env.rpc.ActivityCommand(TabRpcClient, { settabtheme: 1 }, { noresponse: true });
82+
recordTEvent("action:settabtheme");
83+
}),
84+
});
85+
}
86+
menu.push({ label: "Backgrounds", type: "submenu", submenu }, { type: "separator" });
87+
}
88+
menu.push({ label: "Close Tab", click: () => onClose(null) });
89+
return menu;
90+
}
91+
92+
export { buildTabContextMenu };

frontend/app/tab/vtab.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,13 @@ interface VTabProps {
2525
onSelect: () => void;
2626
onClose?: () => void;
2727
onRename?: (newName: string) => void;
28+
onContextMenu?: (event: React.MouseEvent<HTMLDivElement>) => void;
2829
onDragStart: (event: React.DragEvent<HTMLDivElement>) => void;
2930
onDragOver: (event: React.DragEvent<HTMLDivElement>) => void;
3031
onDrop: (event: React.DragEvent<HTMLDivElement>) => void;
3132
onDragEnd: () => void;
3233
onHoverChanged?: (isHovered: boolean) => void;
34+
renameRef?: React.RefObject<(() => void) | null>;
3335
}
3436

3537
export function VTab({
@@ -41,11 +43,13 @@ export function VTab({
4143
onSelect,
4244
onClose,
4345
onRename,
46+
onContextMenu,
4447
onDragStart,
4548
onDragOver,
4649
onDrop,
4750
onDragEnd,
4851
onHoverChanged,
52+
renameRef,
4953
}: VTabProps) {
5054
const [originalName, setOriginalName] = useState(tab.name);
5155
const [isEditable, setIsEditable] = useState(false);
@@ -104,6 +108,10 @@ export function VTab({
104108
}, RenameFocusDelayMs);
105109
}, [isReordering, onRename, selectEditableText]);
106110

111+
if (renameRef != null) {
112+
renameRef.current = startRename;
113+
}
114+
107115
const handleBlur = () => {
108116
if (!editableRef.current) {
109117
return;
@@ -143,6 +151,7 @@ export function VTab({
143151
event.stopPropagation();
144152
startRename();
145153
}}
154+
onContextMenu={onContextMenu}
146155
onDragStart={onDragStart}
147156
onDragOver={onDragOver}
148157
onDrop={onDrop}

frontend/app/tab/vtabbar.tsx

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,9 @@ import { useWaveEnv } from "@/app/waveenv/waveenv";
88
import { validateCssColor } from "@/util/color-validator";
99
import { cn, fireAndForget } from "@/util/util";
1010
import { useAtomValue } from "jotai";
11-
import { useEffect, useRef, useState } from "react";
11+
import { useCallback, useEffect, useRef, useState } from "react";
1212
import { VTab, VTabItem } from "./vtab";
13+
import { buildTabContextMenu } from "./tabcontextmenu";
1314
import { VTabBarEnv } from "./vtabbarenv";
1415
export type { VTabItem } from "./vtab";
1516

@@ -55,6 +56,7 @@ function VTabWrapper({
5556
const env = useWaveEnv<VTabBarEnv>();
5657
const [tabData] = env.wos.useWaveObjectValue<Tab>(makeORef("tab", tabId));
5758
const badges = useAtomValue(getTabBadgeAtom(tabId, env));
59+
const renameRef = useRef<(() => void) | null>(null);
5860

5961
const rawFlagColor = tabData?.meta?.["tab:flagcolor"];
6062
let flagColor: string | null = null;
@@ -74,6 +76,15 @@ function VTabWrapper({
7476
flagColor,
7577
};
7678

79+
const handleContextMenu = useCallback(
80+
(e: React.MouseEvent<HTMLDivElement>) => {
81+
e.preventDefault();
82+
const menu = buildTabContextMenu(tabId, renameRef, () => onClose(), env);
83+
env.showContextMenu(menu, e);
84+
},
85+
[tabId, onClose, env]
86+
);
87+
7788
return (
7889
<VTab
7990
key={`${tabId}:${hoverResetVersion}`}
@@ -85,11 +96,13 @@ function VTabWrapper({
8596
onSelect={onSelect}
8697
onClose={onClose}
8798
onRename={onRename}
99+
onContextMenu={handleContextMenu}
88100
onDragStart={onDragStart}
89101
onDragOver={onDragOver}
90102
onDrop={onDrop}
91103
onDragEnd={onDragEnd}
92104
onHoverChanged={onHoverChanged}
105+
renameRef={renameRef}
93106
/>
94107
);
95108
}

frontend/app/tab/vtabbarenv.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,16 @@ export type VTabBarEnv = WaveEnvSubset<{
1212
rpc: {
1313
UpdateWorkspaceTabIdsCommand: WaveEnv["rpc"]["UpdateWorkspaceTabIdsCommand"];
1414
UpdateTabNameCommand: WaveEnv["rpc"]["UpdateTabNameCommand"];
15+
ActivityCommand: WaveEnv["rpc"]["ActivityCommand"];
16+
SetMetaCommand: WaveEnv["rpc"]["SetMetaCommand"];
1517
};
1618
atoms: {
1719
staticTabId: WaveEnv["atoms"]["staticTabId"];
1820
fullConfigAtom: WaveEnv["atoms"]["fullConfigAtom"];
1921
reinitVersion: WaveEnv["atoms"]["reinitVersion"];
2022
};
2123
wos: WaveEnv["wos"];
24+
showContextMenu: WaveEnv["showContextMenu"];
2225
getSettingsKeyAtom: SettingsKeyAtomFnType<"tab:confirmclose">;
2326
mockSetWaveObj: WaveEnv["mockSetWaveObj"];
2427
isWindows: WaveEnv["isWindows"];

0 commit comments

Comments
 (0)