mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-10 11:39:45 -04:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a4bba9dfa1 | |||
| d867ed02f1 | |||
| 98ba5f0ed6 | |||
| 351832e139 | |||
| 755151cd9e | |||
| 59320d2865 |
@@ -0,0 +1,130 @@
|
|||||||
|
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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -9,6 +9,20 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[data-titlebar-tab][data-tab-color] {
|
||||||
|
&::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
right: 4px;
|
||||||
|
bottom: 0;
|
||||||
|
left: 4px;
|
||||||
|
height: 1px;
|
||||||
|
pointer-events: none;
|
||||||
|
border-radius: 2px 2px 0 0;
|
||||||
|
background: var(--tab-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[data-slot="titlebar-tabs"] {
|
[data-slot="titlebar-tabs"] {
|
||||||
container-type: inline-size;
|
container-type: inline-size;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { projectForSession } from "@/pages/layout/helpers"
|
|||||||
import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar"
|
import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar"
|
||||||
import { showToast } from "@/utils/toast"
|
import { showToast } from "@/utils/toast"
|
||||||
import type { Session } from "@opencode-ai/sdk/v2"
|
import type { Session } from "@opencode-ai/sdk/v2"
|
||||||
|
import { normalizeTabColor } from "@/context/tab-color"
|
||||||
import { canOpenTabRename, forwardTabRef } from "./titlebar-tab-gesture"
|
import { canOpenTabRename, forwardTabRef } from "./titlebar-tab-gesture"
|
||||||
import "./titlebar-tab-nav.css"
|
import "./titlebar-tab-nav.css"
|
||||||
|
|
||||||
@@ -29,6 +30,7 @@ export function TabNavItem(props: {
|
|||||||
dragging?: boolean
|
dragging?: boolean
|
||||||
pressed?: boolean
|
pressed?: boolean
|
||||||
hidden?: boolean
|
hidden?: boolean
|
||||||
|
color?: string
|
||||||
}) {
|
}) {
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const [editing, setEditing] = createSignal(false)
|
const [editing, setEditing] = createSignal(false)
|
||||||
@@ -178,7 +180,9 @@ export function TabNavItem(props: {
|
|||||||
data-slot="titlebar-tab-item"
|
data-slot="titlebar-tab-item"
|
||||||
data-title-overflow={titleOverflowing()}
|
data-title-overflow={titleOverflowing()}
|
||||||
data-editing={editing()}
|
data-editing={editing()}
|
||||||
|
data-tab-color={normalizeTabColor(props.color) ? "" : undefined}
|
||||||
class="group relative flex h-7 w-full min-w-0 max-w-56 select-none flex-row items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-[6px] bg-[var(--tab-bg)] px-1.5 [container-type:inline-size] [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] has-[>a:focus-visible]:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[dragging='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[pressed='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[editing='true']:[--tab-bg:var(--v2-background-bg-layer-02)]"
|
class="group relative flex h-7 w-full min-w-0 max-w-56 select-none flex-row items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-[6px] bg-[var(--tab-bg)] px-1.5 [container-type:inline-size] [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] has-[>a:focus-visible]:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[dragging='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[pressed='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[editing='true']:[--tab-bg:var(--v2-background-bg-layer-02)]"
|
||||||
|
style={{ "--tab-color": normalizeTabColor(props.color) }}
|
||||||
classList={{ invisible: props.hidden }}
|
classList={{ invisible: props.hidden }}
|
||||||
data-active={props.active}
|
data-active={props.active}
|
||||||
data-dragging={props.dragging}
|
data-dragging={props.dragging}
|
||||||
@@ -285,6 +289,7 @@ export function DraftTabItem(props: {
|
|||||||
dragging?: boolean
|
dragging?: boolean
|
||||||
pressed?: boolean
|
pressed?: boolean
|
||||||
hidden?: boolean
|
hidden?: boolean
|
||||||
|
color?: string
|
||||||
}) {
|
}) {
|
||||||
const closeTab = (event: MouseEvent) => {
|
const closeTab = (event: MouseEvent) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
@@ -299,7 +304,9 @@ export function DraftTabItem(props: {
|
|||||||
data-active={props.active}
|
data-active={props.active}
|
||||||
data-dragging={props.dragging}
|
data-dragging={props.dragging}
|
||||||
data-pressed={props.pressed}
|
data-pressed={props.pressed}
|
||||||
|
data-tab-color={normalizeTabColor(props.color) ? "" : undefined}
|
||||||
class="group relative flex h-7 w-full min-w-0 max-w-56 flex-row items-center gap-1.5 overflow-hidden rounded-[6px] bg-[var(--tab-bg)] pl-1.5 pr-8 [container-type:inline-size] whitespace-nowrap [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] has-[>a:focus-visible]:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:has-[>a:focus-visible]:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-overlay-simple-overlay-pressed)] data-[dragging='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[pressed='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true'][data-pressed='true']:[--tab-bg:var(--v2-overlay-simple-overlay-pressed)]"
|
class="group relative flex h-7 w-full min-w-0 max-w-56 flex-row items-center gap-1.5 overflow-hidden rounded-[6px] bg-[var(--tab-bg)] pl-1.5 pr-8 [container-type:inline-size] whitespace-nowrap [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] has-[>a:focus-visible]:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:has-[>a:focus-visible]:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-overlay-simple-overlay-pressed)] data-[dragging='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[pressed='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true'][data-pressed='true']:[--tab-bg:var(--v2-overlay-simple-overlay-pressed)]"
|
||||||
|
style={{ "--tab-color": normalizeTabColor(props.color) }}
|
||||||
classList={{ invisible: props.hidden }}
|
classList={{ invisible: props.hidden }}
|
||||||
onMouseDown={(event) => {
|
onMouseDown={(event) => {
|
||||||
if (event.button !== 1) return
|
if (event.button !== 1) return
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { createResizeObserver } from "@solid-primitives/resize-observer"
|
|||||||
import { tabHref, tabKey, type SessionTab, type Tab } from "@/context/tabs"
|
import { tabHref, tabKey, type SessionTab, type Tab } from "@/context/tabs"
|
||||||
import { ServerConnection } from "@/context/server"
|
import { ServerConnection } from "@/context/server"
|
||||||
import { DraftTabItem, TabNavItem } from "@/components/titlebar-tab-nav"
|
import { DraftTabItem, TabNavItem } from "@/components/titlebar-tab-nav"
|
||||||
|
import { TabColorMenu } from "@/components/titlebar-tab-color"
|
||||||
import { useGlobal, type ServerCtx } from "@/context/global"
|
import { useGlobal, type ServerCtx } from "@/context/global"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useCommand } from "@/context/command"
|
import { useCommand } from "@/context/command"
|
||||||
@@ -56,6 +57,7 @@ function SessionTabSlot(props: {
|
|||||||
onPointerDown: (event: PointerEvent) => void
|
onPointerDown: (event: PointerEvent) => void
|
||||||
onNavigate: (element: HTMLDivElement) => void
|
onNavigate: (element: HTMLDivElement) => void
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
|
onColorChange: (color: string | undefined) => void
|
||||||
}) {
|
}) {
|
||||||
const tabs = useTabs()
|
const tabs = useTabs()
|
||||||
let ref!: HTMLDivElement
|
let ref!: HTMLDivElement
|
||||||
@@ -110,30 +112,33 @@ function SessionTabSlot(props: {
|
|||||||
}}
|
}}
|
||||||
onPointerDown={props.onPointerDown}
|
onPointerDown={props.onPointerDown}
|
||||||
>
|
>
|
||||||
<TabNavItem
|
<TabColorMenu color={props.tab.color} onColorChange={props.onColorChange}>
|
||||||
ref={ref}
|
<TabNavItem
|
||||||
href={tabHref(props.tab)}
|
ref={ref}
|
||||||
server={props.tab.server}
|
href={tabHref(props.tab)}
|
||||||
session={session}
|
server={props.tab.server}
|
||||||
onTitleChange={(title) => {
|
session={session}
|
||||||
const value = session()
|
onTitleChange={(title) => {
|
||||||
const ctx = props.serverCtx()
|
const value = session()
|
||||||
if (value && ctx) ctx.sync.session.remember({ ...value, title })
|
const ctx = props.serverCtx()
|
||||||
}}
|
if (value && ctx) ctx.sync.session.remember({ ...value, title })
|
||||||
onTitleChangeFailed={(title) => {
|
}}
|
||||||
const value = session()
|
onTitleChangeFailed={(title) => {
|
||||||
const ctx = props.serverCtx()
|
const value = session()
|
||||||
if (value && ctx) ctx.sync.session.remember({ ...value, title })
|
const ctx = props.serverCtx()
|
||||||
}}
|
if (value && ctx) ctx.sync.session.remember({ ...value, title })
|
||||||
onNavigate={() => props.onNavigate(ref)}
|
}}
|
||||||
onClose={props.onClose}
|
onNavigate={() => props.onNavigate(ref)}
|
||||||
active={props.active()}
|
onClose={props.onClose}
|
||||||
activeServer={props.tab.server === props.activeServerKey}
|
active={props.active()}
|
||||||
forceTruncate={props.forceTruncate}
|
activeServer={props.tab.server === props.activeServerKey}
|
||||||
suppressNavigation={props.suppressNavigation}
|
forceTruncate={props.forceTruncate}
|
||||||
pressed={props.pressed()}
|
color={props.tab.color}
|
||||||
hidden={props.dragged()}
|
suppressNavigation={props.suppressNavigation}
|
||||||
/>
|
pressed={props.pressed()}
|
||||||
|
hidden={props.dragged()}
|
||||||
|
/>
|
||||||
|
</TabColorMenu>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -145,6 +150,7 @@ export function TitlebarTabStrip(props: {
|
|||||||
forceTruncate: boolean
|
forceTruncate: boolean
|
||||||
onNavigate: (tab: Tab, el?: HTMLDivElement) => void
|
onNavigate: (tab: Tab, el?: HTMLDivElement) => void
|
||||||
onClose: (tab: Tab) => void
|
onClose: (tab: Tab) => void
|
||||||
|
onColorChange: (tab: Tab, color: string | undefined) => void
|
||||||
onReorder: (keys: string[]) => void
|
onReorder: (keys: string[]) => void
|
||||||
onOverflowChange: (overflowing: boolean) => void
|
onOverflowChange: (overflowing: boolean) => void
|
||||||
}) {
|
}) {
|
||||||
@@ -498,6 +504,7 @@ export function TitlebarTabStrip(props: {
|
|||||||
}}
|
}}
|
||||||
onNavigate={(element) => props.onNavigate(tab, element)}
|
onNavigate={(element) => props.onNavigate(tab, element)}
|
||||||
onClose={() => props.onClose(tab)}
|
onClose={() => props.onClose(tab)}
|
||||||
|
onColorChange={(color) => props.onColorChange(tab, color)}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -515,17 +522,20 @@ export function TitlebarTabStrip(props: {
|
|||||||
onPointerDown(id, event)
|
onPointerDown(id, event)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DraftTabItem
|
<TabColorMenu color={tab.color} onColorChange={(color) => props.onColorChange(tab, color)}>
|
||||||
ref={ref}
|
<DraftTabItem
|
||||||
href={tabHref(tab)}
|
ref={ref}
|
||||||
title={language.t("command.session.new")}
|
href={tabHref(tab)}
|
||||||
onNavigate={() => props.onNavigate(tab, ref)}
|
title={language.t("command.session.new")}
|
||||||
onClose={() => props.onClose(tab)}
|
onNavigate={() => props.onNavigate(tab, ref)}
|
||||||
suppressNavigation={() => suppressNavigation()}
|
onClose={() => props.onClose(tab)}
|
||||||
active={props.currentTab() === tab}
|
color={tab.color}
|
||||||
pressed={pressedId() === id}
|
suppressNavigation={() => suppressNavigation()}
|
||||||
hidden={dragged()}
|
active={props.currentTab() === tab}
|
||||||
/>
|
pressed={pressedId() === id}
|
||||||
|
hidden={dragged()}
|
||||||
|
/>
|
||||||
|
</TabColorMenu>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -6,6 +6,42 @@
|
|||||||
outline: none;
|
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-slot="titlebar-v2"]
|
||||||
[data-component="icon-button-v2"][data-variant="ghost-muted"]:is(:focus-visible, [data-state="focus"]):not(
|
[data-component="icon-button-v2"][data-variant="ghost-muted"]:is(:focus-visible, [data-state="focus"]):not(
|
||||||
:disabled
|
:disabled
|
||||||
|
|||||||
@@ -458,6 +458,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
|||||||
const index = tabsStore.findIndex((item) => tabKey(item) === tabKey(tab))
|
const index = tabsStore.findIndex((item) => tabKey(item) === tabKey(tab))
|
||||||
if (index !== -1) tabsStoreActions.removeTab(index)
|
if (index !== -1) tabsStoreActions.removeTab(index)
|
||||||
}}
|
}}
|
||||||
|
onColorChange={(tab, color) => tabsStoreActions.setColor(tab, color)}
|
||||||
onReorder={(keys) => tabsStoreActions.reorder(keys)}
|
onReorder={(keys) => tabsStoreActions.reorder(keys)}
|
||||||
/>
|
/>
|
||||||
<Show when={!(creating() && params.dir)}>
|
<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 { describe, expect, test } from "bun:test"
|
||||||
import { createRoot, getOwner, onCleanup } from "solid-js"
|
import { createRoot, getOwner, onCleanup } from "solid-js"
|
||||||
import { createTabMemory } from "./tab-memory"
|
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", () => {
|
describe("tab memory", () => {
|
||||||
test("keeps state until its tab is removed", () => {
|
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 { SessionTabsRemovedDetail } from "@/components/titlebar-session-events"
|
||||||
import { sessionHref } from "@/utils/session-route"
|
import { sessionHref } from "@/utils/session-route"
|
||||||
import { createTabMemory } from "./tab-memory"
|
import { createTabMemory } from "./tab-memory"
|
||||||
|
import { normalizeTabColor } from "./tab-color"
|
||||||
|
|
||||||
export type SessionTab = {
|
export type SessionTab = {
|
||||||
type: "session"
|
type: "session"
|
||||||
server: ServerConnection.Key
|
server: ServerConnection.Key
|
||||||
sessionId: string
|
sessionId: string
|
||||||
|
color?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DraftTab = {
|
export type DraftTab = {
|
||||||
@@ -23,6 +25,7 @@ export type DraftTab = {
|
|||||||
server: ServerConnection.Key
|
server: ServerConnection.Key
|
||||||
directory: string
|
directory: string
|
||||||
worktree?: string
|
worktree?: string
|
||||||
|
color?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Tab = SessionTab | DraftTab
|
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) {
|
draft(draftID: string) {
|
||||||
const tab = store.find((item) => item.type === "draft" && item.draftID === draftID)
|
const tab = store.find((item) => item.type === "draft" && item.draftID === draftID)
|
||||||
if (!tab || tab.type !== "draft") throw new Error(`Draft not found: ${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
|
// and fall back home. Navigate to the new session first so we leave /new-session
|
||||||
// before the draft is removed from the store.
|
// before the draft is removed from the store.
|
||||||
const active = location.pathname === "/new-session" && location.query.draftId === draftID
|
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(() => {
|
startTransition(() => {
|
||||||
setStore(
|
setStore(
|
||||||
produce((tabs) => {
|
produce((tabs) => {
|
||||||
|
|||||||
@@ -48,6 +48,17 @@ export const dict = {
|
|||||||
"command.session.new": "New session",
|
"command.session.new": "New session",
|
||||||
"command.file.open": "Open file",
|
"command.file.open": "Open file",
|
||||||
"command.tab.close": "Close tab",
|
"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": "Add selection to context",
|
||||||
"command.context.addSelection.description": "Add selected lines from the current file",
|
"command.context.addSelection.description": "Add selected lines from the current file",
|
||||||
"command.input.focus": "Focus input",
|
"command.input.focus": "Focus input",
|
||||||
|
|||||||
Reference in New Issue
Block a user