diff --git a/frontend/app/tab/tab.tsx b/frontend/app/tab/tab.tsx index b86d06120d..6b3679bb37 100644 --- a/frontend/app/tab/tab.tsx +++ b/frontend/app/tab/tab.tsx @@ -1,18 +1,18 @@ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 -import { getTabBadgeAtom, sortBadgesForTab } from "@/app/store/badge"; +import { getTabBadgeAtom } from "@/app/store/badge"; import { getOrefMetaKeyAtom, globalStore, recordTEvent, refocusNode } from "@/app/store/global"; import { TabRpcClient } from "@/app/store/wshrpcutil"; import { WaveEnv, WaveEnvSubset, useWaveEnv } from "@/app/waveenv/waveenv"; import { Button } from "@/element/button"; import { validateCssColor } from "@/util/color-validator"; -import { fireAndForget, makeIconClass } from "@/util/util"; +import { fireAndForget } 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 { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useRef, useState } from "react"; import { makeORef } from "../store/wos"; +import { TabBadges } from "./tabbadges"; import "./tab.scss"; type TabEnv = WaveEnvSubset<{ @@ -47,47 +47,6 @@ interface TabVProps { renameRef?: React.RefObject<(() => void) | null>; } -interface TabBadgesProps { - badges?: Badge[] | null; - flagColor?: string | null; -} - -function TabBadges({ badges, flagColor }: TabBadgesProps) { - const flagBadgeId = useMemo(() => uuidv7(), []); - const allBadges = useMemo(() => { - const base = badges ?? []; - if (!flagColor) { - return base; - } - const flagBadge: Badge = { icon: "flag", color: flagColor, priority: 0, badgeid: flagBadgeId }; - return sortBadgesForTab([...base, flagBadge]); - }, [badges, flagColor, flagBadgeId]); - if (!allBadges[0]) { - return null; - } - const firstBadge = allBadges[0]; - const extraBadges = allBadges.slice(1, 3); - return ( -
- - {extraBadges.length > 0 && ( -
- {extraBadges.map((badge, idx) => ( -
- ))} -
- )} -
- ); -} - const TabV = forwardRef((props, ref) => { const { tabId, diff --git a/frontend/app/tab/tabbadges.tsx b/frontend/app/tab/tabbadges.tsx new file mode 100644 index 0000000000..0e56cc89af --- /dev/null +++ b/frontend/app/tab/tabbadges.tsx @@ -0,0 +1,52 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { sortBadgesForTab } from "@/app/store/badge"; +import { cn, makeIconClass } from "@/util/util"; +import { useMemo } from "react"; +import { v7 as uuidv7 } from "uuid"; + +export interface TabBadgesProps { + badges?: Badge[] | null; + flagColor?: string | null; + className?: string; +} + +const DefaultClassName = + "pointer-events-none absolute left-[4px] top-1/2 z-[3] flex h-[20px] w-[20px] -translate-y-1/2 items-center justify-center px-[2px] py-[1px]"; + +export function TabBadges({ badges, flagColor, className }: TabBadgesProps) { + const flagBadgeId = useMemo(() => uuidv7(), []); + const allBadges = useMemo(() => { + const base = badges ?? []; + if (!flagColor) { + return base; + } + const flagBadge: Badge = { icon: "flag", color: flagColor, priority: 0, badgeid: flagBadgeId }; + return sortBadgesForTab([...base, flagBadge]); + }, [badges, flagColor, flagBadgeId]); + if (!allBadges[0]) { + return null; + } + const firstBadge = allBadges[0]; + const extraBadges = allBadges.slice(1, 3); + return ( +
+ + {extraBadges.length > 0 && ( +
+ {extraBadges.map((badge, idx) => ( +
+ ))} +
+ )} +
+ ); +} diff --git a/frontend/app/tab/vtab.test.tsx b/frontend/app/tab/vtab.test.tsx new file mode 100644 index 0000000000..b995b6a72a --- /dev/null +++ b/frontend/app/tab/vtab.test.tsx @@ -0,0 +1,63 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { renderToStaticMarkup } from "react-dom/server"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { VTab, VTabItem } from "./vtab"; + +const OriginalCss = globalThis.CSS; +const HexColorRegex = /^#([\da-f]{3}|[\da-f]{4}|[\da-f]{6}|[\da-f]{8})$/i; + +function renderVTab(tab: VTabItem): string { + return renderToStaticMarkup( + null} + onDragStart={() => null} + onDragOver={() => null} + onDrop={() => null} + onDragEnd={() => null} + /> + ); +} + +describe("VTab badges", () => { + beforeAll(() => { + globalThis.CSS = { + supports: (_property: string, value: string) => HexColorRegex.test(value), + } as typeof CSS; + }); + + afterAll(() => { + globalThis.CSS = OriginalCss; + }); + + it("renders shared badges and a validated flag badge", () => { + const markup = renderVTab({ + id: "tab-1", + name: "Build Logs", + badges: [{ badgeid: "badge-1", icon: "bell", color: "#f59e0b", priority: 2 }], + flagColor: "#429DFF", + }); + + expect(markup).toContain("#429DFF"); + expect(markup).toContain("#f59e0b"); + expect(markup).toContain("rounded-full"); + }); + + it("ignores invalid flag colors", () => { + const markup = renderVTab({ + id: "tab-2", + name: "Deploy", + badges: [{ badgeid: "badge-2", icon: "bell", color: "#4ade80", priority: 2 }], + flagColor: "definitely-not-a-color", + }); + + expect(markup).not.toContain("definitely-not-a-color"); + expect(markup).not.toContain("fa-flag"); + expect(markup).toContain("#4ade80"); + }); +}); diff --git a/frontend/app/tab/vtab.tsx b/frontend/app/tab/vtab.tsx index e5689de8bb..b6c3a29a54 100644 --- a/frontend/app/tab/vtab.tsx +++ b/frontend/app/tab/vtab.tsx @@ -1,9 +1,10 @@ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 -import { makeIconClass } from "@/util/util"; +import { validateCssColor } from "@/util/color-validator"; import { cn } from "@/util/util"; import { useCallback, useEffect, useRef, useState } from "react"; +import { TabBadges } from "./tabbadges"; const RenameFocusDelayMs = 50; @@ -11,6 +12,8 @@ export interface VTabItem { id: string; name: string; badge?: Badge | null; + badges?: Badge[] | null; + flagColor?: string | null; } interface VTabProps { @@ -44,6 +47,18 @@ export function VTab({ const [isEditable, setIsEditable] = useState(false); const editableRef = useRef(null); const editableTimeoutRef = useRef(null); + const badges = tab.badges ?? (tab.badge ? [tab.badge] : null); + + const rawFlagColor = tab.flagColor; + let flagColor: string | null = null; + if (rawFlagColor) { + try { + validateCssColor(rawFlagColor); + flagColor = rawFlagColor; + } catch { + flagColor = null; + } + } useEffect(() => { setOriginalName(tab.name); @@ -139,11 +154,11 @@ export function VTab({ isDragging && "opacity-50" )} > - {tab.badge && ( - - - - )} +