From cb614eff958a36c429da30ce4ac65a9f738198d7 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sat, 7 Nov 2026 10:39:40 +0800 Subject: [PATCH] perf(vcs): keep Git Log scrolling smooth as pages load Each page of the log recomputed hidden long edges by scanning every row once per lane, and the store laid out the whole graph a second time for fields nothing read. Long edges are now found in one pass over the rows, the unused store graph is gone, and the next page is requested while a page of rows is still ahead so fast scrolling doesn't reach the end before git answers. The commit list also contains its layout, so rows entering while scrolling no longer relay out the workbench. --- src/features/vcs/GitLog.module.css | 2 + src/features/vcs/GitLog.tsx | 5 +- src/features/vcs/__tests__/logLoading.test.ts | 1 - .../vcs/__tests__/logUiFeatures.test.tsx | 1 - src/features/vcs/__tests__/logViews.test.tsx | 1 - src/features/vcs/__tests__/multiRoot.test.ts | 3 - src/features/vcs/graph.ts | 69 ++++++++++--------- src/features/vcs/logStore.ts | 37 ++-------- 8 files changed, 49 insertions(+), 70 deletions(-) diff --git a/src/features/vcs/GitLog.module.css b/src/features/vcs/GitLog.module.css index 706d1ac39..b3463bbef 100644 --- a/src/features/vcs/GitLog.module.css +++ b/src/features/vcs/GitLog.module.css @@ -161,6 +161,8 @@ min-height: 0; overflow: auto; outline: none; + /* Rows coming and going while scrolling relay out the list alone, not the workbench. */ + contain: strict; } .row { position: absolute; diff --git a/src/features/vcs/GitLog.tsx b/src/features/vcs/GitLog.tsx index 43a769231..332e8cfb6 100644 --- a/src/features/vcs/GitLog.tsx +++ b/src/features/vcs/GitLog.tsx @@ -77,6 +77,7 @@ import { toggleRepository, updateLogOptions, useGitLog, + PAGE, } from "./logStore"; import { logOptions, refLabels, useLogOptions, type LogOptions } from "./logOptions"; import { KIND_COLOR, baseName, dirName, fileCount, formatTime, relTo, type EffectiveKind } from "./model"; @@ -1391,8 +1392,10 @@ function CommitList() { const items = virtualizer.getVirtualItems(); const last = items[items.length - 1]; + // The next page is requested while a page of rows is still ahead, so scrolling + // quickly doesn't reach the end before `git log` answers. useEffect(() => { - if (!finished && !loading && last && last.index >= commits.length - 40) void loadMore(scope ?? undefined); + if (!finished && !loading && last && last.index >= commits.length - PAGE) void loadMore(scope ?? undefined); }, [last, commits.length, finished, loading, active, scope]); const selectedIndex = selected ? commits.findIndex((c) => commitKey(c) === selected) : -1; diff --git a/src/features/vcs/__tests__/logLoading.test.ts b/src/features/vcs/__tests__/logLoading.test.ts index a331dbb69..c81e95679 100644 --- a/src/features/vcs/__tests__/logLoading.test.ts +++ b/src/features/vcs/__tests__/logLoading.test.ts @@ -67,7 +67,6 @@ beforeEach(() => { logTabs: [], filters: emptyFilters(), commits: [], - rows: [], selection: [], selected: null, done: false, diff --git a/src/features/vcs/__tests__/logUiFeatures.test.tsx b/src/features/vcs/__tests__/logUiFeatures.test.tsx index 4321c269e..a116a8d92 100644 --- a/src/features/vcs/__tests__/logUiFeatures.test.tsx +++ b/src/features/vcs/__tests__/logUiFeatures.test.tsx @@ -82,7 +82,6 @@ beforeEach(() => { tab: "log", logTabs: [], commits: [], - rows: [], filters: emptyFilters(), selection: [], selected: null, diff --git a/src/features/vcs/__tests__/logViews.test.tsx b/src/features/vcs/__tests__/logViews.test.tsx index d32a19e71..0b7d349d6 100644 --- a/src/features/vcs/__tests__/logViews.test.tsx +++ b/src/features/vcs/__tests__/logViews.test.tsx @@ -42,7 +42,6 @@ beforeEach(() => { tab: "log", logTabs: [], commits: [], - rows: [], filters: emptyFilters(), selection: [], selected: null, diff --git a/src/features/vcs/__tests__/multiRoot.test.ts b/src/features/vcs/__tests__/multiRoot.test.ts index 9949a70f0..5b6aad9d9 100644 --- a/src/features/vcs/__tests__/multiRoot.test.ts +++ b/src/features/vcs/__tests__/multiRoot.test.ts @@ -224,12 +224,10 @@ describe("Git log background refresh", () => { const refresh = reloadLog({ preserve: true }); await vi.waitFor(() => expect(finish).toBeDefined()); expect(useGitLog.getState().commits).toBe(before.commits); - expect(useGitLog.getState().rows).toBe(before.rows); expect(useGitLog.getState().loading).toBe(true); finish({ status: "ok", data: [commit("new"), commit("old")] }); await refresh; expect(useGitLog.getState().commits.map((c) => c.id)).toEqual(["new", "old"]); - expect(useGitLog.getState().rows).toHaveLength(2); expect(useGitLog.getState().selected).toBe(commitKey(before.commits[0]!)); expect(useGitLog.getState().loading).toBe(false); }); @@ -290,7 +288,6 @@ describe("Git log background refresh", () => { log.mockRejectedValue(new Error("unavailable")); await reloadLog({ preserve: true }); expect(useGitLog.getState().commits).toBe(before.commits); - expect(useGitLog.getState().rows).toBe(before.rows); expect(useGitLog.getState().error).toContain("unavailable"); expect(useGitLog.getState().loading).toBe(false); }); diff --git a/src/features/vcs/graph.ts b/src/features/vcs/graph.ts index 477b34eb3..849cd3744 100644 --- a/src/features/vcs/graph.ts +++ b/src/features/vcs/graph.ts @@ -458,7 +458,14 @@ const LONG_EDGE_STUB = 2; const onLane = (i: number) => (s: GraphSegment) => s.from === i && s.to === i; -const passes = (row: GraphRow, i: number) => row.lane !== i && row.up.some(onLane(i)) && row.down.some(onLane(i)); +/** Lanes a row's vertical lines pass straight through (other than its node's). */ +function passingLanes(row: GraphRow): Set { + const out = new Set(); + for (const s of row.up) + if (s.from === s.to && s.from !== row.lane && row.down.some((d) => d.from === s.from && d.to === s.from)) + out.add(s.from); + return out; +} /** * Show Long Edges off: the middle of a straight run of a lane through `minLength` or @@ -466,41 +473,41 @@ const passes = (row: GraphRow, i: number) => row.lane !== i && row.up.some(onLan */ export function hideLongEdges(rows: readonly GraphRow[], minLength = LONG_EDGE): GraphRow[] { const out = rows.slice(); - const width = rows.reduce((max, row) => Math.max(max, row.width), 0); const edit = (r: number, fn: (row: GraphRow) => GraphRow) => { out[r] = fn(out[r]!); }; - for (let i = 0; i < width; i++) { + const hide = (i: number, start: number, end: number) => { const lane = onLane(i); - let start = -1; - for (let r = 0; r <= rows.length; r++) { - if (r < rows.length && passes(rows[r]!, i)) { - if (start < 0) start = r; - continue; - } - if (start >= 0 && r - start >= minLength) { - const from = start + LONG_EDGE_STUB; - const to = r - 1 - LONG_EDGE_STUB; - for (let k = from; k <= to; k++) { - edit(k, (row) => ({ - ...row, - up: row.up.filter((s) => !lane(s)), - down: row.down.filter((s) => !lane(s)), - upTargets: row.upTargets?.filter((_, i) => !lane(row.up[i]!)), - downTargets: row.downTargets?.filter((_, i) => !lane(row.down[i]!)), - })); - } - edit(from - 1, (row) => ({ - ...row, - down: row.down.map((s) => (lane(s) ? { ...s, arrow: "down", targetRow: r } : s)), - })); - edit(to + 1, (row) => ({ - ...row, - up: row.up.map((s) => (lane(s) ? { ...s, arrow: "up", targetRow: start - 1 } : s)), - })); - } - start = -1; + const from = start + LONG_EDGE_STUB; + const to = end - 1 - LONG_EDGE_STUB; + for (let k = from; k <= to; k++) { + edit(k, (row) => ({ + ...row, + up: row.up.filter((s) => !lane(s)), + down: row.down.filter((s) => !lane(s)), + upTargets: row.upTargets?.filter((_, i) => !lane(row.up[i]!)), + downTargets: row.downTargets?.filter((_, i) => !lane(row.down[i]!)), + })); } + edit(from - 1, (row) => ({ + ...row, + down: row.down.map((s) => (lane(s) ? { ...s, arrow: "down", targetRow: end } : s)), + })); + edit(to + 1, (row) => ({ + ...row, + up: row.up.map((s) => (lane(s) ? { ...s, arrow: "up", targetRow: start - 1 } : s)), + })); + }; + // One pass over the rows, following every lane's current run at once. + const runs = new Map(); + for (let r = 0; r <= rows.length; r++) { + const through = r < rows.length ? passingLanes(rows[r]!) : new Set(); + for (const [lane, start] of runs) { + if (through.has(lane)) continue; + if (r - start >= minLength) hide(lane, start, r); + runs.delete(lane); + } + for (const lane of through) if (!runs.has(lane)) runs.set(lane, r); } return out; } diff --git a/src/features/vcs/logStore.ts b/src/features/vcs/logStore.ts index de7015e2e..2771d0e3b 100644 --- a/src/features/vcs/logStore.ts +++ b/src/features/vcs/logStore.ts @@ -1,5 +1,5 @@ /** Git log tool window state: filters, paged commits, graph, selection. */ -import { branchesByRepo, commitKey, commitRepo, graphCommit, mergeRepoLogs, type RepoCommit } from "./multiRoot"; +import { branchesByRepo, commitKey, commitRepo, mergeRepoLogs, type RepoCommit } from "./multiRoot"; import { create } from "zustand"; import { useContext } from "react"; import { LogIdContext } from "./logContext"; @@ -8,7 +8,6 @@ import { maybeEditorService } from "@/workbench/contributions"; import type { Branch, BranchInfo, Commit, LogQuery } from "@/bindings"; import { call, commands, nextRequestId, type RequestId } from "@/platform/ipc"; import { workflowSettings } from "@/platform/workflowSettings"; -import { computeGraph, emptyGraphState, type GraphRow, type GraphState } from "./graph"; import { logOptions, parseLogOptions, setLogOptions, useLogOptions, type LogOptions } from "./logOptions"; import { clearConsole, errorMessage } from "./ops"; import { currentRepo, currentRepoRoot, repoName, repoOf, useVcs } from "./store"; @@ -48,8 +47,6 @@ export interface LogFilters { interface LogState { filters: LogFilters; commits: RepoCommit[]; - rows: GraphRow[]; - graph: GraphState; loading: boolean; done: boolean; error: string | null; @@ -179,8 +176,6 @@ const initialLog = (): Omit => ({ filters: emptyFilters(), seek: null, commits: [], - rows: [], - graph: emptyGraphState(), loading: false, done: false, error: null, @@ -293,8 +288,6 @@ type LogView = Pick< | "filters" | "seek" | "commits" - | "rows" - | "graph" | "loading" | "done" | "error" @@ -306,13 +299,11 @@ type LogView = Pick< > & { options: LogOptions }; function freshView(filters: LogFilters): LogView { - const { commits, rows, graph, loading, done, error, selected, selection, revealTick, fragments } = initialLog(); + const { commits, loading, done, error, selected, selection, revealTick, fragments } = initialLog(); return { filters, seek: null, commits, - rows, - graph, loading, done, error, @@ -329,26 +320,11 @@ function freshView(filters: LogFilters): LogView { const parked = new Map(); function currentView(): LogView { - const { - filters, - commits, - rows, - graph, - done, - error, - selected, - selection, - revealTick, - fragments, - loading, - generation, - seek, - } = useGitLog.getState(); + const { filters, commits, done, error, selected, selection, revealTick, fragments, loading, generation, seek } = + useGitLog.getState(); return { filters, commits, - rows, - graph, loading, seek, generation, @@ -764,7 +740,7 @@ export function reloadLog( cancelLogRequests(id); const next = nextGeneration(); setViewState(id, () => ({ - ...(preserve ? {} : { commits: [], rows: [], graph: emptyGraphState(), done: false }), + ...(preserve ? {} : { commits: [], done: false }), loading: true, error: null, generation: next, @@ -1017,11 +993,8 @@ function loadPage(replace: boolean, id: string, before = false): Promise { ]), Number.MAX_SAFE_INTEGER, ); - const { rows, state } = computeGraph(commits.map(graphCommit)); setViewState(id, { commits, - rows, - graph: state, done: before ? cur.done : commits.length >= cap