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.
Цей коміт міститься в:
l0ng-ai committed 2026-11-07 10:39:40 +08:00
1 джерело 50b55ffc9e
коміт cb614eff95
8 files changed
+49 -70

No files matched your search

+2
Переглянути файл
@@ -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;
+4 -1
Переглянути файл
@@ -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;
-1
Переглянути файл
@@ -67,7 +67,6 @@ beforeEach(() => {
logTabs: [],
filters: emptyFilters(),
commits: [],
rows: [],
selection: [],
selected: null,
done: false,
-1
Переглянути файл
@@ -82,7 +82,6 @@ beforeEach(() => {
tab: "log",
logTabs: [],
commits: [],
rows: [],
filters: emptyFilters(),
selection: [],
selected: null,
-1
Переглянути файл
@@ -42,7 +42,6 @@ beforeEach(() => {
tab: "log",
logTabs: [],
commits: [],
rows: [],
filters: emptyFilters(),
selection: [],
selected: null,
-3
Переглянути файл
@@ -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);
});
+38 -31
Переглянути файл
@@ -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<number> {
const out = new Set<number>();
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<number, number>();
for (let r = 0; r <= rows.length; r++) {
const through = r < rows.length ? passingLanes(rows[r]!) : new Set<number>();
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;
}
+5 -32
Переглянути файл
@@ -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<LogState, "generation" | "viewsVersion"> => ({
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<string, LogView>();
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<void> {
]),
Number.MAX_SAFE_INTEGER,
);
const { rows, state } = computeGraph(commits.map(graphCommit));
setViewState(id, {
commits,
rows,
graph: state,
done: before
? cur.done
: commits.length >= cap