diff --git a/src/features/vcs/GitLog.tsx b/src/features/vcs/GitLog.tsx index be7b59f92..b2964ea7d 100644 --- a/src/features/vcs/GitLog.tsx +++ b/src/features/vcs/GitLog.tsx @@ -1263,16 +1263,20 @@ function CommitList() { ), [all, heads, headCommit], ); + const keep = useMemo( + () => + opts.collapseLinear || fragments.collapsed.size + ? new Set([...headKeys, ...selection, ...Object.keys(bookmarks)]) + : null, + [opts.collapseLinear, fragments.collapsed, headKeys, selection, bookmarks], + ); const { commits, rows, linear, collapsed, graph: displayedGraph, - } = useMemo( - () => presentLogGraph(all, opts, fragments, new Set([...headKeys, ...selection, ...Object.keys(bookmarks)])), - [all, opts, fragments, headKeys, selection, bookmarks], - ); + } = useMemo(() => presentLogGraph(all, opts, fragments, keep ?? new Set()), [all, opts, fragments, keep]); const { edges: highlighted, nodes: highlightedNodes } = useMemo(() => { const empty = { edges: new Set(), nodes: new Set() }; if (!hoverColor) return empty; @@ -1321,8 +1325,9 @@ function CommitList() { const onlyCurrent = filterBranches.length > 0 && filterBranches.every((b) => b === "HEAD" || b === currentBranch); const onBranch = useMemo(() => { if (!opts.highlightCurrentBranch || onlyCurrent) return null; - const reach = new Set(); - for (const head of headKeys) for (const id of reachableFrom(all.map(graphCommit), head)) reach.add(id); + const reach = all.some((c) => c.onCurrentBranch == null) + ? reachableFrom(all.map(graphCommit), headKeys) + : new Set(); for (const c of all) { if (c.onCurrentBranch === true) reach.add(commitKey(c)); else if (c.onCurrentBranch === false) reach.delete(commitKey(c)); diff --git a/src/features/vcs/__tests__/logOptions.test.ts b/src/features/vcs/__tests__/logOptions.test.ts index 284de06d7..82dec66e5 100644 --- a/src/features/vcs/__tests__/logOptions.test.ts +++ b/src/features/vcs/__tests__/logOptions.test.ts @@ -90,8 +90,9 @@ describe("long edges", () => { describe("highlighting", () => { it("finds the loaded commits of the current branch", () => { const commits = [c("s", ["a"]), c("h", ["a"]), c("a", ["r"]), c("r")]; - expect([...reachableFrom(commits, "h")].sort()).toEqual(["a", "h", "r"]); - expect(reachableFrom(commits, null).size).toBe(0); + expect([...reachableFrom(commits, ["h"])].sort()).toEqual(["a", "h", "r"]); + expect([...reachableFrom(commits, ["h", "s"])].sort()).toEqual(["a", "h", "r", "s"]); + expect(reachableFrom(commits, []).size).toBe(0); }); it("matches my commits by email or name", () => { diff --git a/src/features/vcs/__tests__/multiRoot.test.ts b/src/features/vcs/__tests__/multiRoot.test.ts index b77558d6f..9949a70f0 100644 --- a/src/features/vcs/__tests__/multiRoot.test.ts +++ b/src/features/vcs/__tests__/multiRoot.test.ts @@ -249,6 +249,39 @@ describe("Git log background refresh", () => { expect(useGitLog.getState().commits.map((c) => c.id)).toEqual(history.slice(0, PAGE * 3).map((c) => c.id)); }); + it("keeps the loaded multi-root window on refresh without emitting older candidates", async () => { + const roots = useVcs.getState().roots; + const history = Object.fromEntries( + roots.map((repo, root) => [ + repo, + Array.from({ length: 180 }, (_, i) => commit(`${root}-${i}`, 1000 - i * 2 - root)), + ]), + ); + log.mockImplementation(async (repo: string, q: LogQuery) => ({ + status: "ok", + data: history[repo]!.slice(q.skip, q.skip + q.limit).map((c, i) => ({ + ...c, + logPosition: q.skip + i, + logTotal: history[repo]!.length, + })), + })); + await reloadLog(); + const loaded = useGitLog.getState().commits.map((c) => c.id); + expect(loaded).toHaveLength(PAGE); + + history[roots[0]!]!.unshift(commit("new", 2000)); + await reloadLog({ preserve: true }); + expect(useGitLog.getState().commits.map((c) => c.id)).toEqual(["new", ...loaded]); + expect(useGitLog.getState().done).toBe(false); + + await loadMore(); + const expected = Object.values(history) + .flat() + .sort((a, b) => (b.timeMs ?? 0) - (a.timeMs ?? 0)); + expect(useGitLog.getState().commits.map((c) => c.id)).toEqual(expected.map((c) => c.id)); + expect(useGitLog.getState().done).toBe(true); + }); + it("retains the current history if refreshing fails", async () => { useVcs.setState({ roots: ["/project/front"] }); log.mockResolvedValue({ status: "ok", data: [commit("old")] }); diff --git a/src/features/vcs/graph.ts b/src/features/vcs/graph.ts index b6e84dd53..477b34eb3 100644 --- a/src/features/vcs/graph.ts +++ b/src/features/vcs/graph.ts @@ -264,14 +264,13 @@ export function collapseLinear< return { commits: visible, graph }; } -/** Ids of the loaded commits reachable from `head` (log order: children before parents). */ +/** Ids reachable from the given heads (log order: children before parents). */ export function reachableFrom( commits: ReadonlyArray<{ id: string; parents: readonly string[] }>, - head: string | null, + heads: Iterable, ): Set { - const out = new Set(); - if (!head) return out; - out.add(head); + const out = new Set(heads); + if (!out.size) return out; for (const c of commits) if (out.has(c.id)) for (const p of c.parents) out.add(p); return out; } diff --git a/src/features/vcs/logStore.ts b/src/features/vcs/logStore.ts index 1cb26a4e4..de7015e2e 100644 --- a/src/features/vcs/logStore.ts +++ b/src/features/vcs/logStore.ts @@ -994,6 +994,13 @@ function loadPage(replace: boolean, id: string, before = false): Promise { before ? streams.map((stream, i) => [...stream, ...(currentStreams[i] ?? [])]) : streams, before || joined || s.seek || (replace && s.commits.length) ? Number.MAX_SAFE_INTEGER : limit, ); + if (replace && roots.length > 1 && s.commits.length && !s.seek) { + // Keep the loaded window through its last surviving commit. Older candidates + // from each repository are fetched again when paging. + const loadedKeys = new Set(s.commits.map(commitKey)); + const last = page.findLastIndex((c) => loadedKeys.has(commitKey(c))); + page.length = Math.min(page.length, Math.max(limit, last + 1)); + } const cur = logState(id); if (cur.generation !== gen) return; if (replace && results.length > 0 && failures.length === results.length && cur.commits.length) {