mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-23 18:55:37 -04:00
feat(app): add custom tab colors
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)}>
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
export function normalizeTabColor(color: string | undefined) {
|
||||
if (!color || !/^#[0-9a-f]{6}$/i.test(color)) return
|
||||
return color.toLowerCase()
|
||||
}
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user