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), });