From 62fa9bc39d01f3266066ed78e15f13e188567b78 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Tue, 27 Oct 2026 16:43:56 +0800 Subject: [PATCH] feat(vcs): Repository list in the Branches popup for multi-repository projects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With several repositories the Branches popup no longer starts with a wrapping row of repository chips. It now shows: - the search field ("Search for branches, actions, repositories") with Fetch and a Git settings button, then the actions; - one row per repository: a colour square (project colour tokens), its name, its current branch and a chevron. Hovering a row (or →/Enter) opens that repository's branches in a submenu, so switching one repository's branch takes two clicks; - when every repository is on the same branch, collapsible "Common Local Branches" and "Common Remote Branches" (grouped by remote). Their entries act on all repositories: Checkout, New Branch from…, Rebase / Merge, Delete; - otherwise the current repository's (active file's, else the selected one's) Recent, Local and Remote branches and Tags, in collapsible groups. Search matches actions, repository names and branches, and lists the other repositories' matching branches below. Expanded groups are remembered. The title bar widget shows only the branch while all repositories share it, and "repository / branch" once they differ. New Branch… from the popup creates the branch in every repository. Single-repository projects keep the existing popup. Backend: `vcs_roots` reports each repository's current branch and HEAD commit (cheap, no upstream walk), so the widget and the repository rows need no extra calls per refresh. Claude-Session: https://claude.ai/code/session_01VUYsdXA2nkF5v6zsC93dpn --- crates/ide-vcs/src/lib.rs | 5 + crates/ide-vcs/src/refs.rs | 5 + crates/ide-vcs/tests/repo.rs | 2 + src-tauri/src/commands/vcs.rs | 9 +- src/bindings.ts | 4 + src/features/vcs/BranchWidget.module.css | 67 ++- src/features/vcs/BranchWidget.tsx | 550 +++++++++++++++--- .../vcs/__tests__/repoBranches.test.ts | 245 ++++++++ src/features/vcs/actions.tsx | 8 +- src/features/vcs/branchMenu.ts | 60 ++ src/features/vcs/branchOps.ts | 123 +++- src/features/vcs/repoBranches.ts | 293 ++++++++++ src/features/vcs/store.ts | 17 +- src/ui/List.tsx | 5 +- 14 files changed, 1275 insertions(+), 118 deletions(-) create mode 100644 src/features/vcs/__tests__/repoBranches.test.ts create mode 100644 src/features/vcs/repoBranches.ts diff --git a/crates/ide-vcs/src/lib.rs b/crates/ide-vcs/src/lib.rs index 950debbf6..51d9e98f3 100644 --- a/crates/ide-vcs/src/lib.rs +++ b/crates/ide-vcs/src/lib.rs @@ -507,6 +507,11 @@ impl Repo { pub fn branch_info(&self) -> IdeResult { refs::branch_info(self) } + /// Current branch (short name; `None` when detached) and HEAD commit, without + /// upstream tracking (cheap enough to call for every repository on each refresh). + pub fn head(&self) -> IdeResult<(Option, Option)> { + refs::head(&self.gix()?) + } /// Merge / rebase / cherry-pick… in progress (cheap: no ref walking). pub fn operation(&self) -> IdeResult> { Ok(refs::operation(&self.gix()?)) diff --git a/crates/ide-vcs/src/refs.rs b/crates/ide-vcs/src/refs.rs index 5b7903054..1be20430d 100644 --- a/crates/ide-vcs/src/refs.rs +++ b/crates/ide-vcs/src/refs.rs @@ -30,6 +30,11 @@ pub(crate) fn branch_info(r: &Repo) -> IdeResult { Ok(info) } +pub(crate) fn head(repo: &gix::Repository) -> IdeResult<(Option, Option)> { + let name = repo.head_name().map_err(gx)?.map(|n| to_string(n.as_ref().shorten())); + Ok((name, repo.head_id().ok().map(|id| id.to_string()))) +} + pub(crate) fn operation(repo: &gix::Repository) -> Option { use gix::state::InProgress::*; let op = match repo.state()? { diff --git a/crates/ide-vcs/tests/repo.rs b/crates/ide-vcs/tests/repo.rs index 64f8a64ec..b202cd78f 100644 --- a/crates/ide-vcs/tests/repo.rs +++ b/crates/ide-vcs/tests/repo.rs @@ -865,6 +865,7 @@ fn checkout_create_detached_delete_rebase() { repo.checkout("topic", true).unwrap(); assert_eq!(repo.branch_info().unwrap().head.as_deref(), Some("topic")); + assert_eq!(repo.head().unwrap(), (Some("topic".to_string()), Some(c1.clone()))); fx.write("t", "topic\n"); fx.commit_all("topic work"); repo.checkout("main", false).unwrap(); @@ -879,6 +880,7 @@ fn checkout_create_detached_delete_rebase() { let info = repo.branch_info().unwrap(); assert_eq!(info.head, None); assert_eq!(info.head_commit.as_deref(), Some(c1.as_str())); + assert_eq!(repo.head().unwrap(), (None, Some(c1.clone()))); assert!( repo.log(&LogQuery { branch: Some("HEAD".into()), ..Default::default() }).unwrap()[0] .refs diff --git a/src-tauri/src/commands/vcs.rs b/src-tauri/src/commands/vcs.rs index 7f3a8ebfd..dc0582c2b 100644 --- a/src-tauri/src/commands/vcs.rs +++ b/src-tauri/src/commands/vcs.rs @@ -41,6 +41,10 @@ pub struct VcsRoot { pub root: ide_core::IpcPath, /// Merge / rebase / cherry-pick / revert in progress. pub operation: Option, + /// Current branch; `None` when HEAD is detached (or unreadable). + pub head: Option, + /// HEAD commit; `None` before the first commit. + pub head_commit: Option, } /// The project's repositories, the primary one first. @@ -53,7 +57,10 @@ pub fn vcs_roots(window: Window, state: State<'_, AppState>) -> IdeResult(() => ({ open: false })); @@ -37,8 +48,11 @@ export function headLabel(branch: { head: string | null; headCommit: string | nu export function BranchWidget() { const available = useVcs((s) => s.available); const branch = useVcs((s) => s.branch); - // With several repositories the widget names the selected one, like IDEA's. - const repo = useVcs((s) => (s.roots.length > 1 && s.repo ? repoName(s.repo) : null)); + const count = useVcs((s) => s.roots.length); + // Several repositories on different branches: the widget names the selected one. + const repo = useVcs((s) => + s.repo && widgetShowsRepo(s.roots.map((r) => ({ head: s.heads[r]?.head ?? null }))) ? repoName(s.repo) : null, + ); const open = useBranchesPopup((s) => s.open); const ref = useRef(null); if (!available || !branch) return null; @@ -52,6 +66,7 @@ export function BranchWidget() { (branch.upstream ? `Git branch: ${headLabel(branch)}${op}\nTracking ${branch.upstream}` : `Git branch: ${headLabel(branch)}${op}`) + + (count > 1 && !repo ? `\nIn all ${count} repositories` : "") + (sync.length ? `\n${sync.join(", ")} commits` : ""); return ( <> @@ -92,6 +107,12 @@ const POPUP_ACTIONS: PopupAction[] = [ { id: "vcs.checkoutRevision", label: "Checkout Tag or Revision…", icon: "branch" }, ]; +/** A single repository's own entries (its submenu in the popup with several repositories). */ +const REPO_ACTIONS: PopupAction[] = [ + { id: "vcs.newBranch", label: "New Branch…", icon: "plus" }, + { id: "vcs.checkoutRevision", label: "Checkout Tag or Revision…", icon: "branch" }, +]; + /** Entries IDEA puts at the top of the Branches popup while an operation is stopped. */ export function operationActions(op: InProgress | null, conflicts: number): PopupAction[] { if (!op) return []; @@ -118,42 +139,105 @@ export function operationActions(op: InProgress | null, conflicts: number): Popu return out; } +const NO_BRANCHES: readonly Branch[] = []; +const INDENT = 16; +const ROW_HEIGHT = 24; +const SEPARATOR_HEIGHT = 9; +const listHeight = (rows: readonly { type: string; key: string }[]) => + Math.min( + 440, + Math.max( + ROW_HEIGHT * 3, + rows.reduce( + (h, r) => h + (r.type === "section" && r.key.startsWith("separator:") ? SEPARATOR_HEIGHT : ROW_HEIGHT), + 0, + ), + ), + ); + +/** The option element of the row with `key`, to anchor a submenu on. */ +function rowElement(list: HTMLElement | null, key: string): HTMLElement | null { + const el = list?.querySelector(`[data-row-key="${CSS.escape(key)}"]`); + return el ? ((el.closest('[role="option"]') as HTMLElement | null) ?? el) : null; +} + +/** Caret at the end (→ opens) / start (← closes) of the search field. */ +const caretAt = (e: KeyboardEvent, where: "start" | "end") => { + const input = e.target as HTMLInputElement; + if (input.selectionStart !== input.selectionEnd) return false; + return where === "end" ? input.selectionStart === input.value.length : input.selectionStart === 0; +}; + function BranchesPopup({ anchor, onClose }: { anchor: RefObject; onClose: () => void }) { - const branches = useVcs((s) => s.branches); - const loaded = useVcs((s) => s.branchesLoaded); - const current = useVcs((s) => s.branch?.head ?? null); + const multi = useVcs((s) => s.roots.length > 1); + return ( + + {multi ? : } + + ); +} + +interface BranchesPanelProps { + onClose: () => void; + actions: readonly PopupAction[]; + /** A repository other than the selected one (the submenu of a repository): it is selected before acting. */ + root?: string; + /** Its branches until the selected repository's are loaded. */ + fallback?: readonly Branch[]; + /** ← at the start of the search field (back to the repositories). */ + onBack?: () => void; +} + +/** Search, actions and the branches of one repository (the selected one, or `root`). */ +function BranchesPanel({ onClose, actions: baseActions, root, fallback, onBack }: BranchesPanelProps) { + const selected = useVcs((s) => s.repo); + const live = root === undefined || selected === root; + const storeBranches = useVcs((s) => s.branches); + const storeLoaded = useVcs((s) => s.branchesLoaded); + const branches = live && (storeLoaded || !fallback) ? storeBranches : (fallback ?? NO_BRANCHES); + const loaded = (live && storeLoaded) || !!fallback; + const current = useVcs((s) => + live ? (s.branch?.head ?? null) : root ? (s.heads[root]?.head ?? null) : (s.branch?.head ?? null), + ); const [query, setQuery] = useState(""); const [recent] = useState(recentBranches); const [active, setActive] = useState(0); const [menuFor, setMenuFor] = useState<{ items: MenuItem[]; label: string; el: HTMLElement } | null>(null); const listRef = useRef(null); - const repo = useVcs((s) => s.repo); const favorites = useFavoriteBranches((s) => s.names); const [tags, setTags] = useState([]); useEffect(() => { - void loadBranches().catch(() => undefined); + if (root === undefined) void loadBranches().catch(() => undefined); loadFavoriteBranches(); - }, []); + }, [root]); useEffect(() => { let live = true; - call(commands.vcsTags(currentRepo())).then( + call(commands.vcsTags(root ?? currentRepo())).then( (t) => live && setTags(t), () => live && setTags([]), ); return () => { live = false; }; - }, [repo, branches]); + }, [root, selected, branches]); - const op = useVcs((s) => inProgress(s.branch?.operation)); + const op = useVcs((s) => inProgress(root ? s.operations[root] : s.branch?.operation)); const conflicts = useVcs((s) => s.status.filter((f) => f.unstaged === "conflicted").length); - const actions = useMemo(() => [...operationActions(op, conflicts), ...POPUP_ACTIONS], [op, conflicts]); + const actions = useMemo(() => [...operationActions(op, conflicts), ...baseActions], [op, conflicts, baseActions]); const rows = useMemo( () => buildBranchRows(branches, recent, query, actions, favorites, tags), [branches, recent, query, actions, favorites, tags], ); const isSection = (r: BranchRow) => r.type === "section"; const activeIndex = rows[active] && !isSection(rows[active]!) ? active : firstSelectable(rows, isSection); + const scoped = (items: MenuItem[]) => (root ? inRepo(root, items) : items); const closeAll = () => { setMenuFor(null); @@ -162,13 +246,12 @@ function BranchesPopup({ anchor, onClose }: { anchor: RefObject { const row = rows[i]; if (row?.type !== "branch" && row?.type !== "tag") return; - const el = listRef.current?.querySelector(`[data-row-key="${CSS.escape(row.key)}"]`); + const el = rowElement(listRef.current, row.key); if (!el) return; - const anchorEl = (el.closest('[role="option"]') as HTMLElement | null) ?? el; setMenuFor( row.type === "branch" - ? { items: branchMenu(row.branch, current, closeAll), label: `Branch ${row.branch.name}`, el: anchorEl } - : { items: tagMenu(row.tag, closeAll), label: `Tag ${row.tag}`, el: anchorEl }, + ? { items: scoped(branchMenu(row.branch, current, closeAll)), label: `Branch ${row.branch.name}`, el } + : { items: scoped(tagMenu(row.tag, closeAll)), label: `Tag ${row.tag}`, el }, ); }; @@ -177,7 +260,8 @@ function BranchesPopup({ anchor, onClose }: { anchor: RefObject executeAction(id, arg)); } else if (row.type === "branch" || row.type === "tag") { openMenu(i); } @@ -191,13 +275,17 @@ function BranchesPopup({ anchor, onClose }: { anchor: RefObject - + <>
{ setQuery(v); setActive(0); }} - placeholder="Search branches and actions" - aria-label="Search branches and actions" + placeholder={label} + aria-label={label} variant="bare" onKeyDown={onKeyDown} /> - + {!root && }
-
+
r.key} @@ -244,60 +323,377 @@ function BranchesPopup({ anchor, onClose }: { anchor: RefObject activate(i)} focusable={false} hoverSelects - rowHeight={rowHeight} + rowHeight={ROW_HEIGHT} aria-label="Branches" className={styles.list} empty={{loaded ? "No matching branches" : "Loading…"}} renderItem={(r) => } />
- {menuFor && ( - setMenuFor(null)} - className={styles.menuPopup} - > - setMenuFor(null)} - onCloseLevel={() => setMenuFor(null)} - aria-label={menuFor.label} - /> - - )} + {menuFor && setMenuFor(null)} />} + + ); +} + +function SubMenu({ + items, + label, + el, + onClose, +}: { + items: MenuItem[]; + label: string; + el: HTMLElement; + onClose: () => void; +}) { + return ( + + ); } -/** Repository switcher (several repositories): branches, log and push act on the selected one. */ -function RepositoryBar() { +const EXPANDED_KEY = "openidea.vcs.branchesPopup.expanded"; + +function loadExpanded(): Set { + try { + const v: unknown = JSON.parse(localStorage.getItem(EXPANDED_KEY) ?? "null"); + if (Array.isArray(v)) return new Set(v.filter((x): x is string => typeof x === "string")); + } catch { + // Storage unavailable: defaults. + } + return new Set(DEFAULT_EXPANDED); +} + +function saveExpanded(expanded: ReadonlySet) { + try { + localStorage.setItem(EXPANDED_KEY, JSON.stringify([...expanded])); + } catch { + // Expansion is remembered for the session only. + } +} + +/** The repository of the active file, else the selected one, else the first. */ +function contextRepo(roots: readonly string[]): string | null { + const path = maybeEditorService()?.activePath(); + return (path ? repoOf(path, roots) : null) ?? currentRepo() ?? roots[0] ?? null; +} + +/** + * Branches popup of a project with several repositories: actions; the repositories, + * each opening its own branches (on hover or →); then the branches they all have + * (acting on all of them) or, when their branches differ, the current repository's. + */ +function MultiRepoPanel({ onClose }: { onClose: () => void }) { const roots = useVcs((s) => s.roots); - const repo = useVcs((s) => s.repo); - if (roots.length < 2) return null; + const heads = useVcs((s) => s.heads); + const operations = useVcs((s) => s.operations); + const version = useVcs((s) => s.version); + const favorites = useFavoriteBranches((s) => s.names); + const [current] = useState(() => contextRepo(roots)); + const [recent] = useState(recentBranches); + const [tags, setTags] = useState([]); + const [query, setQuery] = useState(""); + const [active, setActive] = useState(0); + const [expanded, setExpanded] = useState>(loadExpanded); + const [all, setAll] = useState>({}); + const [menuFor, setMenuFor] = useState<{ items: MenuItem[]; label: string; el: HTMLElement } | null>(null); + const [repoMenu, setRepoMenu] = useState<{ root: string; el: HTMLElement; focus: boolean } | null>(null); + const listRef = useRef(null); + const searchRef = useRef(null); + /** Back from a repository's submenu to the search field. */ + const closeRepoMenu = () => { + setRepoMenu(null); + searchRef.current?.focus(); + }; + + useEffect(() => loadFavoriteBranches(), []); + // Every repository's branches (common ones, submenus, search); refreshed after operations. + useEffect(() => { + let live = true; + void Promise.all( + roots.map((r) => + call(commands.vcsBranches(r)).then( + (list) => [r, list] as const, + () => [r, [] as Branch[]] as const, + ), + ), + ).then((entries) => live && setAll(Object.fromEntries(entries))); + return () => { + live = false; + }; + }, [roots, version]); + useEffect(() => { + let live = true; + if (current) + call(commands.vcsTags(current)).then( + (t) => live && setTags(t), + () => live && setTags([]), + ); + return () => { + live = false; + }; + }, [current, version]); + + const repos = useMemo( + () => + roots.map((root) => ({ + root, + name: repoName(root), + head: heads[root]?.head ?? null, + headCommit: heads[root]?.headCommit ?? null, + })), + [roots, heads], + ); + const op = useVcs((s) => inProgress(s.branch?.operation)); + const conflicts = useVcs((s) => s.status.filter((f) => f.unstaged === "conflicted").length); + const actions = useMemo( + () => [ + ...operationActions(op, conflicts), + ...POPUP_ACTIONS.map((a) => (a.id === "vcs.newBranch" ? { ...a, arg: { repos: roots } } : a)), + ], + [op, conflicts, roots], + ); + const rows = useMemo( + () => buildMultiRepoRows({ repos, branches: all, query, actions, expanded, favorites, current, recent, tags }), + [repos, all, query, actions, expanded, favorites, current, recent, tags], + ); + const isSection = (r: RepoRow) => r.type === "section"; + const activeIndex = rows[active] && !isSection(rows[active]!) ? active : firstSelectable(rows, isSection); + + const closeAll = () => { + setMenuFor(null); + setRepoMenu(null); + onClose(); + }; + /** Opens the submenu of row `i`; `focus` moves the keyboard into a repository's submenu. */ + const openSub = (i: number, focus: boolean) => { + const row = rows[i]; + if (!row) return; + const el = rowElement(listRef.current, row.key); + if (!el) return; + if (row.type === "repo") { + setMenuFor(null); + setRepoMenu((prev) => + prev?.root === row.repo.root && prev.el === el && prev.focus === focus + ? prev + : { root: row.repo.root, el, focus }, + ); + return; + } + setRepoMenu(null); + if (row.type === "common") { + setMenuFor({ items: commonBranchMenu(row.branch, roots, closeAll), label: `Branch ${row.branch.name}`, el }); + } else if (row.type === "branch") { + const items = inRepo(row.repo.root, branchMenu(row.branch, row.repo.head, closeAll)); + setMenuFor({ items, label: `Branch ${row.branch.name} of ${row.repo.name}`, el }); + } else if (row.type === "tag") { + setMenuFor({ items: inRepo(row.repo.root, tagMenu(row.tag, closeAll)), label: `Tag ${row.tag}`, el }); + } + }; + const setOpen = (key: string, open: boolean) => + setExpanded((prev) => { + if (prev.has(key) === open) return prev; + const next = toggleExpanded(prev, key); + saveExpanded(next); + return next; + }); + + const activate = (i: number) => { + const row = rows[i]; + if (!row) return; + if (row.type === "action") { + onClose(); + void executeAction(row.action.id, row.action.arg); + } else if (row.type === "group") { + if (!query.trim()) setOpen(row.key, !row.expanded); + } else { + openSub(i, true); + } + }; + // The pointer on a repository opens its branches right away; elsewhere it closes them. + const hover = (i: number) => { + setActive(i); + if (rows[i]?.type === "repo") openSub(i, false); + else setRepoMenu(null); + }; + + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + activate(activeIndex); + return; + } + const row = rows[activeIndex]; + if (e.key === "ArrowRight" && row && row.type !== "action" && row.type !== "section") { + if (!caretAt(e, "end")) return; + e.preventDefault(); + if (row.type === "group") { + if (!query.trim()) setOpen(row.key, true); + } else openSub(activeIndex, true); + return; + } + if (e.key === "ArrowLeft" && row?.type === "group" && row.expanded) { + if (!caretAt(e, "start") || query.trim()) return; + e.preventDefault(); + setOpen(row.key, false); + return; + } + const next = listNavigate(e.key, { items: rows, active: activeIndex, isSection, wrap: true }); + if (next !== null) { + e.preventDefault(); + setActive(next); + } + }; + return ( -
- {roots.map((r) => ( - - ))} -
+ + + )} + ); } -function BranchRowView({ row }: { row: BranchRow }) { +function RepoRowView({ row, operation }: { row: RepoRow; operation?: string }) { + switch (row.type) { + case "action": + case "branch": + case "tag": + return ; + case "repo": + return ( + + + ); + case "group": + return ( + + + {row.depth > 0 && } + {row.label} + + ); + case "common": + return ( + + + + {row.branch.current && current} + + + ); + default: + return null; + } +} + +function BranchRowView({ row }: { row: BranchRow | Extract }) { + const indent = "depth" in row && row.depth ? { paddingLeft: row.depth * INDENT } : undefined; if (row.type === "action") { const keys = shortcutLabel(row.action.id); return ( @@ -310,7 +706,7 @@ function BranchRowView({ row }: { row: BranchRow }) { } if (row.type === "tag") { return ( - + + {b.current ? ( ) : ( diff --git a/src/features/vcs/__tests__/repoBranches.test.ts b/src/features/vcs/__tests__/repoBranches.test.ts new file mode 100644 index 000000000..8004a36f3 --- /dev/null +++ b/src/features/vcs/__tests__/repoBranches.test.ts @@ -0,0 +1,245 @@ +import { describe, expect, it } from "vitest"; +import type { Branch } from "@/bindings"; +import { + buildMultiRepoRows, + commonBranches, + DEFAULT_EXPANDED, + repoColorIndex, + repoHeadLabel, + toggleExpanded, + widgetShowsRepo, + type RepoEntry, + type RepoRow, +} from "../repoBranches"; + +const b = (name: string, opts: Partial = {}): Branch => ({ + name, + remote: name.startsWith("origin/") || name.startsWith("upstream/"), + current: false, + upstream: null, + ahead: 0, + behind: 0, + lastCommitMs: 0, + commit: "", + ...opts, +}); + +const repo = (name: string, head: string | null = "dev"): RepoEntry => ({ + root: `/p/${name}`, + name, + head, + headCommit: "0123456789abcdef", +}); + +// The reported layout: several repositories, all on `dev`. +const repos = [repo("ology-collect"), repo("ology-dependencies"), repo("ology-gateway")]; +const branches: Record = { + "/p/ology-collect": [ + b("dev", { current: true }), + b("main"), + b("collect-only"), + b("origin/HEAD"), + b("origin/dev"), + b("origin/main"), + b("origin/feature/x"), + ], + "/p/ology-dependencies": [b("dev", { current: true }), b("main"), b("origin/dev"), b("origin/main")], + "/p/ology-gateway": [b("dev", { current: true }), b("main"), b("origin/dev"), b("origin/main"), b("gw-fix")], +}; +const actions = [ + { id: "vcs.update", label: "Update Project…" }, + { id: "vcs.newBranch", label: "New Branch…" }, +]; + +const summary = (rows: RepoRow[]) => + rows.map((r) => { + switch (r.type) { + case "section": + return r.label ? `# ${r.label}` : "---"; + case "action": + return `> ${r.action.label}`; + case "repo": + return `repo ${r.repo.name} [${r.repo.head}] c${r.color}`; + case "group": + return `${" ".repeat(r.depth)}${r.expanded ? "v" : ">"} ${r.label}`; + case "common": + return `${" ".repeat(r.depth)}${r.label}${r.branch.current ? " (current)" : ""}`; + case "branch": + return `${" ".repeat(r.depth)}${r.repo.name}:${r.branch.name}`; + case "tag": + return `${" ".repeat(r.depth)}tag ${r.tag}`; + } + }); + +describe("widgetShowsRepo", () => { + it.each([ + ["one repository", [{ head: "main" }], false], + ["all on the same branch", [{ head: "dev" }, { head: "dev" }, { head: "dev" }], false], + ["one on another branch", [{ head: "dev" }, { head: "feature" }, { head: "dev" }], true], + ["one detached", [{ head: "dev" }, { head: null }], true], + ["all detached", [{ head: null }, { head: null }], true], + ["no repositories", [], false], + ])("%s", (_name, heads, shows) => { + expect(widgetShowsRepo(heads)).toBe(shows); + }); +}); + +describe("commonBranches", () => { + it("keeps the branches every repository has, current in all first, then favourites", () => { + const common = commonBranches(Object.values(branches)); + expect(common.local.map((x) => [x.name, x.current, x.favorite])).toEqual([ + ["dev", true, false], + ["main", false, true], + ]); + expect(common.remote.map((x) => x.name)).toEqual(["origin/main", "origin/dev"]); + }); + + it("is current only when checked out everywhere", () => { + const common = commonBranches([[b("dev", { current: true })], [b("dev")]]); + expect(common.local).toEqual([{ name: "dev", remote: false, current: false, favorite: false }]); + }); + + it("does not match a local branch with a remote one of the same name", () => { + expect(commonBranches([[b("origin/x", { remote: false })], [b("origin/x")]])).toEqual({ local: [], remote: [] }); + expect(commonBranches([])).toEqual({ local: [], remote: [] }); + }); +}); + +describe("buildMultiRepoRows", () => { + const input = { repos, branches, actions, expanded: new Set(DEFAULT_EXPANDED) }; + + it("lists actions, repositories with their branch, then the common groups", () => { + expect(summary(buildMultiRepoRows({ ...input, query: "" }))).toEqual([ + "> Update Project…", + "> New Branch…", + "---", + "repo ology-collect [dev] c1", + "repo ology-dependencies [dev] c2", + "repo ology-gateway [dev] c3", + "---", + "v Common Local Branches", + " dev (current)", + " main", + "> Common Remote Branches", + ]); + }); + + it("groups common remote branches by remote when expanded", () => { + const expanded = toggleExpanded(toggleExpanded(new Set(), "common:remote"), "common:folder:origin"); + expect(summary(buildMultiRepoRows({ ...input, expanded, query: "" })).slice(-6)).toEqual([ + "---", + "> Common Local Branches", + "v Common Remote Branches", + " v origin", + " main", + " dev", + ]); + }); + + it("filters repositories and branches, expands every group and lists each repository's own matches", () => { + expect(summary(buildMultiRepoRows({ ...input, expanded: new Set(), query: "dev" }))).toEqual([ + "v Common Local Branches", + " dev (current)", + "v Common Remote Branches", + " v origin", + " dev", + ]); + expect(summary(buildMultiRepoRows({ ...input, query: "deps" }))).toEqual(["repo ology-dependencies [dev] c2"]); + expect(summary(buildMultiRepoRows({ ...input, query: "fix" }))).toEqual([ + "# ology-gateway", + "ology-gateway:gw-fix", + ]); + expect(summary(buildMultiRepoRows({ ...input, query: "collect" }))).toEqual([ + "repo ology-collect [dev] c1", + "# ology-collect", + "ology-collect:collect-only", + ]); + }); + + it("highlights the shown part of a remote branch name", () => { + const rows = buildMultiRepoRows({ ...input, query: "origin/dev" }); + const dev = rows.find((r) => r.type === "common" && r.branch.name === "origin/dev"); + expect(dev?.type === "common" && [dev.label, dev.matches]).toEqual(["dev", [0, 1, 2]]); + }); + + it("shows no common branches until every repository's branches are loaded", () => { + const partial = { "/p/ology-collect": branches["/p/ology-collect"]! }; + expect(summary(buildMultiRepoRows({ ...input, branches: partial, query: "" })).at(-1)).toBe( + "repo ology-gateway [dev] c3", + ); + }); +}); + +describe("buildMultiRepoRows with repositories on different branches", () => { + const diverged = [repo("ology-collect", "release/1.0"), repo("ology-dependencies"), repo("ology-gateway")]; + const own: Record = { + ...branches, + "/p/ology-collect": [ + b("release/1.0", { current: true }), + b("dev"), + b("main"), + b("origin/dev"), + b("origin/release/1.0"), + ], + }; + const input = { + repos: diverged, + branches: own, + actions: [], + expanded: new Set(DEFAULT_EXPANDED), + recent: ["dev", "release/1.0", "gone"], + tags: ["v1.2", "v1.10"], + }; + + it("lists the current repository's recent, local, remote branches and tags instead of the common ones", () => { + expect(summary(buildMultiRepoRows({ ...input, current: "/p/ology-collect", query: "" }))).toEqual([ + "repo ology-collect [release/1.0] c1", + "repo ology-dependencies [dev] c2", + "repo ology-gateway [dev] c3", + "---", + "> Recent Branches in ology-collect", + "v Local Branches in ology-collect", + " ology-collect:release/1.0", + " ology-collect:main", + " ology-collect:dev", + "> Remote Branches in ology-collect", + "> Tags in ology-collect", + ]); + }); + + it("falls back to the first repository and expands the matching lists on search", () => { + const expanded = new Set(["repo:tags", "repo:recent"]); + const rows = summary(buildMultiRepoRows({ ...input, expanded, current: "/elsewhere", query: "" })); + expect(rows.slice(4)).toEqual([ + "v Recent Branches in ology-collect", + " ology-collect:dev", + "> Local Branches in ology-collect", + "> Remote Branches in ology-collect", + "v Tags in ology-collect", + " tag v1.10", + " tag v1.2", + ]); + // Search: the current repository's lists, then the other repositories' branches. + expect(summary(buildMultiRepoRows({ ...input, current: "/p/ology-gateway", query: "fix" }))).toEqual([ + "v Local Branches in ology-gateway", + " ology-gateway:gw-fix", + ]); + expect(summary(buildMultiRepoRows({ ...input, current: "/p/ology-gateway", query: "rel" }))).toEqual([ + "# ology-collect", + "ology-collect:release/1.0", + "ology-collect:origin/release/1.0", + ]); + }); +}); + +describe("repository colours and heads", () => { + it("gives up to nine repositories distinct colours, then cycles", () => { + expect(Array.from({ length: 10 }, (_, i) => repoColorIndex(i))).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 1]); + }); + + it("names a detached HEAD by its short commit", () => { + expect(repoHeadLabel({ head: "dev", headCommit: "0123456789" })).toBe("dev"); + expect(repoHeadLabel({ head: null, headCommit: "0123456789" })).toBe("01234567"); + expect(repoHeadLabel({ head: null, headCommit: null })).toBe("No commits"); + }); +}); diff --git a/src/features/vcs/actions.tsx b/src/features/vcs/actions.tsx index 117e96a9f..68b27a466 100644 --- a/src/features/vcs/actions.tsx +++ b/src/features/vcs/actions.tsx @@ -6,7 +6,7 @@ import { Menu, type MenuItem } from "@/ui"; import { editorService, maybeEditorService, notifications, toolWindowService } from "@/workbench/contributions"; import { annotationsShown, toggleAnnotations } from "./blame"; import { showBranchesPopup } from "./BranchWidget"; -import { checkoutRevision, newBranch } from "./branchOps"; +import { checkoutRevision, newBranch, newBranchInRepos } from "./branchOps"; import { addToVcs, commitChanges, @@ -401,7 +401,11 @@ export function registerVcsActions(): () => void { category: "Git", icon: "plus", when: VCS, - run: () => void newBranch(), + // `{ repos }` (Branches popup with several repositories): in each of them. + run: ({ arg }) => { + const repos = (arg as { repos?: unknown } | undefined)?.repos; + void (Array.isArray(repos) && repos.length ? newBranchInRepos(repos as string[]) : newBranch()); + }, }, { id: "vcs.checkoutRevision", diff --git a/src/features/vcs/branchMenu.ts b/src/features/vcs/branchMenu.ts index 44609e921..3b546a531 100644 --- a/src/features/vcs/branchMenu.ts +++ b/src/features/vcs/branchMenu.ts @@ -7,8 +7,13 @@ import { checkoutAndRebase, checkoutBranch, checkoutDetached, + checkoutInRepos, deleteBranch, + deleteInRepos, deleteRemoteBranch, + mergeInRepos, + newBranchInRepos, + rebaseInRepos, loadFavoriteBranches, mergeIntoCurrent, newBranch, @@ -20,6 +25,8 @@ import { } from "./branchOps"; import { showCompareBranches } from "./CompareBranches"; import { showDiffWithWorkingTree } from "./operations"; +import type { CommonBranch } from "./repoBranches"; +import { focusRepo } from "./store"; export function branchMenu(b: Branch, current: string | null, done: () => void = () => {}): MenuItem[] { const run = (fn: () => unknown) => () => { @@ -98,3 +105,56 @@ export function tagMenu(tag: string, done: () => void = () => {}): MenuItem[] { { label: "Show History", icon: "history", run: run(() => executeAction("vcs.showLog", { branch: tag })) }, ]; } + +/** Entries for a branch common to all repositories: each acts in every one of `roots`. */ +export function commonBranchMenu(b: CommonBranch, roots: readonly string[], done: () => void = () => {}): MenuItem[] { + const run = (fn: () => unknown) => () => { + done(); + void fn(); + }; + const newFrom: MenuItem = { + label: `New Branch from '${b.name}'…`, + icon: "branch", + run: run(() => newBranchInRepos(roots, b.name)), + }; + if (b.current) + return [ + newFrom, + { kind: "separator" }, + { label: "Update", icon: "git-pull", run: run(() => executeAction("vcs.update")) }, + { label: "Push…", icon: "git-push", run: run(() => executeAction("vcs.push")) }, + ]; + const items: MenuItem[] = [ + { label: "Checkout", run: run(() => checkoutInRepos(b.name, roots)) }, + newFrom, + { kind: "separator" }, + { label: `Rebase Current onto '${b.name}'`, run: run(() => rebaseInRepos(b.name, roots)) }, + { label: `Merge '${b.name}' into Current`, run: run(() => mergeInRepos(b.name, roots)) }, + ]; + if (!b.remote) + items.push( + { kind: "separator" }, + { label: "Delete", danger: true, icon: "trash", run: run(() => deleteInRepos(b.name, roots)) }, + ); + return items; +} + +/** `items` acting on repository `root`: it is selected (with its branches loaded) before each entry runs. */ +export function inRepo(root: string, items: MenuItem[]): MenuItem[] { + return items.map((item): MenuItem => { + if (item.kind === "separator" || item.kind === "header") return item; + if (item.kind === "action") + return { + label: item.label ?? item.action, + icon: item.icon, + disabled: item.disabled, + run: () => void focusRepo(root).then(() => executeAction(item.action, item.arg)), + }; + const run = item.run; + return { + ...item, + run: run && (() => void focusRepo(root).then(run)), + submenu: item.submenu && inRepo(root, item.submenu), + }; + }); +} diff --git a/src/features/vcs/branchOps.ts b/src/features/vcs/branchOps.ts index b52b4bb03..d066ec2ae 100644 --- a/src/features/vcs/branchOps.ts +++ b/src/features/vcs/branchOps.ts @@ -9,7 +9,7 @@ import { isFavoriteBranch, pushRecent, shortRemoteName } from "./branches"; import { showCommitRange } from "./CompareBranches"; import { confirmDialog, promptText } from "./dialogs"; import { runOp, runRemote, saveAll } from "./ops"; -import { conflictedFiles, currentRepo, refreshNow, useVcs } from "./store"; +import { conflictedFiles, currentRepo, refreshNow, repoName, useVcs } from "./store"; const recentKey = () => `openidea.vcs.recentBranches:${useVcs.getState().root ?? ""}`; @@ -68,18 +68,23 @@ async function askSmartCheckout(files: readonly string[]): Promise string): Promise { +async function performCheckout( + rev: string, + success: () => string, + repo: string | null = currentRepo(), + quiet = false, +): Promise { await saveAll(); let options: CheckoutOptions = { mode: "normal", diverged: null }; for (;;) { const opts = options; - const outcome = await runOp(`Checkout ${rev}`, () => call(commands.vcsCheckout(currentRepo(), rev, false, opts))); + const outcome = await runOp(`Checkout ${rev}`, () => call(commands.vcsCheckout(repo, rev, false, opts))); if (!outcome) return false; // `success` may name the branch now checked out. if (outcome.kind === "done" || outcome.kind === "conflicts") await refreshNow(); switch (outcome.kind) { case "done": - notifications().notify({ group: "vcs", level: "info", title: success() }); + if (!quiet) notifications().notify({ group: "vcs", level: "info", title: success() }); return true; case "conflicts": notifications().notify({ @@ -316,6 +321,109 @@ export async function updateBranch(name: string) { await runRemote({ op: "updateBranch", branch: name }, `${name} ← ${b.upstream}`); } +// --- several repositories ---------------------------------------------------- + +/** "in 3 repositories", or "in 2 of 3 repositories" when some were left out. */ +function inRepos(done: number, total: number): string { + return done === total ? `in ${total} repositories` : `in ${done} of ${total} repositories`; +} + +/** + * Runs `step` in each repository in turn, stopping at the first one that fails (its + * error is reported by `step`), then reports how many repositories it was done in. + */ +async function eachRepo(roots: readonly string[], step: (root: string) => Promise, title: string) { + let done = 0; + for (const root of roots) { + if (!(await step(root))) break; + done++; + } + await refreshNow(); + if (done) notifications().notify({ group: "vcs", level: "info", title: `${title} ${inRepos(done, roots.length)}` }); +} + +/** Checks out `name` (local, or remote: its local namesake) in every repository of `roots`. */ +export async function checkoutInRepos(name: string, roots: readonly string[]) { + const label = useVcs.getState().branches.some((b) => b.remote && b.name === name) ? shortRemoteName(name) : name; + const previous = useVcs.getState().branch?.head; + await eachRepo(roots, (root) => performCheckout(name, () => "", root, true), `Checked out ${label}`); + rememberBranch(previous); + rememberBranch(useVcs.getState().branch?.head); +} + +/** New Branch in every repository of `roots` (from HEAD or `from`), checked out by default. */ +export async function newBranchInRepos(roots: readonly string[], from?: string) { + const r = await promptText({ + title: from ? `Create New Branch from ${from}` : "Create New Branch", + label: `New branch name (in ${roots.length} repositories):`, + initial: from?.includes("/") ? shortRemoteName(from) : "", + mono: true, + okLabel: "Create", + validate: validateBranchName, + checkbox: { label: "Checkout branch", initial: true }, + }); + if (!r) return; + const name = r.value.trim(); + if (r.checked) await saveAll(); + await eachRepo( + roots, + async (root) => + (await runOp(`Create branch ${name}`, () => call(commands.vcsCreateBranch(root, name, from ?? null, r.checked)), { + refresh: false, + })) !== undefined, + r.checked ? `Created and checked out ${name}` : `Created branch ${name}`, + ); +} + +/** Merges `branch` into the current branch of every repository of `roots`; stops on conflicts. */ +export async function mergeInRepos(branch: string, roots: readonly string[]) { + await saveAll(); + await eachRepo( + roots, + async (root) => + (await runOp(`Merge ${branch} (${repoName(root)})`, () => call(commands.vcsMerge(root, branch)), { + refresh: false, + errorActions: conflictActions, + onError: openConflictsInstead, + })) !== undefined, + `Merged ${branch}`, + ); +} + +/** Rebases the current branch of every repository of `roots` onto `branch`; stops on conflicts. */ +export async function rebaseInRepos(branch: string, roots: readonly string[]) { + await saveAll(); + await eachRepo( + roots, + async (root) => + (await runOp(`Rebase onto ${branch} (${repoName(root)})`, () => call(commands.vcsRebase(root, branch)), { + refresh: false, + errorActions: conflictActions, + onError: openConflictsInstead, + })) !== undefined, + `Rebased onto ${branch}`, + ); +} + +/** Deletes local branch `name` in every repository of `roots`. */ +export async function deleteInRepos(name: string, roots: readonly string[]) { + const ok = await confirmDialog({ + title: "Delete Branch", + message: `Delete branch '${name}' in ${roots.length} repositories? Unmerged commits are discarded.`, + okLabel: "Delete", + danger: true, + }); + if (!ok) return; + await eachRepo( + roots, + async (root) => + (await runOp(`Delete branch ${name} (${repoName(root)})`, () => call(commands.vcsDeleteBranch(root, name)), { + refresh: false, + })) !== undefined, + `Deleted branch ${name}`, + ); +} + // --- favorites --------------------------------------------------------------- const favoritesKey = () => `openidea.vcs.favoriteBranches:${useVcs.getState().root ?? ""}`; @@ -341,7 +449,12 @@ export function toggleFavoriteBranch(b: Branch) { loadFavoriteBranches(); const stored = useFavoriteBranches.getState().names; // First edit: start from the defaults that are currently shown. - const base = stored ?? useVcs.getState().branches.filter((x) => isFavoriteBranch(x, null)).map((x) => x.name); + const base = + stored ?? + useVcs + .getState() + .branches.filter((x) => isFavoriteBranch(x, null)) + .map((x) => x.name); const names = base.includes(b.name) ? base.filter((n) => n !== b.name) : [...base, b.name]; useFavoriteBranches.setState({ names }); try { diff --git a/src/features/vcs/repoBranches.ts b/src/features/vcs/repoBranches.ts new file mode 100644 index 000000000..798e8baf6 --- /dev/null +++ b/src/features/vcs/repoBranches.ts @@ -0,0 +1,293 @@ +/** Branches popup model for a project with several repositories (pure). */ +import type { Branch } from "@/bindings"; +import { fuzzyMatch } from "@/ui/match"; +import { isFavoriteBranch, MAX_RECENT, sortLocal, sortRemote, type PopupAction } from "./branches"; + +export interface RepoEntry { + root: string; + name: string; + /** Current branch; `null` when HEAD is detached. */ + head: string | null; + headCommit: string | null; +} + +/** Number of `--project-color-N` tokens repositories are coloured with. */ +export const REPO_COLORS = 9; + +/** Colour slot (1-based) of the repository at `index`: distinct for up to `REPO_COLORS` repositories. */ +export function repoColorIndex(index: number): number { + return (index % REPO_COLORS) + 1; +} + +/** Display name of a repository's HEAD: its branch, else the short commit. */ +export function repoHeadLabel(r: Pick): string { + if (r.head) return r.head; + return r.headCommit ? r.headCommit.slice(0, 8) : "No commits"; +} + +/** + * Whether the branch widget names the repository besides the branch: only with several + * repositories that are not all on the same branch. + */ +export function widgetShowsRepo(repos: readonly Pick[]): boolean { + if (repos.length < 2) return false; + const first = repos[0]!.head; + return !first || repos.some((r) => r.head !== first); +} + +export interface CommonBranch { + name: string; + remote: boolean; + /** Checked out in every repository. */ + current: boolean; + favorite: boolean; +} + +/** Branches present in every repository (by name), local and remote, sorted like the single-repository lists. */ +export function commonBranches( + branchesByRepo: readonly (readonly Branch[])[], + favorites: readonly string[] | null = null, +): { local: CommonBranch[]; remote: CommonBranch[] } { + if (!branchesByRepo.length) return { local: [], remote: [] }; + const pick = (sorted: (list: readonly Branch[]) => Branch[]): CommonBranch[] => { + const [first, ...rest] = branchesByRepo; + const others = rest.map((list) => new Map(list.map((b) => [`${b.remote}:${b.name}`, b]))); + return sorted(first!).flatMap((b) => { + const all = others.map((m) => m.get(`${b.remote}:${b.name}`)); + if (all.some((x) => !x)) return []; + return [ + { + name: b.name, + remote: b.remote, + current: b.current && all.every((x) => x!.current), + favorite: isFavoriteBranch(b, favorites), + }, + ]; + }); + }; + const byRank = (a: CommonBranch, b: CommonBranch) => + Number(b.current) - Number(a.current) || Number(b.favorite) - Number(a.favorite); + return { + local: pick((l) => sortLocal(l, favorites)).sort(byRank), + remote: pick((l) => sortRemote(l, favorites)).sort(byRank), + }; +} + +export type BranchGroupKind = "recent" | "local" | "remote"; + +export type RepoRow = + /** Section header; an empty label draws a separator. */ + | { type: "section"; key: string; label: string } + | { type: "action"; key: string; action: PopupAction; matches: number[] | null } + | { type: "repo"; key: string; repo: RepoEntry; color: number; matches: number[] | null } + /** Collapsible group (common branches, or one of the current repository's lists) or remote folder. */ + | { type: "group"; key: string; label: string; expanded: boolean; depth: number } + | { + type: "common"; + key: string; + branch: CommonBranch; + /** Shown name: remote branches without their remote (it is the folder). */ + label: string; + depth: number; + matches: number[] | null; + } + /** A branch of one repository. */ + | { + type: "branch"; + key: string; + repo: RepoEntry; + branch: Branch; + group: BranchGroupKind; + depth: number; + matches: number[] | null; + favorite: boolean; + } + | { type: "tag"; key: string; repo: RepoEntry; tag: string; depth: number; matches: number[] | null }; + +/** + * Initially expanded groups: the common local branches, and the current repository's + * local branches (switching a repository's branch stays one click away). + */ +export const DEFAULT_EXPANDED: readonly string[] = ["common:local", "repo:local"]; + +export interface MultiRepoInput { + repos: readonly RepoEntry[]; + /** Root → its branches (missing while loading). */ + branches: Readonly>; + query: string; + actions: readonly PopupAction[]; + /** Expanded group / folder keys (`common:local`, `repo:remote`, `common:folder:origin`…). */ + expanded: ReadonlySet; + favorites?: readonly string[] | null; + /** The current repository (of the active file), whose lists are shown when the repositories' branches differ. */ + current?: string | null; + /** Recently checked-out branches (newest first). */ + recent?: readonly string[]; + /** Tags of the current repository. */ + tags?: readonly string[]; +} + +/** + * Rows of the Branches popup with several repositories: actions; the repositories, + * each with its current branch and opening its own branches; then, when they are all + * on the same branch, the branches common to all of them (acting on all of them), + * otherwise the current repository's recent, local and remote branches and tags, in + * collapsible groups. A query filters actions, repositories (by name) and branches, + * expands every group, and adds the other repositories' matching branches. + */ +export function buildMultiRepoRows({ + repos, + branches, + query, + actions, + expanded, + favorites = null, + current = null, + recent = [], + tags = [], +}: MultiRepoInput): RepoRow[] { + const q = query.trim(); + const match = (text: string): number[] | null | false => (q ? (fuzzyMatch(text, q) ?? false) : null); + const open = (key: string) => !!q || expanded.has(key); + const blocks: RepoRow[][] = []; + + const actionRows: RepoRow[] = []; + for (const a of actions) { + const m = match(a.label); + if (m !== false) actionRows.push({ type: "action", key: `action:${a.id}`, action: a, matches: m }); + } + blocks.push(actionRows); + + const repoRows: RepoRow[] = []; + repos.forEach((repo, i) => { + const m = match(repo.name); + if (m !== false) + repoRows.push({ type: "repo", key: `repo:${repo.root}`, repo, color: repoColorIndex(i), matches: m }); + }); + blocks.push(repoRows); + + /** A group row followed (when expanded) by its children; nothing when it has none. */ + const group = (out: RepoRow[], key: string, label: string, depth: number, children: RepoRow[]) => { + if (!children.length) return; + const expandedNow = open(key); + out.push({ type: "group", key, label, expanded: expandedNow, depth }); + if (expandedNow) out.push(...children); + }; + const branchRows = (repo: RepoEntry, list: readonly Branch[], kind: BranchGroupKind, depth: number) => + list.flatMap((b): RepoRow[] => { + const m = match(b.name); + if (m === false) return []; + const key = `branch:${repo.root}:${kind}:${b.name}`; + return [ + { + type: "branch", + key, + repo, + branch: b, + group: kind, + depth, + matches: m, + favorite: isFavoriteBranch(b, favorites), + }, + ]; + }); + + const shared = !widgetShowsRepo(repos); + const groups: RepoRow[] = []; + let shown = new Set(); + const currentRepo = repos.find((r) => r.root === current) ?? repos[0]; + if (shared) { + const loaded = repos.every((r) => branches[r.root]); + const common = loaded + ? commonBranches( + repos.map((r) => branches[r.root]!), + favorites, + ) + : { local: [], remote: [] }; + shown = new Set([...common.local, ...common.remote].map((b) => `${b.remote}:${b.name}`)); + const local = common.local.flatMap((b): RepoRow[] => { + const m = match(b.name); + return m === false + ? [] + : [{ type: "common", key: `common:${b.name}`, branch: b, label: b.name, depth: 1, matches: m }]; + }); + group(groups, "common:local", "Common Local Branches", 0, local); + const byRemote = new Map(); + for (const b of common.remote) { + const remote = b.name.slice(0, Math.max(0, b.name.indexOf("/"))); + byRemote.set(remote, [...(byRemote.get(remote) ?? []), b]); + } + const remote: RepoRow[] = []; + for (const [name, items] of byRemote) { + const leaves = items.flatMap((b): RepoRow[] => { + const m = match(b.name); + if (m === false) return []; + // Highlights are of the full name: shift them onto the shown part. + const part = m && m.map((i) => i - name.length - 1).filter((i) => i >= 0); + const label = b.name.slice(name.length + 1); + return [{ type: "common", key: `common:${b.name}`, branch: b, label, depth: 2, matches: part }]; + }); + group(remote, `common:folder:${name}`, name, 1, leaves); + } + group(groups, "common:remote", "Common Remote Branches", 0, remote); + } else if (currentRepo) { + const list = branches[currentRepo.root] ?? []; + const local = sortLocal(list, favorites); + const byName = new Map(local.map((b) => [b.name, b])); + const recentList = q + ? [] + : recent + .map((n) => byName.get(n)) + .filter((b): b is Branch => !!b && !b.current) + .slice(0, MAX_RECENT); + const name = currentRepo.name; + group(groups, "repo:recent", `Recent Branches in ${name}`, 0, branchRows(currentRepo, recentList, "recent", 1)); + group(groups, "repo:local", `Local Branches in ${name}`, 0, branchRows(currentRepo, local, "local", 1)); + group( + groups, + "repo:remote", + `Remote Branches in ${name}`, + 0, + branchRows(currentRepo, sortRemote(list, favorites), "remote", 1), + ); + const tagRows = [...tags] + .sort((a, b) => b.localeCompare(a, undefined, { numeric: true })) + .flatMap((t): RepoRow[] => { + const m = match(t); + return m === false + ? [] + : [{ type: "tag", key: `tag:${currentRepo.root}:${t}`, repo: currentRepo, tag: t, depth: 1, matches: m }]; + }); + group(groups, "repo:tags", `Tags in ${name}`, 0, tagRows); + } + blocks.push(groups); + + if (q) { + for (const repo of repos) { + if (!shared && repo === currentRepo) continue; + const list = branches[repo.root] ?? []; + const own = (l: Branch[]) => l.filter((b) => !shown.has(`${b.remote}:${b.name}`)); + const rows = [ + ...branchRows(repo, own(sortLocal(list, favorites)), "local", 0), + ...branchRows(repo, own(sortRemote(list, favorites)), "remote", 0), + ]; + if (rows.length) blocks.push([{ type: "section", key: `section:${repo.root}`, label: repo.name }, ...rows]); + } + } + + const rows: RepoRow[] = []; + for (const block of blocks) { + if (!block.length) continue; + if (rows.length && block[0]!.type !== "section") + rows.push({ type: "section", key: `separator:${rows.length}`, label: "" }); + rows.push(...block); + } + return rows; +} + +/** Toggles a group / folder key. */ +export function toggleExpanded(expanded: ReadonlySet, key: string): Set { + const next = new Set(expanded); + if (!next.delete(key)) next.add(key); + return next; +} diff --git a/src/features/vcs/store.ts b/src/features/vcs/store.ts index 6cf5104b4..5cf5b94ac 100644 --- a/src/features/vcs/store.ts +++ b/src/features/vcs/store.ts @@ -17,6 +17,11 @@ import { useProject } from "@/platform/project"; import { setFileDecorations } from "@/workbench/contributions"; import { effectiveKind, repoLabel, rootOf, statusDecorations, stripSlash } from "./model"; +export interface RepoHead { + head: string | null; + headCommit: string | null; +} + export interface VcsState { /** The open project is a git repository. */ available: boolean; @@ -25,6 +30,8 @@ export interface VcsState { roots: string[]; /** Root → merge / rebase / cherry-pick / revert in progress there. */ operations: Record; + /** Root → its current branch (`head` null when detached) and HEAD commit. */ + heads: Record; /** The selected repository's root (branch widget, log, push…). */ repo: string | null; status: FileStatus[]; @@ -50,6 +57,7 @@ const initial: VcsState = { root: null, roots: [], operations: {}, + heads: {}, repo: null, status: [], statusLoaded: false, @@ -109,6 +117,12 @@ export function selectRepo(root: string): Promise { return refreshNow(); } +/** Selects `root` and loads its branches, so branch operations act on it. */ +export async function focusRepo(root: string): Promise { + await selectRepo(root); + if (!useVcs.getState().branchesLoaded) await loadBranches(); +} + export function conflictedFiles(status: readonly FileStatus[] = useVcs.getState().status): FileStatus[] { return status.filter((f) => effectiveKind(f) === "conflicted"); } @@ -180,7 +194,8 @@ export function refreshNow(): Promise { // `null` (before the first refresh) is the primary repository, listed first. const shown = s.repo && roots.includes(s.repo) ? s.repo : (roots[0] ?? null); const repo = attentionRepo(shown, s.operations, operations) ?? shown; - useVcs.setState({ operations }); + const heads = Object.fromEntries(vcsRoots.map((r) => [r.root, { head: r.head, headCommit: r.headCommit }])); + useVcs.setState({ operations, heads }); if (repo !== s.repo || roots.join("\n") !== s.roots.join("\n")) useVcs.setState({ roots, repo }); applyStatus(status, branch); useVcs.setState({ branches, stashes, error: null }); diff --git a/src/ui/List.tsx b/src/ui/List.tsx index d8ef96ff4..718c1d86d 100644 --- a/src/ui/List.tsx +++ b/src/ui/List.tsx @@ -29,6 +29,8 @@ export interface ListProps { onContextMenu?: (item: T, index: number, e: MouseEvent) => void; rowHeight?: number; sectionHeight?: number; + /** Height of a given section row (e.g. thin separators), else `sectionHeight`. */ + sectionHeightOf?: (item: T) => number | undefined; /** `false` when an external input drives navigation (rows still respond to the mouse). */ focusable?: boolean; /** Move the active row with the pointer (popup choosers). */ @@ -109,6 +111,7 @@ export function List({ onContextMenu, rowHeight = 24, sectionHeight = 24, + sectionHeightOf, focusable = true, hoverSelects = false, empty, @@ -127,7 +130,7 @@ export function List({ const virtualizer = useVirtualizer({ count: items.length, getScrollElement: () => scrollRef.current, - estimateSize: (i) => (isSection?.(items[i]!) ? sectionHeight : rowHeight), + estimateSize: (i) => (isSection?.(items[i]!) ? (sectionHeightOf?.(items[i]!) ?? sectionHeight) : rowHeight), overscan, getItemKey: (i) => getKey(items[i]!, i), });