diff --git a/CHANGELOG.md b/CHANGELOG.md index a4b528f..eb642e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,14 @@ Page Down are included by default instead of separate fixed scroll buttons. Up to four optional right-side buttons can also be configured for the original Up/Down position. +- Pin individual workspaces or linked worktrees to the top of the Workspace + tree with browser persistence. Pinned linked worktrees are lifted out of their + repository group into the top-level pinned section. +- Collapse and expand linked worktrees beneath their repository workspace, with + the collapsed groups saved in the current browser. +- Mark linked-worktree workspace items with a compact branch icon, expanded to + a `WT` badge when pinned or otherwise top-level, and hide a redundant branch + badge when its branch matches the workspace name. ### Changed diff --git a/USAGE.md b/USAGE.md index 456dfb0..c9a1264 100644 --- a/USAGE.md +++ b/USAGE.md @@ -344,6 +344,14 @@ launchctl bootout "gui/$(id -u)/dev.herdr.herdr-gui" 在 workspace 上右键可以打开菜单: +- `Pin workspace` / `Pin worktree`:把 workspace 或 linked worktree 置顶; + 置顶的 linked worktree 会脱离原仓库分组,作为独立项目显示在列表顶部。再次打开 + 菜单取消置顶后,它会回到原 parent workspace 下。配置保存在当前浏览器中。 +- linked worktree 会显示紧凑的分支图标;置顶并脱离原分组后会展开为 `WT` + 标记。如果 workspace 名称与 Git branch 完全相同,则不再重复显示 branch + 标签。 +- 有 linked worktree 的主 workspace 左侧会显示箭头;点击可以折叠或展开同组 + worktree,折叠状态也保存在当前浏览器中。 - `New worktree...`:从主 checkout 创建新 worktree。 - `Rename workspace...`:重命名 workspace。 - `Remove worktree`:移除 linked worktree。 diff --git a/web/src/components/ContextMenu.tsx b/web/src/components/ContextMenu.tsx index 015fe7d..26b005b 100644 --- a/web/src/components/ContextMenu.tsx +++ b/web/src/components/ContextMenu.tsx @@ -8,6 +8,7 @@ import { WorktreeOpenDialog } from "./WorktreeOpenDialog"; import { WorkspaceAutoSyncDialog } from "./WorkspaceAutoSyncDialog"; import { worktreeCreationSource } from "../worktree"; import { WorktreeLifecycleDialog } from "./WorktreeLifecycleDialog"; +import { isWorkspacePinned } from "../workspacePins"; export interface ContextMenuState { x: number; @@ -45,9 +46,13 @@ type DialogState = export function ContextMenu({ state, + pinnedWorkspaceKeys, + onPinnedChange, onClose, }: { state: ContextMenuState | null; + pinnedWorkspaceKeys: ReadonlySet; + onPinnedChange: (workspace: Workspace, pinned: boolean) => void; onClose: () => void; }) { const workspaces = useStore().workspaces; @@ -199,11 +204,20 @@ export function ContextMenu({ ); } - const w = state.workspace; + const w = + workspaces.find( + (workspace) => workspace.workspace_id === state.workspace.workspace_id, + ) ?? state.workspace; const isLinked = !!w.worktree?.is_linked_worktree; + const pinned = isWorkspacePinned(pinnedWorkspaceKeys, w); const creationSource = worktreeCreationSource(workspaces, w); - const items: Item[] = []; + const items: Item[] = [ + { + label: `${pinned ? "Unpin" : "Pin"} ${isLinked ? "worktree" : "workspace"}`, + action: () => onPinnedChange(w, !pinned), + }, + ]; if (w.worktree) { items.push({ label: "Worktree lifecycle…", diff --git a/web/src/components/WorkspaceTree.tsx b/web/src/components/WorkspaceTree.tsx index c8f1d29..5f7ad71 100644 --- a/web/src/components/WorkspaceTree.tsx +++ b/web/src/components/WorkspaceTree.tsx @@ -4,13 +4,45 @@ import { agentClass } from "../utils"; import { useEffect, useRef, useState } from "react"; import { ContextMenu, type ContextMenuState } from "./ContextMenu"; import { CreateWorkspaceDialog } from "./CreateWorkspaceDialog"; -import { buildWorkspaceHierarchy } from "../worktree"; -import { GitBranch } from "lucide-react"; +import { buildWorkspaceHierarchy, worktreeCreationSource } from "../worktree"; +import { + ChevronDown, + ChevronRight, + GitBranch, + GitFork, + Pin, +} from "lucide-react"; import { WorktreeLifecycleDialog } from "./WorktreeLifecycleDialog"; +import { + WORKSPACE_PINS_STORAGE_KEY, + isWorkspacePinned, + parseWorkspacePins, + serializeWorkspacePins, + setWorkspacePinned, +} from "../workspacePins"; +import { + COLLAPSED_WORKTREE_GROUPS_STORAGE_KEY, + isWorktreeGroupCollapsed, + parseCollapsedWorktreeGroups, + serializeCollapsedWorktreeGroups, + setWorktreeGroupCollapsed, +} from "../workspaceTreeCollapse"; +import { + showWorkspaceBranchBadge, + workspaceDisplayName, +} from "../workspaceTreeBadges"; +import { pruneClosedWorkspacePreferenceKeys } from "../workspacePreferences"; const LONG_PRESS_MS = 550; const LONG_PRESS_MOVE_PX = 10; +function stringArraysEqual(left: readonly string[], right: readonly string[]) { + return ( + left.length === right.length && + left.every((value, index) => value === right[index]) + ); +} + function gitChangedCount(status: GitStatusSummary) { return status.staged + status.unstaged + status.untracked + status.conflicted; } @@ -31,11 +63,20 @@ function gitStatusTitle(status?: GitStatusSummary) { return parts.join(" · "); } -function GitStatusBadges({ status }: { status?: GitStatusSummary }) { +function GitStatusBadges({ + status, + showBranch = true, +}: { + status?: GitStatusSummary; + showBranch?: boolean; +}) { if (!status) return null; if (status.error) { return ( - + git? ); @@ -43,9 +84,14 @@ function GitStatusBadges({ status }: { status?: GitStatusSummary }) { const changed = gitChangedCount(status); const branch = status.branch || "git"; + const hasVisibleBadge = + showBranch || changed > 0 || status.ahead > 0 || status.behind > 0; + if (!hasVisibleBadge) return null; return ( - {branch} + {showBranch ? ( + {branch} + ) : null} {changed > 0 ? ( Δ{changed} ) : null} @@ -59,17 +105,92 @@ function GitStatusBadges({ status }: { status?: GitStatusSummary }) { ); } -export function WorkspaceTree({ - onSelect, -}: { - onSelect?: () => void; -}) { +export function WorkspaceTree({ onSelect }: { onSelect?: () => void }) { const s = useStore(); const [menu, setMenu] = useState(null); const [createOpen, setCreateOpen] = useState(false); const [lifecycleWorkspaceId, setLifecycleWorkspaceId] = useState< string | null >(null); + const lastPrunedWorkspaceRefresh = useRef(0); + const [pinnedWorkspaceKeys, setPinnedWorkspaceKeys] = useState(() => + parseWorkspacePins(localStorage.getItem(WORKSPACE_PINS_STORAGE_KEY)), + ); + const [collapsedWorktreeGroupKeys, setCollapsedWorktreeGroupKeys] = useState< + string[] + >(() => + parseCollapsedWorktreeGroups( + localStorage.getItem(COLLAPSED_WORKTREE_GROUPS_STORAGE_KEY), + ), + ); + const pinnedWorkspaceSet = new Set(pinnedWorkspaceKeys); + const collapsedWorktreeGroupSet = new Set(collapsedWorktreeGroupKeys); + + useEffect(() => { + localStorage.setItem( + WORKSPACE_PINS_STORAGE_KEY, + serializeWorkspacePins(pinnedWorkspaceKeys), + ); + }, [pinnedWorkspaceKeys]); + useEffect(() => { + localStorage.setItem( + COLLAPSED_WORKTREE_GROUPS_STORAGE_KEY, + serializeCollapsedWorktreeGroups(collapsedWorktreeGroupKeys), + ); + }, [collapsedWorktreeGroupKeys]); + useEffect(() => { + if ( + s.status !== "connected" || + s.lastRefresh === 0 || + s.lastRefresh <= lastPrunedWorkspaceRefresh.current + ) { + return; + } + lastPrunedWorkspaceRefresh.current = s.lastRefresh; + setPinnedWorkspaceKeys((current) => { + const next = pruneClosedWorkspacePreferenceKeys(current, s.workspaces); + return stringArraysEqual(current, next) ? current : next; + }); + setCollapsedWorktreeGroupKeys((current) => { + const next = pruneClosedWorkspacePreferenceKeys(current, s.workspaces); + return stringArraysEqual(current, next) ? current : next; + }); + }, [s.lastRefresh, s.status, s.workspaces]); + useEffect(() => { + const onStorage = (event: StorageEvent) => { + if (event.key === WORKSPACE_PINS_STORAGE_KEY) { + setPinnedWorkspaceKeys(parseWorkspacePins(event.newValue)); + } else if (event.key === COLLAPSED_WORKTREE_GROUPS_STORAGE_KEY) { + setCollapsedWorktreeGroupKeys( + parseCollapsedWorktreeGroups(event.newValue), + ); + } + }; + window.addEventListener("storage", onStorage); + return () => window.removeEventListener("storage", onStorage); + }, []); + + const updatePinnedWorkspace = (workspace: Workspace, pinned: boolean) => { + setPinnedWorkspaceKeys((current) => + setWorkspacePinned(current, workspace, pinned), + ); + if (!pinned && workspace.worktree?.is_linked_worktree) { + const parent = worktreeCreationSource(s.workspaces, workspace); + if (parent) { + setCollapsedWorktreeGroupKeys((current) => + setWorktreeGroupCollapsed(current, parent, false), + ); + } + } + }; + const updateCollapsedWorktreeGroup = ( + workspace: Workspace, + collapsed: boolean, + ) => { + setCollapsedWorktreeGroupKeys((current) => + setWorktreeGroupCollapsed(current, workspace, collapsed), + ); + }; if (s.workspaces.length === 0) { return ( @@ -99,7 +220,10 @@ export function WorkspaceTree({ ); } - const { topLevel, childrenByParent } = buildWorkspaceHierarchy(s.workspaces); + const { topLevel, childrenByParent } = buildWorkspaceHierarchy( + s.workspaces, + pinnedWorkspaceSet, + ); const focusedRepoWorkspace = s.workspaces.find( (workspace) => workspace.focused && workspace.worktree, ); @@ -138,6 +262,9 @@ export function WorkspaceTree({ w={w} depth={0} childrenByParent={childrenByParent} + pinnedWorkspaceKeys={pinnedWorkspaceSet} + collapsedWorktreeGroupKeys={collapsedWorktreeGroupSet} + onCollapsedChange={updateCollapsedWorktreeGroup} onSelect={onSelect} onContextMenu={(w, x, y) => setMenu({ workspace: w, x, y })} /> @@ -145,6 +272,8 @@ export function WorkspaceTree({ setMenu(null)} /> ; + pinnedWorkspaceKeys: ReadonlySet; + collapsedWorktreeGroupKeys: ReadonlySet; + onCollapsedChange: (workspace: Workspace, collapsed: boolean) => void; onSelect?: () => void; onContextMenu: (w: Workspace, x: number, y: number) => void; }) { const children = childrenByParent.get(w.workspace_id) ?? []; const s = useStore(); const isChild = depth > 0; - const isPendingFocus = s.pendingFocusWorkspaceId === w.workspace_id && !w.focused; + const hasChildren = children.length > 0; + const collapsed = + hasChildren && isWorktreeGroupCollapsed(collapsedWorktreeGroupKeys, w); + const pinned = isWorkspacePinned(pinnedWorkspaceKeys, w); + const worktreeMarkerRepoName = + w.worktree?.is_linked_worktree === true ? w.worktree.repo_name : null; + const compactWorktreeMarker = isChild && !pinned; + const isPendingFocus = + s.pendingFocusWorkspaceId === w.workspace_id && !w.focused; const longPressTimer = useRef | null>(null); const longPressStart = useRef<{ x: number; y: number } | null>(null); const longPressTriggered = useRef(false); @@ -226,7 +369,7 @@ function WorkspaceRow({
{ if (longPressTriggered.current) { @@ -258,26 +401,90 @@ function WorkspaceRow({ : w.workspace_id } > - {isChild ? "⌞" : " "} - {w.label || w.workspace_id} + {hasChildren ? ( + + ) : ( + {isChild ? "⌞" : " "} + )} + {workspaceDisplayName(w)} + {worktreeMarkerRepoName ? ( + + + ) : null} + {pinned ? ( + + ) : null} {isPendingFocus ? ( ) : null} {w.worktree ? ( - + + ) : null} + {w.agent_status !== "unknown" ? ( + {w.agent_status} ) : null} - {w.agent_status}
- {children.map((child) => ( - - ))} + {!collapsed + ? children.map((child) => ( + + )) + : null} ); } diff --git a/web/src/styles.css b/web/src/styles.css index a30286f..db896a3 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -1420,6 +1420,28 @@ textarea:focus { width: 10px; display: inline-block; } +.workspace-group-toggle { + width: 24px; + height: 24px; + flex: 0 0 24px; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; + border: 0; + border-radius: 4px; + background: transparent; + color: var(--muted); + cursor: pointer; +} +.workspace-group-toggle:hover { + background: color-mix(in srgb, var(--accent) 14%, transparent); + color: var(--text); +} +.workspace-group-toggle:focus-visible { + outline: 1px solid var(--accent); + outline-offset: 1px; +} .ws-label { flex: 1; overflow: hidden; @@ -1429,6 +1451,43 @@ textarea:focus { .tree-row.is-child strong { font-weight: 500; } +.workspace-worktree-marker { + height: 16px; + max-width: 34px; + flex: 0 1 34px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 2px; + padding: 0 3px; + border: 1px solid color-mix(in srgb, var(--accent) 38%, var(--border)); + border-radius: 5px; + background: color-mix(in srgb, var(--accent-soft) 48%, transparent); + color: color-mix(in srgb, var(--accent) 72%, var(--text)); + font-size: 8px; + font-weight: 700; + line-height: 14px; + letter-spacing: 0.02em; +} +.workspace-worktree-marker svg { + flex: 0 0 auto; +} +.workspace-worktree-marker.is-compact { + width: 14px; + max-width: 14px; + flex-basis: 14px; + padding: 0; + border-color: transparent; + background: transparent; +} +.workspace-pin { + flex: 0 0 auto; + color: var(--muted); + transform: rotate(-18deg); +} +.tree-row.is-pinned:not(.is-focused) { + background: color-mix(in srgb, var(--panel-2) 62%, transparent); +} .row-spinner { width: 10px; height: 10px; @@ -1452,6 +1511,7 @@ textarea:focus { } .git-status { min-width: 0; + max-width: min(45%, 142px); display: inline-flex; align-items: center; gap: 3px; @@ -5039,6 +5099,12 @@ textarea:focus { margin-top: 16px; } /* ===== badges ===== */ +.tree-row > .badge { + max-width: 60px; + overflow: hidden; + flex: 0 1 auto; + text-overflow: ellipsis; +} .badge { font-size: 11px; padding: 1px 7px; diff --git a/web/src/workspaceIdentity.ts b/web/src/workspaceIdentity.ts new file mode 100644 index 0000000..a078cfc --- /dev/null +++ b/web/src/workspaceIdentity.ts @@ -0,0 +1,53 @@ +import type { Workspace } from "./types"; + +const WORKSPACE_KEY_PREFIX = "workspace:"; +const WORKTREE_KEY_PREFIX = "worktree:"; + +function normalizedPath(path: string): string { + const normalized = path.replace(/\\/g, "/"); + if (normalized === "/") return normalized; + return normalized.replace(/\/+$/, ""); +} + +// A workspace is a live Herdr object, while a linked worktree represents a +// durable checkout that can be closed and reopened under a new workspace id. +// Keep those identities separate: main-checkout workspaces may legitimately +// share a path, so only linked worktrees use repository + checkout identity. +export function workspacePreferenceKey(workspace: Workspace): string { + const worktree = workspace.worktree; + const repository = + worktree?.gui_settings_key?.trim() || worktree?.repo_key.trim(); + const checkoutPath = worktree + ? normalizedPath(worktree.checkout_path.trim()) + : ""; + if (worktree?.is_linked_worktree === true && repository && checkoutPath) { + return `${WORKTREE_KEY_PREFIX}${encodeURIComponent(repository)}:${encodeURIComponent(checkoutPath)}`; + } + return `${WORKSPACE_KEY_PREFIX}${encodeURIComponent(workspace.workspace_id)}`; +} + +function isCanonicalEncodedValue(value: string): boolean { + if (!value) return false; + try { + const decoded = decodeURIComponent(value); + return decoded.length > 0 && encodeURIComponent(decoded) === value; + } catch { + return false; + } +} + +export function isWorkspacePreferenceKey(value: string): boolean { + if (value.startsWith(WORKSPACE_KEY_PREFIX)) { + return isCanonicalEncodedValue(value.slice(WORKSPACE_KEY_PREFIX.length)); + } + if (!value.startsWith(WORKTREE_KEY_PREFIX)) return false; + + const encoded = value.slice(WORKTREE_KEY_PREFIX.length); + const separator = encoded.indexOf(":"); + return ( + separator > 0 && + separator === encoded.lastIndexOf(":") && + isCanonicalEncodedValue(encoded.slice(0, separator)) && + isCanonicalEncodedValue(encoded.slice(separator + 1)) + ); +} diff --git a/web/src/workspacePins.test.ts b/web/src/workspacePins.test.ts new file mode 100644 index 0000000..6f30ac5 --- /dev/null +++ b/web/src/workspacePins.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, test } from "bun:test"; +import type { Workspace } from "./types"; +import { + isWorkspacePinned, + parseWorkspacePins, + serializeWorkspacePins, + setWorkspacePinned, + workspacePinKey, +} from "./workspacePins"; + +function workspace( + workspaceId: string, + worktree?: Workspace["worktree"], +): Workspace { + return { + workspace_id: workspaceId, + number: Number(workspaceId.replace(/\D/g, "")) || 1, + label: workspaceId, + focused: false, + pane_count: 1, + tab_count: 1, + agent_status: "unknown", + worktree, + }; +} + +const linkedWorktree: NonNullable = { + repo_key: "/repo/.git", + repo_name: "repo", + repo_root: "/repo", + checkout_path: "/repo-worktrees/feature/", + is_linked_worktree: true, + gui_settings_key: "local:/repo/.git", +}; + +describe("workspace pins", () => { + test("uses stable checkout identity only for linked worktrees", () => { + expect(workspacePinKey(workspace("w1"))).toBe("workspace:w1"); + expect(workspacePinKey(workspace("w:1"))).toBe("workspace:w%3A1"); + expect(workspacePinKey(workspace("w2", linkedWorktree))).toBe( + "worktree:local%3A%2Frepo%2F.git:%2Frepo-worktrees%2Ffeature", + ); + expect(workspacePinKey(workspace("w99", linkedWorktree))).toBe( + workspacePinKey(workspace("w2", linkedWorktree)), + ); + expect( + workspacePinKey( + workspace("w100", { + ...linkedWorktree, + checkout_path: " /repo-worktrees/feature// ", + }), + ), + ).toBe(workspacePinKey(workspace("w2", linkedWorktree))); + expect( + workspacePinKey( + workspace("w3", { ...linkedWorktree, gui_settings_key: undefined }), + ), + ).toBe("worktree:%2Frepo%2F.git:%2Frepo-worktrees%2Ffeature"); + + const mainCheckout = { + ...linkedWorktree, + checkout_path: "/repo", + is_linked_worktree: false, + }; + expect(workspacePinKey(workspace("w2", mainCheckout))).toBe("workspace:w2"); + expect(workspacePinKey(workspace("w99", mainCheckout))).toBe( + "workspace:w99", + ); + }); + + test("parses only bounded, unique workspace and worktree keys", () => { + const pins = parseWorkspacePins( + JSON.stringify([ + "workspace:w1", + "workspace:w1", + "worktree:repo:%2Fpath", + "other:value", + 42, + `workspace:${"x".repeat(3000)}`, + ]), + ); + + expect(pins).toEqual(["workspace:w1", "worktree:repo:%2Fpath"]); + expect(parseWorkspacePins("bad json")).toEqual([]); + expect(parseWorkspacePins("{}" as string)).toEqual([]); + expect(JSON.parse(serializeWorkspacePins(pins))).toEqual(pins); + }); + + test("pins, unpins, and recognizes exact workspace identities", () => { + const target = workspace("w2", linkedWorktree); + const pinned = setWorkspacePinned(["workspace:w1"], target, true); + + expect(isWorkspacePinned(new Set(pinned), target)).toBe(true); + expect(pinned).toHaveLength(2); + expect(setWorkspacePinned(pinned, target, true)).toEqual(pinned); + expect(setWorkspacePinned(pinned, target, false)).toEqual(["workspace:w1"]); + }); +}); diff --git a/web/src/workspacePins.ts b/web/src/workspacePins.ts new file mode 100644 index 0000000..eb79ba8 --- /dev/null +++ b/web/src/workspacePins.ts @@ -0,0 +1,28 @@ +import type { Workspace } from "./types"; +import { workspacePreferenceKey } from "./workspaceIdentity"; +import { + parseWorkspacePreferenceKeys, + serializeWorkspacePreferenceKeys, + setWorkspacePreferenceKey, +} from "./workspacePreferences"; + +export const WORKSPACE_PINS_STORAGE_KEY = "workspacePins.v1"; + +export const workspacePinKey = workspacePreferenceKey; +export const parseWorkspacePins = parseWorkspacePreferenceKeys; +export const serializeWorkspacePins = serializeWorkspacePreferenceKeys; + +export function isWorkspacePinned( + pins: ReadonlySet, + workspace: Workspace, +): boolean { + return pins.has(workspacePinKey(workspace)); +} + +export function setWorkspacePinned( + pins: readonly string[], + workspace: Workspace, + pinned: boolean, +): string[] { + return setWorkspacePreferenceKey(pins, workspacePinKey(workspace), pinned); +} diff --git a/web/src/workspacePreferences.test.ts b/web/src/workspacePreferences.test.ts new file mode 100644 index 0000000..2b86ccf --- /dev/null +++ b/web/src/workspacePreferences.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, test } from "bun:test"; +import type { Workspace } from "./types"; +import { + MAX_WORKSPACE_PREFERENCES, + parseWorkspacePreferenceKeys, + pruneClosedWorkspacePreferenceKeys, + serializeWorkspacePreferenceKeys, + setWorkspacePreferenceKey, +} from "./workspacePreferences"; + +describe("workspace preference keys", () => { + test("parses only bounded, canonical, unique keys", () => { + const keys = parseWorkspacePreferenceKeys( + JSON.stringify([ + "workspace:w1", + "workspace:w1", + "worktree:local%3A%2Frepo:%2Fcheckout", + "worktree:legacy:path:with:ambiguous:separators", + "worktree:bad%ZZ:%2Fcheckout", + "worktree:raw slash:%2Fcheckout", + "workspace:raw slash", + "workspace:", + "other:value", + 42, + `workspace:${"x".repeat(3000)}`, + ]), + ); + + expect(keys).toEqual([ + "workspace:w1", + "worktree:local%3A%2Frepo:%2Fcheckout", + ]); + expect(parseWorkspacePreferenceKeys("bad json")).toEqual([]); + expect(parseWorkspacePreferenceKeys("{}" as string)).toEqual([]); + expect(JSON.parse(serializeWorkspacePreferenceKeys(keys))).toEqual(keys); + }); + + test("keeps the newest explicit preference when storage is full", () => { + const full = Array.from( + { length: MAX_WORKSPACE_PREFERENCES }, + (_, index) => `workspace:w${index}`, + ); + const next = setWorkspacePreferenceKey(full, "workspace:newest", true); + + expect(next).toHaveLength(MAX_WORKSPACE_PREFERENCES); + expect(next[0]).toBe("workspace:w1"); + expect(next[next.length - 1]).toBe("workspace:newest"); + }); + + test("moves an existing enabled preference to the newest position", () => { + expect( + setWorkspacePreferenceKey( + ["workspace:w1", "workspace:w2"], + "workspace:w1", + true, + ), + ).toEqual(["workspace:w2", "workspace:w1"]); + expect( + setWorkspacePreferenceKey( + ["workspace:w1", "workspace:w2"], + "workspace:w1", + false, + ), + ).toEqual(["workspace:w2"]); + }); + + test("prunes closed ephemeral workspaces but keeps durable worktrees", () => { + const workspace = { + workspace_id: "w1", + number: 1, + label: "one", + focused: false, + pane_count: 1, + tab_count: 1, + agent_status: "unknown", + } satisfies Workspace; + + expect( + pruneClosedWorkspacePreferenceKeys( + ["workspace:w1", "workspace:w2", "worktree:repo:%2Fcheckout"], + [workspace], + ), + ).toEqual(["workspace:w1", "worktree:repo:%2Fcheckout"]); + }); +}); diff --git a/web/src/workspacePreferences.ts b/web/src/workspacePreferences.ts new file mode 100644 index 0000000..11c283b --- /dev/null +++ b/web/src/workspacePreferences.ts @@ -0,0 +1,67 @@ +import type { Workspace } from "./types"; +import { + isWorkspacePreferenceKey, + workspacePreferenceKey, +} from "./workspaceIdentity"; + +export const MAX_WORKSPACE_PREFERENCES = 256; +export const MAX_WORKSPACE_PREFERENCE_KEY_LENGTH = 2048; + +export function parseWorkspacePreferenceKeys(raw: string | null): string[] { + if (!raw) return []; + try { + const value = JSON.parse(raw); + if (!Array.isArray(value)) return []; + const keys: string[] = []; + const seen = new Set(); + for (const candidate of value) { + if (keys.length >= MAX_WORKSPACE_PREFERENCES) break; + if ( + typeof candidate !== "string" || + candidate.length > MAX_WORKSPACE_PREFERENCE_KEY_LENGTH || + !isWorkspacePreferenceKey(candidate) || + seen.has(candidate) + ) { + continue; + } + seen.add(candidate); + keys.push(candidate); + } + return keys; + } catch { + return []; + } +} + +export function serializeWorkspacePreferenceKeys( + keys: readonly string[], +): string { + return JSON.stringify(parseWorkspacePreferenceKeys(JSON.stringify(keys))); +} + +export function setWorkspacePreferenceKey( + keys: readonly string[], + key: string, + enabled: boolean, +): string[] { + const normalized = parseWorkspacePreferenceKeys(JSON.stringify(keys)); + const withoutKey = normalized.filter((candidate) => candidate !== key); + if (!enabled) return withoutKey; + // Preferences are insertion ordered. Discard the oldest item at capacity so + // a user's newest explicit action always takes effect. + return [...withoutKey, key].slice(-MAX_WORKSPACE_PREFERENCES); +} + +export function pruneClosedWorkspacePreferenceKeys( + keys: readonly string[], + workspaces: readonly Workspace[], +): string[] { + const liveWorkspaceKeys = new Set( + workspaces + .map(workspacePreferenceKey) + .filter((key) => key.startsWith("workspace:")), + ); + return parseWorkspacePreferenceKeys(JSON.stringify(keys)).filter( + (key) => key.startsWith("worktree:") || liveWorkspaceKeys.has(key), + ); +} diff --git a/web/src/workspaceTreeBadges.test.ts b/web/src/workspaceTreeBadges.test.ts new file mode 100644 index 0000000..32aa884 --- /dev/null +++ b/web/src/workspaceTreeBadges.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, test } from "bun:test"; +import type { GitStatusSummary, Workspace } from "./types"; +import { + showWorkspaceBranchBadge, + workspaceDisplayName, +} from "./workspaceTreeBadges"; + +function workspace({ + label, + branch, + linked = true, +}: { + label: string; + branch?: string; + linked?: boolean; +}): Workspace { + const gitStatus: GitStatusSummary = { + branch, + ahead: 0, + behind: 0, + staged: 0, + unstaged: 0, + untracked: 0, + conflicted: 0, + dirty: false, + }; + return { + workspace_id: "w1", + number: 1, + label, + focused: false, + pane_count: 1, + tab_count: 1, + agent_status: "unknown", + worktree: { + repo_key: "/repo/.git", + repo_name: "repo", + repo_root: "/repo", + checkout_path: linked ? "/repo-worktree" : "/repo", + is_linked_worktree: linked, + git_status: gitStatus, + }, + }; +} + +describe("workspace tree badges", () => { + test("hides a redundant branch badge for matching linked worktree names", () => { + expect( + showWorkspaceBranchBadge( + workspace({ label: "feature", branch: "feature" }), + ), + ).toBe(false); + }); + + test("normalizes incidental whitespace before comparing names", () => { + expect( + showWorkspaceBranchBadge( + workspace({ label: "feature ", branch: " feature" }), + ), + ).toBe(false); + }); + + test("keeps branch badges when the visible name differs", () => { + expect( + showWorkspaceBranchBadge( + workspace({ label: "Feature workspace", branch: "feature" }), + ), + ).toBe(true); + }); + + test("keeps branch badges for main checkouts and missing branch names", () => { + expect( + showWorkspaceBranchBadge( + workspace({ label: "main", branch: "main", linked: false }), + ), + ).toBe(true); + expect(showWorkspaceBranchBadge(workspace({ label: "feature" }))).toBe( + true, + ); + }); + + test("falls back to the workspace id for the visible name", () => { + const target = workspace({ label: "", branch: "w1" }); + expect(workspaceDisplayName(target)).toBe("w1"); + expect(showWorkspaceBranchBadge(target)).toBe(false); + }); +}); diff --git a/web/src/workspaceTreeBadges.ts b/web/src/workspaceTreeBadges.ts new file mode 100644 index 0000000..0be5ff5 --- /dev/null +++ b/web/src/workspaceTreeBadges.ts @@ -0,0 +1,14 @@ +import type { Workspace } from "./types"; + +export function workspaceDisplayName(workspace: Workspace): string { + return workspace.label || workspace.workspace_id; +} + +export function showWorkspaceBranchBadge(workspace: Workspace): boolean { + const branch = workspace.worktree?.git_status?.branch; + if (!branch) return true; + return !( + workspace.worktree?.is_linked_worktree === true && + workspaceDisplayName(workspace).trim() === branch.trim() + ); +} diff --git a/web/src/workspaceTreeCollapse.test.ts b/web/src/workspaceTreeCollapse.test.ts new file mode 100644 index 0000000..9155f89 --- /dev/null +++ b/web/src/workspaceTreeCollapse.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, test } from "bun:test"; +import type { Workspace } from "./types"; +import { + isWorktreeGroupCollapsed, + parseCollapsedWorktreeGroups, + serializeCollapsedWorktreeGroups, + setWorktreeGroupCollapsed, + worktreeGroupKey, +} from "./workspaceTreeCollapse"; + +function workspace( + workspaceId: string, + worktree?: Workspace["worktree"], +): Workspace { + return { + workspace_id: workspaceId, + number: Number(workspaceId.replace(/\D/g, "")) || 1, + label: workspaceId, + focused: false, + pane_count: 1, + tab_count: 1, + agent_status: "unknown", + worktree, + }; +} + +const mainWorktree: NonNullable = { + repo_key: "/repo/.git", + repo_name: "repo", + repo_root: "/repo", + checkout_path: "/repo/", + is_linked_worktree: false, + gui_settings_key: "local:/repo/.git", +}; + +describe("collapsed worktree groups", () => { + test("uses stable workspace preference identity", () => { + expect(worktreeGroupKey(workspace("w1"))).toBe("workspace:w1"); + expect(worktreeGroupKey(workspace("w2", mainWorktree))).toBe( + "workspace:w2", + ); + expect(worktreeGroupKey(workspace("w99", mainWorktree))).toBe( + "workspace:w99", + ); + }); + + test("parses only bounded, unique workspace and worktree keys", () => { + const groups = parseCollapsedWorktreeGroups( + JSON.stringify([ + "workspace:w1", + "workspace:w1", + "worktree:repo:%2Fpath", + "other:value", + 42, + `workspace:${"x".repeat(3000)}`, + ]), + ); + + expect(groups).toEqual(["workspace:w1", "worktree:repo:%2Fpath"]); + expect(parseCollapsedWorktreeGroups("bad json")).toEqual([]); + expect(parseCollapsedWorktreeGroups("{}" as string)).toEqual([]); + expect(JSON.parse(serializeCollapsedWorktreeGroups(groups))).toEqual( + groups, + ); + }); + + test("collapses, expands, and recognizes exact group identities", () => { + const target = workspace("w2", mainWorktree); + const collapsed = setWorktreeGroupCollapsed(["workspace:w1"], target, true); + + expect(isWorktreeGroupCollapsed(new Set(collapsed), target)).toBe(true); + expect(collapsed).toHaveLength(2); + expect(setWorktreeGroupCollapsed(collapsed, target, true)).toEqual( + collapsed, + ); + expect(setWorktreeGroupCollapsed(collapsed, target, false)).toEqual([ + "workspace:w1", + ]); + }); +}); diff --git a/web/src/workspaceTreeCollapse.ts b/web/src/workspaceTreeCollapse.ts new file mode 100644 index 0000000..5611e84 --- /dev/null +++ b/web/src/workspaceTreeCollapse.ts @@ -0,0 +1,34 @@ +import type { Workspace } from "./types"; +import { workspacePreferenceKey } from "./workspaceIdentity"; +import { + parseWorkspacePreferenceKeys, + serializeWorkspacePreferenceKeys, + setWorkspacePreferenceKey, +} from "./workspacePreferences"; + +export const COLLAPSED_WORKTREE_GROUPS_STORAGE_KEY = + "collapsedWorktreeGroups.v1"; + +export const worktreeGroupKey = workspacePreferenceKey; +export const parseCollapsedWorktreeGroups = parseWorkspacePreferenceKeys; +export const serializeCollapsedWorktreeGroups = + serializeWorkspacePreferenceKeys; + +export function isWorktreeGroupCollapsed( + groups: ReadonlySet, + workspace: Workspace, +): boolean { + return groups.has(worktreeGroupKey(workspace)); +} + +export function setWorktreeGroupCollapsed( + groups: readonly string[], + workspace: Workspace, + collapsed: boolean, +): string[] { + return setWorkspacePreferenceKey( + groups, + worktreeGroupKey(workspace), + collapsed, + ); +} diff --git a/web/src/worktree.test.ts b/web/src/worktree.test.ts index 5750a30..3db270f 100644 --- a/web/src/worktree.test.ts +++ b/web/src/worktree.test.ts @@ -6,6 +6,7 @@ import { resolveWorktreeOpenSource, worktreeCreationSource, } from "./worktree"; +import { workspacePinKey } from "./workspacePins"; function workspace(worktree?: Workspace["worktree"]): Workspace { return { @@ -55,19 +56,17 @@ describe("worktree open source", () => { }; test("uses the repository parent returned when listing from a linked workspace", () => { - expect( - resolveWorktreeOpenSource(list), - ).toEqual({ workspaceId: "main-workspace" }); + expect(resolveWorktreeOpenSource(list)).toEqual({ + workspaceId: "main-workspace", + }); }); test("uses the repository root when the main workspace is closed", () => { expect( - resolveWorktreeOpenSource( - { - ...list, - source: { ...list.source, source_workspace_id: undefined }, - }, - ), + resolveWorktreeOpenSource({ + ...list, + source: { ...list.source, source_workspace_id: undefined }, + }), ).toEqual({ cwd: "/repo" }); }); @@ -135,7 +134,9 @@ describe("workspace worktree hierarchy", () => { const hierarchy = buildWorkspaceHierarchy([first, second, linked]); expect(hierarchy.topLevel).toEqual([first, second, linked]); expect(hierarchy.childrenByParent.size).toBe(0); - expect(worktreeCreationSource([first, second, linked], linked)).toBeUndefined(); + expect( + worktreeCreationSource([first, second, linked], linked), + ).toBeUndefined(); }); test("ignores an explicit parent without matching Git metadata", () => { @@ -175,4 +176,92 @@ describe("workspace worktree hierarchy", () => { expect(hierarchy.childrenByParent.get("w1")).toEqual([linked]); expect(worktreeCreationSource([main, linked], linked)).toBe(main); }); + + test("sorts explicitly pinned workspaces before ordinary workspace numbers", () => { + const first = { ...workspace(), workspace_id: "w1", number: 1 }; + const second = { ...workspace(), workspace_id: "w2", number: 2 }; + const third = { ...workspace(), workspace_id: "w3", number: 3 }; + const pins = new Set([workspacePinKey(third)]); + + expect( + buildWorkspaceHierarchy([first, second, third], pins).topLevel, + ).toEqual([third, first, second]); + }); + + test("pins only the selected main-checkout workspace", () => { + const first = { + ...workspace(mainWorktree), + workspace_id: "w1", + number: 1, + }; + const second = { + ...workspace(mainWorktree), + workspace_id: "w2", + number: 2, + }; + const pins = new Set([workspacePinKey(second)]); + + expect(buildWorkspaceHierarchy([first, second], pins).topLevel).toEqual([ + second, + first, + ]); + }); + + test("lifts a pinned linked worktree out of its parent group", () => { + const ordinary = { ...workspace(), workspace_id: "w1", number: 1 }; + const main = { ...workspace(mainWorktree), workspace_id: "w2", number: 2 }; + const firstLinked = { + ...workspace({ + ...mainWorktree, + checkout_path: "/repo-worktree-a", + is_linked_worktree: true, + parent_workspace_id: "w2", + }), + workspace_id: "w3", + number: 3, + }; + const pinnedLinked = { + ...workspace({ + ...mainWorktree, + checkout_path: "/repo-worktree-b", + is_linked_worktree: true, + parent_workspace_id: "w2", + }), + workspace_id: "w4", + number: 4, + }; + const pins = new Set([workspacePinKey(pinnedLinked)]); + const hierarchy = buildWorkspaceHierarchy( + [ordinary, main, firstLinked, pinnedLinked], + pins, + ); + + expect(hierarchy.topLevel).toEqual([pinnedLinked, ordinary, main]); + expect(hierarchy.childrenByParent.get("w2")).toEqual([firstLinked]); + }); + + test("returns an unpinned linked worktree to its parent group", () => { + const main = { ...workspace(mainWorktree), workspace_id: "w1", number: 1 }; + const linked = { + ...workspace({ + ...mainWorktree, + checkout_path: "/repo-worktree", + is_linked_worktree: true, + parent_workspace_id: "w1", + }), + workspace_id: "w2", + number: 2, + }; + + const pinnedHierarchy = buildWorkspaceHierarchy( + [main, linked], + new Set([workspacePinKey(linked)]), + ); + expect(pinnedHierarchy.topLevel).toEqual([linked, main]); + expect(pinnedHierarchy.childrenByParent.size).toBe(0); + + const unpinnedHierarchy = buildWorkspaceHierarchy([main, linked]); + expect(unpinnedHierarchy.topLevel).toEqual([main]); + expect(unpinnedHierarchy.childrenByParent.get("w1")).toEqual([linked]); + }); }); diff --git a/web/src/worktree.ts b/web/src/worktree.ts index 3e175e9..3ff210e 100644 --- a/web/src/worktree.ts +++ b/web/src/worktree.ts @@ -1,8 +1,10 @@ import type { WorktreeList, Workspace } from "./types"; +import { isWorkspacePinned } from "./workspacePins"; + +const EMPTY_WORKSPACE_PINS = new Set(); export type WorktreeOpenSource = - | { workspaceId: string; cwd?: never } - | { workspaceId?: never; cwd: string }; + { workspaceId: string; cwd?: never } | { workspaceId?: never; cwd: string }; // worktree.list accepts a linked checkout and resolves its repository parent, // while worktree.open rejects that same linked workspace as a source. Use the @@ -43,7 +45,9 @@ export function worktreeCreationSource( const explicitParentId = workspace.worktree.parent_workspace_id; const explicitParent = explicitParentId - ? workspaces.find((candidate) => candidate.workspace_id === explicitParentId) + ? workspaces.find( + (candidate) => candidate.workspace_id === explicitParentId, + ) : undefined; if ( explicitParent && @@ -71,11 +75,14 @@ export type WorkspaceHierarchy = { // bridge's explicit association and only use repo_key when it is unambiguous. export function buildWorkspaceHierarchy( workspaces: Workspace[], + pinnedWorkspaceKeys: ReadonlySet = EMPTY_WORKSPACE_PINS, ): WorkspaceHierarchy { const childrenByParent = new Map(); const topLevel: Workspace[] = []; + const pinned = (workspace: Workspace) => + isWorkspacePinned(pinnedWorkspaceKeys, workspace); for (const workspace of workspaces) { - if (!workspace.worktree?.is_linked_worktree) { + if (!workspace.worktree?.is_linked_worktree || pinned(workspace)) { topLevel.push(workspace); continue; } @@ -91,7 +98,9 @@ export function buildWorkspaceHierarchy( childrenByParent.set(parent.workspace_id, children); } - topLevel.sort((a, b) => a.number - b.number); + topLevel.sort( + (a, b) => Number(pinned(b)) - Number(pinned(a)) || a.number - b.number, + ); childrenByParent.forEach((children) => children.sort((a, b) => a.number - b.number), );