Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 42 additions & 21 deletions frontend/app/tab/tab.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,16 @@
// Copyright 2026, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0

import { getTabBadgeAtom, sortBadgesForTab } from "@/app/store/badge";
import { atoms, getOrefMetaKeyAtom, globalStore, recordTEvent, refocusNode } from "@/app/store/global";
import { RpcApi } from "@/app/store/wshclientapi";
import { sortBadgesForTab } from "@/app/store/badge";
import { TabRpcClient } from "@/app/store/wshrpcutil";
import { useWaveEnv, WaveEnv } from "@/app/waveenv/waveenv";
import { Button } from "@/element/button";
import { ContextMenuModel } from "@/store/contextmenu";
import { validateCssColor } from "@/util/color-validator";
import { fireAndForget, makeIconClass } from "@/util/util";
import clsx from "clsx";
import { useAtomValue } from "jotai";
import { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from "react";
import { v7 as uuidv7 } from "uuid";
import { ObjectService } from "../store/services";
import { makeORef, useWaveObjectValue } from "../store/wos";
import "./tab.scss";

Expand Down Expand Up @@ -41,6 +38,23 @@ interface TabBadgesProps {
flagColor?: string | null;
}

export type TabEnv = {
atoms: {
fullConfigAtom: WaveEnv["atoms"]["fullConfigAtom"];
};
rpc: {
ActivityCommand: WaveEnv["rpc"]["ActivityCommand"];
};
tab: {
getTabBadgeAtom: WaveEnv["tab"]["getTabBadgeAtom"];
updateObjectMeta: WaveEnv["tab"]["updateObjectMeta"];
updateTabName: WaveEnv["tab"]["updateTabName"];
recordTEvent: WaveEnv["tab"]["recordTEvent"];
refocusNode: WaveEnv["tab"]["refocusNode"];
};
showContextMenu: WaveEnv["showContextMenu"];
};

function TabBadges({ badges, flagColor }: TabBadgesProps) {
const flagBadgeId = useMemo(() => uuidv7(), []);
const allBadges = useMemo(() => {
Expand Down Expand Up @@ -106,7 +120,10 @@ const TabV = forwardRef<HTMLDivElement, TabVProps>((props, ref) => {

useEffect(() => {
setOriginalName(tabName);
}, [tabName]);
if (!isEditable && editableRef.current != null) {
editableRef.current.innerText = tabName;
}
}, [isEditable, tabName]);

useEffect(() => {
return () => {
Expand Down Expand Up @@ -249,11 +266,15 @@ const FlagColors: { label: string; value: string }[] = [
{ label: "Orange", value: "#FF9500" },
{ label: "Yellow", value: "#FFE900" },
];
const RefocusDelayMs = 10;

function buildTabContextMenu(
id: string,
renameRef: React.RefObject<(() => void) | null>,
onClose: (event: React.MouseEvent<HTMLButtonElement, MouseEvent> | null) => void
onClose: (event: React.MouseEvent<HTMLButtonElement, MouseEvent> | null) => void,
env: TabEnv,
currentFlagColor: string | null,
fullConfig: FullConfigType
): ContextMenuItem[] {
const menu: ContextMenuItem[] = [];
menu.push(
Expand All @@ -265,23 +286,21 @@ function buildTabContextMenu(
{ type: "separator" }
);
const tabORef = makeORef("tab", id);
const currentFlagColor = globalStore.get(getOrefMetaKeyAtom(tabORef, "tab:flagcolor")) ?? null;
const flagSubmenu: ContextMenuItem[] = [
{
label: "None",
type: "checkbox",
checked: currentFlagColor == null,
click: () => fireAndForget(() => ObjectService.UpdateObjectMeta(tabORef, { "tab:flagcolor": null })),
click: () => fireAndForget(() => env.tab.updateObjectMeta(tabORef, { "tab:flagcolor": null })),
},
...FlagColors.map((fc) => ({
label: fc.label,
type: "checkbox" as const,
checked: currentFlagColor === fc.value,
click: () => fireAndForget(() => ObjectService.UpdateObjectMeta(tabORef, { "tab:flagcolor": fc.value })),
click: () => fireAndForget(() => env.tab.updateObjectMeta(tabORef, { "tab:flagcolor": fc.value })),
})),
];
menu.push({ label: "Flag Tab", type: "submenu", submenu: flagSubmenu }, { type: "separator" });
const fullConfig = globalStore.get(atoms.fullConfigAtom);
const bgPresets: string[] = [];
for (const key in fullConfig?.presets ?? {}) {
if (key.startsWith("bg@") && fullConfig.presets[key] != null) {
Expand All @@ -303,9 +322,9 @@ function buildTabContextMenu(
label: preset["display:name"] ?? presetName,
click: () =>
fireAndForget(async () => {
await ObjectService.UpdateObjectMeta(oref, preset);
RpcApi.ActivityCommand(TabRpcClient, { settabtheme: 1 }, { noresponse: true });
recordTEvent("action:settabtheme");
await env.tab.updateObjectMeta(oref, preset);
env.rpc.ActivityCommand(TabRpcClient, { settabtheme: 1 }, { noresponse: true });
env.tab.recordTEvent("action:settabtheme");
}),
});
}
Expand All @@ -330,8 +349,10 @@ interface TabProps {

const TabInner = forwardRef<HTMLDivElement, TabProps>((props, ref) => {
const { id, active, showDivider, isDragging, tabWidth, isNew, onLoaded, onSelect, onClose, onDragStart } = props;
const env = useWaveEnv<TabEnv>();
const [tabData, _] = useWaveObjectValue<Tab>(makeORef("tab", id));
const badges = useAtomValue(getTabBadgeAtom(id));
const badges = useAtomValue(env.tab.getTabBadgeAtom(id));
const fullConfig = useAtomValue(env.atoms.fullConfigAtom);

const rawFlagColor = tabData?.meta?.["tab:flagcolor"];
let flagColor: string | null = null;
Expand Down Expand Up @@ -361,18 +382,18 @@ const TabInner = forwardRef<HTMLDivElement, TabProps>((props, ref) => {
const handleContextMenu = useCallback(
(e: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
e.preventDefault();
const menu = buildTabContextMenu(id, renameRef, onClose);
ContextMenuModel.getInstance().showContextMenu(menu, e);
const menu = buildTabContextMenu(id, renameRef, onClose, env, rawFlagColor ?? null, fullConfig);
env.showContextMenu(menu, e);
},
[id, onClose]
[env, fullConfig, id, onClose, rawFlagColor]
);

const handleRename = useCallback(
(newName: string) => {
fireAndForget(() => ObjectService.UpdateTabName(id, newName));
setTimeout(() => refocusNode(null), 10);
fireAndForget(() => env.tab.updateTabName(id, newName));
setTimeout(() => env.tab.refocusNode(null), RefocusDelayMs);
},
[id]
[env, id]
);

return (
Expand Down
7 changes: 7 additions & 0 deletions frontend/app/waveenv/waveenv.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,13 @@ export type WaveEnv = {
configAtoms: ConfigAtoms;
isDev: () => boolean;
atoms: GlobalAtomsType;
tab: {
getTabBadgeAtom: (tabId: string) => Atom<Badge[]>;
updateObjectMeta: (oref: string, meta: MetaType) => Promise<void>;
updateTabName: (tabId: string, name: string) => Promise<void>;
recordTEvent: (event: string, props?: TEventProps) => void;
refocusNode: (blockId: string) => void;
};
createBlock: (blockDef: BlockDef, magnified?: boolean, ephemeral?: boolean) => Promise<string>;
showContextMenu: (menu: ContextMenuItem[], e: React.MouseEvent) => void;
};
Expand Down
11 changes: 10 additions & 1 deletion frontend/app/waveenv/waveenvimpl.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
// Copyright 2026, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0

import { atoms, createBlock, getSettingsKeyAtom, isDev } from "@/app/store/global";
import { getTabBadgeAtom } from "@/app/store/badge";
import { atoms, createBlock, getSettingsKeyAtom, isDev, recordTEvent, refocusNode } from "@/app/store/global";
import { ContextMenuModel } from "@/app/store/contextmenu";
import { ObjectService } from "@/app/store/services";
import { RpcApi } from "@/app/store/wshclientapi";
import { WaveEnv } from "@/app/waveenv/waveenv";

Expand All @@ -19,6 +21,13 @@ export function makeWaveEnvImpl(): WaveEnv {
configAtoms,
isDev,
atoms,
tab: {
getTabBadgeAtom,
updateObjectMeta: (oref, meta) => ObjectService.UpdateObjectMeta(oref, meta),
updateTabName: (tabId, name) => ObjectService.UpdateTabName(tabId, name),
recordTEvent,
refocusNode,
},
createBlock,
showContextMenu: (menu: ContextMenuItem[], e: React.MouseEvent) => {
ContextMenuModel.getInstance().showContextMenu(menu, e);
Expand Down
15 changes: 15 additions & 0 deletions frontend/preview/mock/mockwaveenv.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,21 @@ export function makeMockWaveEnv(ids?: MockIds): WaveEnv {
configAtoms: makeMockConfigAtoms(),
isDev: () => true,
atoms: makeMockGlobalAtoms(ids),
tab: {
getTabBadgeAtom: () => atom([]),
updateObjectMeta: async (oref, meta) => {
console.log("[mock updateObjectMeta]", oref, meta);
},
updateTabName: async (tabId, name) => {
console.log("[mock updateTabName]", tabId, name);
},
recordTEvent: (event, props) => {
console.log("[mock recordTEvent]", event, props);
},
refocusNode: (blockId) => {
console.log("[mock refocusNode]", blockId);
},
},
createBlock: (blockDef: BlockDef, magnified?: boolean, ephemeral?: boolean) => {
console.log("[mock createBlock]", blockDef, { magnified, ephemeral });
return Promise.resolve(crypto.randomUUID());
Expand Down
158 changes: 122 additions & 36 deletions frontend/preview/previews/tab.preview.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,32 @@
// Copyright 2026, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0

import { TabV } from "@/app/tab/tab";
import { Tab, TabEnv } from "@/app/tab/tab";
import { globalStore } from "@/app/store/jotaiStore";
import { getWaveObjectAtom, makeORef, mockObjectForPreview, setObjectValue } from "@/app/store/wos";
import { WaveEnv, WaveEnvContext, useWaveEnv } from "@/app/waveenv/waveenv";
import { atom, Atom, Provider } from "jotai";
import { useEffect, useRef, useState } from "react";
import { makeMockRpc } from "../mock/mockwaveenv";

const TAB_WIDTH = 130;
const TAB_HEIGHT = 26;
const EmptyBadgeAtom = atom([] as Badge[]);
const fullConfigAtom = atom<FullConfigType>({
settings: {},
presets: {
"bg@sunset": {
"display:name": "Sunset",
"display:order": 1,
"bg:opacity": 0.85,
},
"bg@aurora": {
"display:name": "Aurora",
"display:order": 2,
"bg:opacity": 0.65,
},
},
} as FullConfigType);

interface PreviewTabEntry {
tabId: string;
Expand Down Expand Up @@ -57,10 +78,78 @@ const tabDefs: PreviewTabEntry[] = [
},
];

function makePreviewTab(entry: PreviewTabEntry): Tab {
const meta = entry.flagColor == null ? {} : { "tab:flagcolor": entry.flagColor };
return {
otype: "tab",
oid: entry.tabId,
version: 1,
name: entry.tabName,
blockids: [],
meta,
} as Tab;
}

function makeTabEnv(baseEnv: WaveEnv): TabEnv {
const tabs = new Map<string, Tab>();
const badgeAtoms = new Map<string, Atom<Badge[]>>();
for (const tabDef of tabDefs) {
const tab = makePreviewTab(tabDef);
const oref = makeORef("tab", tabDef.tabId);
tabs.set(tabDef.tabId, tab);
badgeAtoms.set(tabDef.tabId, atom(tabDef.badges ?? []));
mockObjectForPreview(oref, tab);
getWaveObjectAtom<Tab>(oref);
setObjectValue(tab);
}

const updatePreviewTab = (tabId: string, updateFn: (tab: Tab) => Tab) => {
const tab = tabs.get(tabId);
if (tab == null) {
return;
}
const nextTab = updateFn(tab);
tabs.set(tabId, nextTab);
mockObjectForPreview(makeORef("tab", tabId), nextTab);
setObjectValue(nextTab);
};

return {
...baseEnv,
rpc: makeMockRpc({
ActivityCommand: () => Promise.resolve(null),
}),
atoms: {
...baseEnv.atoms,
fullConfigAtom,
},
tab: {
...baseEnv.tab,
getTabBadgeAtom: (tabId) => badgeAtoms.get(tabId) ?? EmptyBadgeAtom,
updateObjectMeta: async (oref, meta) => {
const tabId = oref.split(":")[1];
updatePreviewTab(tabId, (tab) => {
const nextMeta = { ...(tab.meta ?? {}), ...meta };
if (nextMeta["tab:flagcolor"] == null) {
delete nextMeta["tab:flagcolor"];
}
return { ...tab, version: tab.version + 1, meta: nextMeta };
});
},
updateTabName: async (tabId, name) => {
updatePreviewTab(tabId, (tab) => ({ ...tab, version: tab.version + 1, name }));
},
recordTEvent: (event, props) => {
console.log("[preview recordTEvent]", event, props);
},
refocusNode: () => {},
},
};
}

export function TabPreview() {
const [tabNames, setTabNames] = useState<Record<string, string>>(
Object.fromEntries(tabDefs.map((t) => [t.tabId, t.tabName]))
);
const baseEnv = useWaveEnv();
const envRef = useRef(makeTabEnv(baseEnv));
const [activeTabId, setActiveTabId] = useState<string>(tabDefs.find((t) => t.active)?.tabId ?? tabDefs[0].tabId);
const tabRefs = useRef<Record<string, HTMLDivElement | null>>({});

Expand All @@ -77,37 +166,34 @@ export function TabPreview() {
}, []);

return (
<div style={{ position: "relative", width: TAB_WIDTH * tabDefs.length, height: TAB_HEIGHT }}>
{tabDefs.map((tab, index) => {
const activeIndex = tabDefs.findIndex((t) => t.tabId === activeTabId);
const isActive = tab.tabId === activeTabId;
const showDivider = index !== 0 && !isActive && index !== activeIndex + 1;
return (
<TabV
key={tab.tabId}
ref={(el) => {
tabRefs.current[tab.tabId] = el;
}}
tabId={tab.tabId}
tabName={tabNames[tab.tabId]}
active={isActive}
showDivider={showDivider}
isDragging={false}
tabWidth={TAB_WIDTH}
isNew={false}
badges={tab.badges ?? null}
flagColor={tab.flagColor ?? null}
onClick={() => setActiveTabId(tab.tabId)}
onClose={() => console.log("close", tab.tabId)}
onDragStart={() => {}}
onContextMenu={() => {}}
onRename={(newName) => {
console.log("rename", tab.tabId, newName);
setTabNames((prev) => ({ ...prev, [tab.tabId]: newName }));
}}
/>
);
})}
</div>
<Provider store={globalStore}>
<WaveEnvContext.Provider value={envRef.current}>
<div style={{ position: "relative", width: TAB_WIDTH * tabDefs.length, height: TAB_HEIGHT }}>
{tabDefs.map((tab, index) => {
const activeIndex = tabDefs.findIndex((t) => t.tabId === activeTabId);
const isActive = tab.tabId === activeTabId;
const showDivider = index !== 0 && !isActive && index !== activeIndex + 1;
return (
<Tab
key={tab.tabId}
ref={(el) => {
tabRefs.current[tab.tabId] = el;
}}
id={tab.tabId}
active={isActive}
showDivider={showDivider}
isDragging={false}
tabWidth={TAB_WIDTH}
isNew={false}
onSelect={() => setActiveTabId(tab.tabId)}
onClose={() => console.log("close", tab.tabId)}
onDragStart={() => {}}
onLoaded={() => {}}
/>
);
})}
</div>
</WaveEnvContext.Provider>
</Provider>
);
}