From 45a1132ecef77ec193b5259319ae70c176fd5249 Mon Sep 17 00:00:00 2001 From: SunZhiQiang <71381346+panda-student@users.noreply.github.com> Date: Sat, 7 Nov 2026 13:51:25 -0800 Subject: [PATCH] feat(vcs): group the Worktrees panel by repository and merge linked roots (#2063) Closes #1984 Worktrees are grouped under their repository; a linked worktree opened as its own root joins its repository's group instead of a separate list. The current marker compares against the roots actually opened, so a project opened from a linked worktree no longer marks the main worktree as current. A root's listing error is still shown when its repository isn't listed by another root. Co-authored-by: panda-student <71381346+panda-student@users.noreply.github.com> --- src/features/vcs/GitLog.module.css | 1 - .../vcs/__tests__/worktreesPanel.test.tsx | 35 +++ src/features/vcs/common.module.css | 31 ++- src/features/vcs/gitMenuDialogs.tsx | 199 ++++++++++++------ src/features/vcs/worktrees.test.ts | 66 +++++- src/features/vcs/worktrees.ts | 64 +++++- src/platform/locales/text/ko.json | 1 - src/platform/locales/text/zh.json | 1 - 8 files changed, 312 insertions(+), 86 deletions(-) create mode 100644 src/features/vcs/__tests__/worktreesPanel.test.tsx diff --git a/src/features/vcs/GitLog.module.css b/src/features/vcs/GitLog.module.css index b3463bbef..2917f7794 100644 --- a/src/features/vcs/GitLog.module.css +++ b/src/features/vcs/GitLog.module.css @@ -601,7 +601,6 @@ .worktreeList { flex: 1; min-height: 0; - overflow: auto; } .colRepository { diff --git a/src/features/vcs/__tests__/worktreesPanel.test.tsx b/src/features/vcs/__tests__/worktreesPanel.test.tsx new file mode 100644 index 000000000..28c3114da --- /dev/null +++ b/src/features/vcs/__tests__/worktreesPanel.test.tsx @@ -0,0 +1,35 @@ +import { beforeEach, expect, it, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; + +const { list } = vi.hoisted(() => ({ list: vi.fn() })); +vi.mock("@/platform/ipc", async (original) => { + const real = await original(); + return { ...real, commands: { ...real.commands, vcsWorktrees: list } }; +}); + +import { WorktreesPanel } from "../gitMenuDialogs"; +import { useVcs } from "../store"; + +const worktrees = [ + { path: "/work/app", branch: "main", head: "12345678", main: true, locked: false, prunable: false }, + { path: "/work/app-feature", branch: "feature", head: "12345678", main: false, locked: false, prunable: false }, +]; + +beforeEach(() => { + list.mockReset().mockResolvedValue({ status: "ok", data: worktrees }); + useVcs.setState({ root: "/work/app-feature", roots: ["/work/app-feature"], repo: null }); +}); + +it("marks the linked project root current when its main worktree is also listed", async () => { + render(); + expect(await screen.findByText("current /work/app-feature")).toBeInTheDocument(); + expect(screen.getByText("main /work/app")).toBeInTheDocument(); +}); + +it("marks both opened roots current while grouping their repository once", async () => { + useVcs.setState({ roots: ["/work/app-feature", "/work/app"] }); + render(); + expect(await screen.findByText("current /work/app-feature")).toBeInTheDocument(); + expect(screen.getByText("current /work/app")).toBeInTheDocument(); + expect(screen.getAllByRole("treeitem")).toHaveLength(3); +}); diff --git a/src/features/vcs/common.module.css b/src/features/vcs/common.module.css index 030e549b6..fc399915c 100644 --- a/src/features/vcs/common.module.css +++ b/src/features/vcs/common.module.css @@ -322,17 +322,30 @@ .manageRowSelected:hover { background: var(--bg-list-selected); } -/* A fixed slot, so the names line up whether or not a row is marked. */ -.worktreeMark { - display: flex; - flex: none; - width: 14px; - margin-right: -4px; +/* Worktrees dialog: the tree needs a height of its own inside the auto-sized body. */ +.worktreeListDialog { + height: 340px; + min-height: 0; } -/* The branch keeps its width; the path is what gets cut short. */ -.worktreeName { +/* Worktrees tree rows: the worktree name keeps its width, the path is what gets cut short. */ +.worktreeIcon { flex: none; - max-width: 40%; + margin-right: 2px; + color: var(--fg-muted); +} +.worktreeLabel { + flex: none; + max-width: 45%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.worktreeDetail { + min-width: 0; + margin-left: 4px; + overflow: hidden; + text-overflow: ellipsis; + color: var(--fg-muted); } .comparisonDialog > [data-dialog-body] { diff --git a/src/features/vcs/gitMenuDialogs.tsx b/src/features/vcs/gitMenuDialogs.tsx index 5f0d36b04..2d24b5666 100644 --- a/src/features/vcs/gitMenuDialogs.tsx +++ b/src/features/vcs/gitMenuDialogs.tsx @@ -1,18 +1,30 @@ /** Git menu dialogs: Pull…, Merge…, Rebase…, Manage Remotes… and Worktrees. */ -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import clsx from "clsx"; import type { Branch, MergeOptions, Remote, Worktree } from "@/bindings"; import { executeAction } from "@/platform/actions"; import { call, commands } from "@/platform/ipc"; import { notifications } from "@/workbench/contributions"; -import { Checkbox, Dialog, Icon, IconButton, Input, Select, T, useSanitizedField, type SelectOption } from "@/ui"; +import { + Checkbox, + Dialog, + HighlightedText, + Icon, + IconButton, + Input, + Select, + T, + Tree, + useSanitizedField, + type SelectOption, +} from "@/ui"; import { sortLocal, sortRemote } from "./branches"; import { conflictActions, openConflictsInstead, validateBranchName } from "./branchOps"; import { cleanUpBranchNameOnTyping } from "@/platform/gitRefName"; import { confirmDialog, openDialog } from "./dialogs"; import { errorMessage, runOp, runRemote, saveAll } from "./ops"; -import { currentRepo, loadBranches, refreshNow, repoName, selectRepo, useVcs } from "./store"; -import { isCurrentWorktree } from "./worktrees"; +import { currentRepo, loadBranches, refreshNow, repoName, useVcs } from "./store"; +import { groupWorktrees, isCurrentWorktree, type RepoWorktrees } from "./worktrees"; import styles from "./common.module.css"; import { tx } from "@/platform/i18n"; @@ -83,8 +95,7 @@ function MergeFlags({ ); } -function useBranches(): Branch[] { - const [repo] = useState(currentRepo); +function useBranches(repo: string | null): Branch[] { const [branches, setBranches] = useState([]); useEffect(() => { let live = true; @@ -94,7 +105,7 @@ function useBranches(): Branch[] { return () => { live = false; }; - }, []); + }, [repo]); return branches; } @@ -118,7 +129,7 @@ export function showPullDialog() { function PullDialog({ onClose }: { onClose: () => void }) { const [repo] = useState(currentRepo); const [info] = useState(() => useVcs.getState().branch); - const branches = useBranches(); + const branches = useBranches(repo); const [remotes, setRemotes] = useState(null); const [remote, setRemote] = useState(null); const [chosenBranch, setBranch] = useState(null); @@ -224,7 +235,7 @@ export function showMergeDialog() { function MergeDialog({ onClose }: { onClose: () => void }) { const [repo] = useState(currentRepo); const [head] = useState(() => useVcs.getState().branch?.head ?? null); - const branches = useBranches(); + const branches = useBranches(repo); const [branch, setBranch] = useState(null); const [flags, setFlags] = useState>(() => new Set()); const [withMessage, setWithMessage] = useState(false); @@ -296,7 +307,7 @@ export function showRebaseDialog() { function RebaseDialog({ onClose }: { onClose: () => void }) { const [repo] = useState(currentRepo); const [head] = useState(() => useVcs.getState().branch?.head ?? null); - const branches = useBranches(); + const branches = useBranches(repo); const [upstream, setUpstream] = useState(null); const [useOnto, setUseOnto] = useState(false); const [onto, setOnto] = useState(null); @@ -545,9 +556,10 @@ export function showWorktrees() { openDialog((close) => ); } -export function showNewWorktree(onCreated?: () => void) { +export function showNewWorktree(onCreated?: () => void, repo?: string | null) { openDialog((close) => ( { close(); if (created) onCreated?.(); @@ -570,31 +582,81 @@ function WorktreesDialog({ onClose }: { onClose: () => void }) { ); } +/** A repository section of the Worktrees tree; its children are the repository's worktrees. */ +interface WorktreeNode { + key: string; + label: string; + worktree?: Worktree; + children?: WorktreeNode[]; +} + /** - * The worktrees of the current repository with New / Remove / Open: the Worktrees - * dialog's content and the Git tool window's Worktrees tab. `listClassName` replaces - * the dialog's bordered list box. + * The worktrees of every repository of the project as one tree, styled like the Log's + * Branches pane, with New / Remove / Open: the Worktrees dialog's content and the Git + * tool window's Worktrees tab. `listClassName` replaces the dialog's default height. */ export function WorktreesPanel({ className, listClassName }: { className?: string; listClassName?: string }) { const repo = useVcs((s) => s.repo); const repos = useVcs((s) => s.roots); - const [list, setList] = useState([]); + const [groups, setGroups] = useState([]); + /** The selected tree row (`r:` repository, `w:` worktree). */ const [selected, setSelected] = useState(null); const [error, setError] = useState(null); - const reload = () => - call(commands.vcsWorktrees(repo)) - .then((l) => { - setList(l); - setError(null); - }) - .catch((e) => setError(errorMessage(e))); + /** The last reload wins: a scan of the roots may change them while it runs. */ + const run = useRef(0); + const reload = () => { + const id = ++run.current; + return Promise.all( + repos.map((root) => + call(commands.vcsWorktrees(root)).then( + (worktrees): RepoWorktrees => ({ root, worktrees }), + (e): RepoWorktrees => ({ root, worktrees: [], error: errorMessage(e) }), + ), + ), + ).then((lists) => { + if (id !== run.current) return; + setGroups(groupWorktrees(lists)); + setError(null); + }); + }; useEffect(() => { void reload(); - }, [repo]); - const current = list.find((w) => w.path === selected); + }, [repos]); + const tree = useMemo( + () => + groups.map((group): WorktreeNode => ({ + // The main worktree names a repository across reloads (its linked roots come and go). + key: `r:${group.worktrees.find((w) => w.main)?.path ?? group.root}`, + label: repoName(group.root), + children: group.worktrees.map((w): WorktreeNode => ({ + key: `w:${w.path}`, + label: w.branch ?? (w.head ? w.head.slice(0, 8) : tx("(bare)")), + worktree: w, + })), + })), + [groups], + ); + const currentGroup = selected?.startsWith("w:") + ? groups.find((g) => g.worktrees.some((w) => `w:${w.path}` === selected)) + : undefined; + const current = currentGroup?.worktrees.find((w) => `w:${w.path}` === selected); + const selectedKeys = useMemo(() => new Set(selected ? [selected] : []), [selected]); + const isCurrent = (path: string) => repos.some((root) => isCurrentWorktree(path, root)); + const icon = (node: WorktreeNode) => + node.worktree ? (isCurrent(node.worktree.path) ? "current-branch" : "branch") : "folder"; + const detail = (node: WorktreeNode) => { + const w = node.worktree; + if (!w) return ""; + const current = isCurrent(w.path); + // "main" only when the main worktree is not the one this window works in. + const flags = [ + current ? tx("current") : w.main ? "main" : w.locked ? tx("locked") : w.prunable ? tx("prunable") : "", + ].filter(Boolean); + return [...flags, w.path].join(" "); + }; const remove = async () => { - if (!current || current.main) return; + if (!current || !currentGroup || current.main) return; const ok = await confirmDialog({ title: "Remove Worktree", message: tx`Remove the worktree at ${current.path}? Its folder is deleted.`, @@ -603,7 +665,7 @@ export function WorktreesPanel({ className, listClassName }: { className?: strin }); if (!ok) return; try { - await call(commands.vcsRemoveWorktree(repo, current.path, false)); + await call(commands.vcsRemoveWorktree(currentGroup.root, current.path, false)); } catch (e) { const force = await confirmDialog({ title: "Remove Worktree", @@ -612,7 +674,9 @@ export function WorktreesPanel({ className, listClassName }: { className?: strin danger: true, }); if (force) - await call(commands.vcsRemoveWorktree(repo, current.path, true)).catch((x) => setError(errorMessage(x))); + await call(commands.vcsRemoveWorktree(currentGroup.root, current.path, true)).catch((x) => + setError(errorMessage(x)), + ); } setSelected(null); await reload(); @@ -621,20 +685,12 @@ export function WorktreesPanel({ className, listClassName }: { className?: strin return (
- {repos.length > 1 && ( -