diff --git a/packages/app/src/components/titlebar-tab-color.tsx b/packages/app/src/components/titlebar-tab-color.tsx new file mode 100644 index 00000000000..bce22e43cb6 --- /dev/null +++ b/packages/app/src/components/titlebar-tab-color.tsx @@ -0,0 +1,145 @@ +import { createEffect, For, onCleanup, Show, type JSX } from "solid-js" +import { createStore } from "solid-js/store" +import { Portal } from "solid-js/web" +import { makeEventListener } from "@solid-primitives/event-listener" +import { useLanguage } from "@/context/language" +import { normalizeTabColor } from "@/context/tab-color" + +const colors = [ + { name: "tab.color.blue", value: "#4c8dff" }, + { name: "tab.color.cyan", value: "#35b5c5" }, + { name: "tab.color.green", value: "#4fbf80" }, + { name: "tab.color.yellow", value: "#e3c243" }, + { name: "tab.color.orange", value: "#f29d49" }, + { name: "tab.color.red", value: "#ef5b5b" }, + { name: "tab.color.pink", value: "#e65fa8" }, + { name: "tab.color.purple", value: "#9b6cff" }, +] as const + +export function TabColorMenu(props: { + color?: string + onColorChange: (color: string | undefined) => void + children: JSX.Element +}) { + const language = useLanguage() + const [menu, setMenu] = createStore({ open: false, x: 0, y: 0 }) + let menuRef: HTMLDivElement | undefined + + const choose = (color: string | undefined) => { + props.onColorChange(color) + setMenu("open", false) + } + + createEffect(() => { + if (!menu.open) return + requestAnimationFrame(() => menuRef?.querySelector("button")?.focus()) + const cleanups = [ + makeEventListener(document, "pointerdown", (event) => { + if (event.target instanceof Node && menuRef?.contains(event.target)) return + setMenu("open", false) + }), + makeEventListener(document, "keydown", (event) => { + if (event.key === "Escape") setMenu("open", false) + }), + ] + onCleanup(() => cleanups.forEach((cleanup) => cleanup())) + }) + + return ( + <> +
{ + event.preventDefault() + event.stopPropagation() + const rect = event.currentTarget.getBoundingClientRect() + setMenu({ + open: true, + x: Math.max(4, Math.min(event.clientX || rect.left, window.innerWidth - 168)), + y: Math.max(4, Math.min(event.clientY || rect.bottom, window.innerHeight - 136)), + }) + }} + > + {props.children} +
+ + + + ) } @@ -285,6 +288,7 @@ export function DraftTabItem(props: { dragging?: boolean pressed?: boolean hidden?: boolean + color?: string }) { const closeTab = (event: MouseEvent) => { event.preventDefault() @@ -346,6 +350,7 @@ export function DraftTabItem(props: { aria-label="Close tab" /> + ) } diff --git a/packages/app/src/components/titlebar-tab-strip.tsx b/packages/app/src/components/titlebar-tab-strip.tsx index 3a8cf86e010..e0f4f40a185 100644 --- a/packages/app/src/components/titlebar-tab-strip.tsx +++ b/packages/app/src/components/titlebar-tab-strip.tsx @@ -16,6 +16,7 @@ import { createResizeObserver } from "@solid-primitives/resize-observer" import { tabHref, tabKey, type SessionTab, type Tab } from "@/context/tabs" import { ServerConnection } from "@/context/server" import { DraftTabItem, TabNavItem } from "@/components/titlebar-tab-nav" +import { TabColorMenu } from "@/components/titlebar-tab-color" import { useGlobal, type ServerCtx } from "@/context/global" import { useLanguage } from "@/context/language" import { useCommand } from "@/context/command" @@ -56,6 +57,7 @@ function SessionTabSlot(props: { onPointerDown: (event: PointerEvent) => void onNavigate: (element: HTMLDivElement) => void onClose: () => void + onColorChange: (color: string | undefined) => void }) { const tabs = useTabs() let ref!: HTMLDivElement @@ -110,30 +112,33 @@ function SessionTabSlot(props: { }} onPointerDown={props.onPointerDown} > - { - const value = session() - const ctx = props.serverCtx() - if (value && ctx) ctx.sync.session.remember({ ...value, title }) - }} - onTitleChangeFailed={(title) => { - const value = session() - const ctx = props.serverCtx() - if (value && ctx) ctx.sync.session.remember({ ...value, title }) - }} - onNavigate={() => props.onNavigate(ref)} - onClose={props.onClose} - active={props.active()} - activeServer={props.tab.server === props.activeServerKey} - forceTruncate={props.forceTruncate} - suppressNavigation={props.suppressNavigation} - pressed={props.pressed()} - hidden={props.dragged()} - /> + + { + const value = session() + const ctx = props.serverCtx() + if (value && ctx) ctx.sync.session.remember({ ...value, title }) + }} + onTitleChangeFailed={(title) => { + const value = session() + const ctx = props.serverCtx() + if (value && ctx) ctx.sync.session.remember({ ...value, title }) + }} + onNavigate={() => props.onNavigate(ref)} + onClose={props.onClose} + active={props.active()} + activeServer={props.tab.server === props.activeServerKey} + forceTruncate={props.forceTruncate} + color={props.tab.color} + suppressNavigation={props.suppressNavigation} + pressed={props.pressed()} + hidden={props.dragged()} + /> + ) } @@ -145,6 +150,7 @@ export function TitlebarTabStrip(props: { forceTruncate: boolean onNavigate: (tab: Tab, el?: HTMLDivElement) => void onClose: (tab: Tab) => void + onColorChange: (tab: Tab, color: string | undefined) => void onReorder: (keys: string[]) => void onOverflowChange: (overflowing: boolean) => void }) { @@ -498,6 +504,7 @@ export function TitlebarTabStrip(props: { }} onNavigate={(element) => props.onNavigate(tab, element)} onClose={() => props.onClose(tab)} + onColorChange={(color) => props.onColorChange(tab, color)} /> ) } @@ -515,17 +522,20 @@ export function TitlebarTabStrip(props: { onPointerDown(id, event) }} > - props.onNavigate(tab, ref)} - onClose={() => props.onClose(tab)} - suppressNavigation={() => suppressNavigation()} - active={props.currentTab() === tab} - pressed={pressedId() === id} - hidden={dragged()} - /> + props.onColorChange(tab, color)}> + props.onNavigate(tab, ref)} + onClose={() => props.onClose(tab)} + color={tab.color} + suppressNavigation={() => suppressNavigation()} + active={props.currentTab() === tab} + pressed={pressedId() === id} + hidden={dragged()} + /> + ) }} diff --git a/packages/app/src/components/titlebar.css b/packages/app/src/components/titlebar.css index 43104c1530d..e93e1a274cb 100644 --- a/packages/app/src/components/titlebar.css +++ b/packages/app/src/components/titlebar.css @@ -6,6 +6,42 @@ outline: none; } +.titlebar-tab-color-menu { + min-width: 152px; + border-radius: 6px; + background: var(--v2-background-bg-layer-01); + box-shadow: var(--v2-elevation-floating); + + [data-slot="context-menu-group-label"] { + color: var(--v2-text-text-faint); + } + + [data-slot="context-menu-item"] { + color: var(--v2-text-text-base); + + &:hover, + &[data-highlighted] { + background: var(--v2-overlay-simple-overlay-hover); + } + } + + [data-tab-color] { + width: 20px; + height: 20px; + padding: 3px; + border: 1px solid transparent; + + &:is(:hover, :focus-visible, [data-checked]) { + border-color: var(--v2-border-border-strong); + background: transparent; + } + } + + [data-slot="context-menu-separator"] { + border-color: var(--v2-border-border-muted); + } +} + [data-slot="titlebar-v2"] [data-component="icon-button-v2"][data-variant="ghost-muted"]:is(:focus-visible, [data-state="focus"]):not( :disabled diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx index 186e5756252..4cbf0af008a 100644 --- a/packages/app/src/components/titlebar.tsx +++ b/packages/app/src/components/titlebar.tsx @@ -458,6 +458,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) { const index = tabsStore.findIndex((item) => tabKey(item) === tabKey(tab)) if (index !== -1) tabsStoreActions.removeTab(index) }} + onColorChange={(tab, color) => tabsStoreActions.setColor(tab, color)} onReorder={(keys) => tabsStoreActions.reorder(keys)} /> diff --git a/packages/app/src/context/tab-color.ts b/packages/app/src/context/tab-color.ts new file mode 100644 index 00000000000..5f5d0bb6244 --- /dev/null +++ b/packages/app/src/context/tab-color.ts @@ -0,0 +1,4 @@ +export function normalizeTabColor(color: string | undefined) { + if (!color || !/^#[0-9a-f]{6}$/i.test(color)) return + return color.toLowerCase() +} diff --git a/packages/app/src/context/tabs.test.ts b/packages/app/src/context/tabs.test.ts index 27525af63d9..54408977174 100644 --- a/packages/app/src/context/tabs.test.ts +++ b/packages/app/src/context/tabs.test.ts @@ -1,6 +1,21 @@ import { describe, expect, test } from "bun:test" import { createRoot, getOwner, onCleanup } from "solid-js" import { createTabMemory } from "./tab-memory" +import { normalizeTabColor } from "./tab-color" + +describe("tab color", () => { + test("normalizes valid hex colors", () => { + expect(normalizeTabColor("#4C8DFF")).toBe("#4c8dff") + expect(normalizeTabColor("#123abc")).toBe("#123abc") + }) + + test("rejects invalid color values", () => { + expect(normalizeTabColor(undefined)).toBeUndefined() + expect(normalizeTabColor("red")).toBeUndefined() + expect(normalizeTabColor("#fff")).toBeUndefined() + expect(normalizeTabColor("#12345678")).toBeUndefined() + }) +}) describe("tab memory", () => { test("keeps state until its tab is removed", () => { diff --git a/packages/app/src/context/tabs.tsx b/packages/app/src/context/tabs.tsx index c9f7ab88c68..09f922a4d1a 100644 --- a/packages/app/src/context/tabs.tsx +++ b/packages/app/src/context/tabs.tsx @@ -10,11 +10,13 @@ import { uuid } from "@/utils/uuid" import { SessionTabsRemovedDetail } from "@/components/titlebar-session-events" import { sessionHref } from "@/utils/session-route" import { createTabMemory } from "./tab-memory" +import { normalizeTabColor } from "./tab-color" export type SessionTab = { type: "session" server: ServerConnection.Key sessionId: string + color?: string } export type DraftTab = { @@ -23,6 +25,7 @@ export type DraftTab = { server: ServerConnection.Key directory: string worktree?: string + color?: string } export type Tab = SessionTab | DraftTab @@ -156,6 +159,21 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ }), ) }, + setColor(tab: Tab, color: string | undefined) { + const key = tabKey(tab) + const next = normalizeTabColor(color) + setStore( + produce((tabs) => { + const current = tabs.find((item) => tabKey(item) === key) + if (!current) return + if (next) { + current.color = next + return + } + delete current.color + }), + ) + }, draft(draftID: string) { const tab = store.find((item) => item.type === "draft" && item.draftID === draftID) if (!tab || tab.type !== "draft") throw new Error(`Draft not found: ${draftID}`) @@ -182,7 +200,8 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ // and fall back home. Navigate to the new session first so we leave /new-session // before the draft is removed from the store. const active = location.pathname === "/new-session" && location.query.draftId === draftID - const next = { type: "session" as const, ...session } + const color = store.find((tab) => tab.type === "draft" && tab.draftID === draftID)?.color + const next = { type: "session" as const, ...session, color } startTransition(() => { setStore( produce((tabs) => { diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index f9cbcb961a3..9fdbfd33f8a 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -48,6 +48,17 @@ export const dict = { "command.session.new": "New session", "command.file.open": "Open file", "command.tab.close": "Close tab", + "tab.color.label": "Tab color", + "tab.color.custom": "Custom color", + "tab.color.none": "Remove color", + "tab.color.blue": "Blue", + "tab.color.cyan": "Cyan", + "tab.color.green": "Green", + "tab.color.yellow": "Yellow", + "tab.color.orange": "Orange", + "tab.color.red": "Red", + "tab.color.pink": "Pink", + "tab.color.purple": "Purple", "command.context.addSelection": "Add selection to context", "command.context.addSelection.description": "Add selected lines from the current file", "command.input.focus": "Focus input",