소스 검색

feat(app): add custom tab colors

Test 1 개월 전
부모
커밋
59320d2865

+ 145 - 0
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<HTMLElement>("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 (
+    <>
+      <div
+        class="flex w-full min-w-0"
+        onContextMenu={(event) => {
+          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}
+      </div>
+      <Show when={menu.open}>
+        <Portal>
+          <div
+            ref={menuRef}
+            role="menu"
+            data-component="context-menu-content"
+            class="titlebar-tab-color-menu fixed z-[100]"
+            style={{ left: `${menu.x}px`, top: `${menu.y}px` }}
+          >
+            <div data-slot="context-menu-group-label">{language.t("tab.color.label")}</div>
+            <div role="group" aria-label={language.t("tab.color.label")} class="grid grid-cols-5 gap-1 px-2 pb-2">
+              <For each={colors}>
+                {(color) => (
+                  <button
+                    type="button"
+                    role="menuitemradio"
+                    data-tab-color={color.value}
+                    data-checked={normalizeTabColor(props.color) === color.value ? "" : undefined}
+                    aria-label={language.t(color.name)}
+                    aria-checked={normalizeTabColor(props.color) === color.value}
+                    class="relative size-5 rounded-full border border-transparent p-[3px] outline-none hover:border-v2-border-border-strong focus-visible:border-v2-border-border-strong data-[checked]:border-v2-border-border-strong"
+                    onClick={() => choose(color.value)}
+                  >
+                    <span class="block size-full rounded-full" style={{ "background-color": color.value }} />
+                  </button>
+                )}
+              </For>
+              <label
+                class="relative flex size-5 cursor-pointer items-center justify-center rounded-full border border-transparent p-[3px] hover:border-v2-border-border-strong has-[:focus-visible]:border-v2-border-border-strong"
+                title={language.t("tab.color.custom")}
+              >
+                <span
+                  class="block size-full rounded-full"
+                  style={{
+                    background:
+                      "conic-gradient(#ef5b5b, #e3c243, #4fbf80, #35b5c5, #4c8dff, #9b6cff, #e65fa8, #ef5b5b)",
+                  }}
+                />
+                <input
+                  data-tab-color-custom
+                  type="color"
+                  aria-label={language.t("tab.color.custom")}
+                  value={normalizeTabColor(props.color) ?? colors[0].value}
+                  class="absolute inset-0 cursor-pointer opacity-0"
+                  onInput={(event) => choose(event.currentTarget.value)}
+                />
+              </label>
+            </div>
+            <Show when={normalizeTabColor(props.color)}>
+              <div data-slot="context-menu-separator" />
+              <button
+                type="button"
+                role="menuitem"
+                data-slot="context-menu-item"
+                class="w-full"
+                onClick={() => choose(undefined)}
+              >
+                <span data-slot="context-menu-item-label">{language.t("tab.color.none")}</span>
+              </button>
+            </Show>
+          </div>
+        </Portal>
+      </Show>
+    </>
+  )
+}
+
+export function TabColorPill(props: { color?: string }) {
+  return (
+    <Show when={normalizeTabColor(props.color)}>
+      {(color) => (
+        <span
+          data-slot="tab-color"
+          aria-hidden="true"
+          class="pointer-events-none absolute bottom-px left-2 right-2 h-0.5 rounded-full"
+          style={{ "background-color": color() }}
+        />
+      )}
+    </Show>
+  )
+}

+ 5 - 0
packages/app/src/components/titlebar-tab-nav.tsx

@@ -11,6 +11,7 @@ import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar"
 import { showToast } from "@/utils/toast"
 import type { Session } from "@opencode-ai/sdk/v2"
 import { canOpenTabRename, forwardTabRef } from "./titlebar-tab-gesture"
+import { TabColorPill } from "./titlebar-tab-color"
 import "./titlebar-tab-nav.css"
 
 export function TabNavItem(props: {
@@ -29,6 +30,7 @@ export function TabNavItem(props: {
   dragging?: boolean
   pressed?: boolean
   hidden?: boolean
+  color?: string
 }) {
   const language = useLanguage()
   const [editing, setEditing] = createSignal(false)
@@ -270,6 +272,7 @@ export function TabNavItem(props: {
           icon={<IconV2 name="xmark-small" />}
         />
       </div>
+      <TabColorPill color={props.color} />
     </div>
   )
 }
@@ -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"
         />
       </div>
+      <TabColorPill color={props.color} />
     </div>
   )
 }

+ 45 - 35
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}
     >
-      <TabNavItem
-        ref={ref}
-        href={tabHref(props.tab)}
-        server={props.tab.server}
-        session={session}
-        onTitleChange={(title) => {
-          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()}
-      />
+      <TabColorMenu color={props.tab.color} onColorChange={props.onColorChange}>
+        <TabNavItem
+          ref={ref}
+          href={tabHref(props.tab)}
+          server={props.tab.server}
+          session={session}
+          onTitleChange={(title) => {
+            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()}
+        />
+      </TabColorMenu>
     </div>
   )
 }
@@ -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)
                     }}
                   >
-                    <DraftTabItem
-                      ref={ref}
-                      href={tabHref(tab)}
-                      title={language.t("command.session.new")}
-                      onNavigate={() => props.onNavigate(tab, ref)}
-                      onClose={() => props.onClose(tab)}
-                      suppressNavigation={() => suppressNavigation()}
-                      active={props.currentTab() === tab}
-                      pressed={pressedId() === id}
-                      hidden={dragged()}
-                    />
+                    <TabColorMenu color={tab.color} onColorChange={(color) => props.onColorChange(tab, color)}>
+                      <DraftTabItem
+                        ref={ref}
+                        href={tabHref(tab)}
+                        title={language.t("command.session.new")}
+                        onNavigate={() => props.onNavigate(tab, ref)}
+                        onClose={() => props.onClose(tab)}
+                        color={tab.color}
+                        suppressNavigation={() => suppressNavigation()}
+                        active={props.currentTab() === tab}
+                        pressed={pressedId() === id}
+                        hidden={dragged()}
+                      />
+                    </TabColorMenu>
                   </div>
                 )
               }}

+ 36 - 0
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

+ 1 - 0
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)}
                 />
                 <Show when={!(creating() && params.dir)}>

+ 4 - 0
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()
+}

+ 15 - 0
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", () => {

+ 20 - 1
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) => {

+ 11 - 0
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",