diff --git a/CHANGELOG.md b/CHANGELOG.md index 77b81a6f7..0880bc67b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -93,6 +93,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 with a single group has no header and is unchanged. A pinned session still carries its group to the top, which is the one order a drag cannot beat. +- **A worktree group's header spells the whole worktree name.** Worktrees named + the way most repositories name branches — `feat/x`, `fix/x` — came out of the + divider as `x`, because the header showed only the last folder of the + checkout's path. Two worktrees off the same ticket therefore drew the identical + header, and the divider stopped telling you which sessions were whose. The + header now reads the name the worktree was created with, slashes and all. A + worktree without a slash in it looks exactly as it did. + - **lich asks which agents you use, instead of assuming Claude Code.** The first launch opened on a Claude session because Claude was the only harness lich's author ran: a machine with Codex and nothing else met a first session that died diff --git a/frontend/src/components/sidebar/SessionGroup.tsx b/frontend/src/components/sidebar/SessionGroup.tsx index ad7221719..c5747bc7c 100644 --- a/frontend/src/components/sidebar/SessionGroup.tsx +++ b/frontend/src/components/sidebar/SessionGroup.tsx @@ -5,7 +5,7 @@ import { SortableContext, useSortable, verticalListSortingStrategy } from "@dnd- import { CSS } from "@dnd-kit/utilities" import { useSortableList, verticalAxis } from "@/lib/use-sortable-list" import { cn } from "@/lib/utils" -import { baseName } from "@/lib/paths" +import { checkoutLabel } from "@/lib/git/checkout-label" import { groupKey, type Session } from "@/lib/session/sessions" import { useProjects } from "@/providers/projects" import { SessionCard } from "./SessionCard" @@ -40,12 +40,12 @@ interface SessionGroupProps { } // SessionGroup renders one worktree's sessions under a static divider titled -// with the worktree folder name; the branch stays on each card. The title reads -// the group's own checkout path, never a session's live cwd, so a `cd` deeper -// into the tree never re-buckets the group. The isolated DndContext is what -// confines a card drag to reordering within the group; the group itself is a -// sortable of the sidebar's outer context, dragged by its header alone so the -// two never contend for the same pointer. +// with the worktree's name; the branch stays on each card. Both the title and +// the bucketing read the group's own checkout path, never a session's live cwd, +// so a `cd` deeper into the tree never re-buckets the group or renames it. The +// isolated DndContext is what confines a card drag to reordering within the +// group; the group itself is a sortable of the sidebar's outer context, dragged +// by its header alone so the two never contend for the same pointer. // // The card actions needing nothing but a session id — select, rename, open a // terminal beside it — are wired here rather than threaded down from the @@ -67,7 +67,7 @@ export function SessionGroup({ const navigate = useNavigate() const ids = sessions.map((session) => session.id) const { sensors, onDragEnd } = useSortableList(ids, onReorder) - const name = baseName(path || projectPath) + const name = checkoutLabel(path, projectPath, projectId) const group = useSortable({ id: groupKey(path), disabled: !showHeader }) // The PR card keys off the group's real checkout — the project root for the // root group (empty path), else the worktree — so a root project on a feature diff --git a/frontend/src/lib/git/checkout-label.test.ts b/frontend/src/lib/git/checkout-label.test.ts new file mode 100644 index 000000000..20d3c9a15 --- /dev/null +++ b/frontend/src/lib/git/checkout-label.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest" +import { checkoutLabel } from "./checkout-label" + +const PROJECT = "/home/me/code/lich" +const ID = "e48f04f46a4a" +const ROOT = `/home/me/.local/share/lich/worktrees/${ID}` + +describe("checkoutLabel", () => { + it("titles the root group with the project folder", () => { + expect(checkoutLabel("", PROJECT, ID)).toBe("lich") + }) + + it("keeps the whole worktree name when it holds a slash", () => { + expect(checkoutLabel(`${ROOT}/feat/x`, PROJECT, ID)).toBe("feat/x") + }) + + it("tells apart two worktrees sharing a last segment", () => { + expect(checkoutLabel(`${ROOT}/feat/x`, PROJECT, ID)).not.toBe( + checkoutLabel(`${ROOT}/fix/x`, PROJECT, ID), + ) + }) + + it("names a slashless worktree the same as before", () => { + expect(checkoutLabel(`${ROOT}/eager-willow`, PROJECT, ID)).toBe("eager-willow") + }) + + it("reads a Windows checkout, which lich spells with backslashes", () => { + expect( + checkoutLabel(`C:\\Users\\me\\AppData\\lich\\worktrees\\${ID}\\feat\\x`, PROJECT, ID), + ).toBe("feat/x") + }) + + it("cuts at the root, not at a worktree named after the project id", () => { + expect(checkoutLabel(`${ROOT}/${ID}/x`, PROJECT, ID)).toBe(`${ID}/x`) + }) + + it("falls back to the last segment for a checkout outside the worktree root", () => { + expect(checkoutLabel("/home/me/code/lich-elsewhere", PROJECT, ID)).toBe("lich-elsewhere") + }) +}) diff --git a/frontend/src/lib/git/checkout-label.ts b/frontend/src/lib/git/checkout-label.ts new file mode 100644 index 000000000..aef2d296a --- /dev/null +++ b/frontend/src/lib/git/checkout-label.ts @@ -0,0 +1,36 @@ +import { baseName } from "@/lib/paths" + +// checkoutLabel titles a sidebar group with the checkout it holds: the project's +// own folder for the root group (an empty path), else the worktree's name. +// +// A worktree's name is not its last path segment. It lives at +// // and git accepts a slash in a branch name, +// so one created as "feat/x" sits at ".../feat/x" and its final segment is "x" — +// the same header "fix/x" would draw, leaving the divider naming neither +// checkout. The project id is the one part of that root the frontend knows, and +// it is enough: everything past it is the name the worktree was made with. +// +// The name is deliberately read off the directory rather than off the branch the +// checkout currently holds. They agree only at creation; an agent that branches +// inside a worktree — the ordinary way to work in one — moves the branch and +// leaves the directory, and a header that followed it would rename a group under +// the user and repeat what every card in it already says. +// +// A checkout outside that root (a worktree added by hand elsewhere, then opened +// here) has no name to read and keeps its last segment. +export function checkoutLabel(path: string, projectPath: string, projectId: string): string { + if (!path) { + return baseName(projectPath) + } + // git reports a Windows checkout with forward slashes while lich builds one + // with backslashes; the same path reaches this in either spelling. + const unix = path.replace(/\\/g, "/") + const root = `/${projectId}/` + // The first match, not the last: a worktree may itself be named after the + // project id, and the root is the one that comes first. + const at = unix.indexOf(root) + if (at === -1) { + return baseName(path) + } + return unix.slice(at + root.length) +}