дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 19:25:54 +02:00
perf(vcs): keep Git Log rows from re-rendering on every selection change
Commit rows were memoized but received a new Set and new closures on every render, so moving the selection re-rendered every visible row, and each row built a new Intl.DateTimeFormat for its date. Rows now get stable handlers and shared remote-name sets, and date formatting reuses cached formatters.
Цей коміт міститься в:
1 джерело
b524d547d5
коміт
6999d4034f
3 files changed
+113
-47
No files matched your search
@@ -1388,6 +1388,28 @@ function CommitList() {
|
||||
else if (e.metaKey || e.ctrlKey) toggleCommitSelection(id);
|
||||
else selectCommit(id);
|
||||
}, []);
|
||||
// CommitRow is memoized: rows get handlers that stay the same across renders and
|
||||
// call this render's (assigned before rendering).
|
||||
const rowEvents = useRef<RowEvents | null>(null);
|
||||
const rowHandlers = useMemo<RowEvents>(
|
||||
() => ({
|
||||
onHover: (edge) => rowEvents.current?.onHover(edge),
|
||||
onFragment: (from, target, expand) => rowEvents.current?.onFragment(from, target, expand),
|
||||
onNavigate: (index, key) => rowEvents.current?.onNavigate(index, key),
|
||||
}),
|
||||
[],
|
||||
);
|
||||
// Called while a row renders its graph, so it changes with what it reads.
|
||||
const targetTitle = useCallback(
|
||||
(index: number, key?: string) => {
|
||||
const target = commits[index];
|
||||
return target
|
||||
? `${target.shortId} ${target.summary} — ${target.author}`
|
||||
: (key && boundaryTitles.get(key)) ||
|
||||
`${tx("Commit is outside the loaded history")} · ${key?.split("\0").at(-1)?.slice(0, 8) ?? ""}`;
|
||||
},
|
||||
[commits, boundaryTitles],
|
||||
);
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (!e.ctrlKey && !e.metaKey && !e.altKey && (e.key.length === 1 || e.key === "Backspace" || e.key === "Escape")) {
|
||||
@@ -1559,6 +1581,38 @@ function CommitList() {
|
||||
}
|
||||
};
|
||||
|
||||
rowEvents.current = {
|
||||
onHover: (edge) => {
|
||||
setHoverColor(edge);
|
||||
if (!edge) return;
|
||||
const source = displayedGraph.find((c) => c.parents.some((p) => `${c.id}\0${p}` === edge));
|
||||
const target = source?.parents.find((p) => `${source.id}\0${p}` === edge);
|
||||
if (!source || !target || commits.some((c) => commitKey(c) === target) || boundaryRequests.current.has(target))
|
||||
return;
|
||||
const original = all.find((c) => commitKey(c) === source.id);
|
||||
if (!original) return;
|
||||
boundaryRequests.current.add(target);
|
||||
const split = target.lastIndexOf("\0"),
|
||||
id = target.slice(split + 1);
|
||||
void loadLogDetails({ ...original, id })
|
||||
.then((details) =>
|
||||
setBoundaryTitles((current) =>
|
||||
new Map(current).set(target, `${id.slice(0, 8)} ${details.message.split("\n")[0]}`),
|
||||
),
|
||||
)
|
||||
.catch(() => undefined);
|
||||
},
|
||||
onFragment: fragment,
|
||||
onNavigate: (index, key) => {
|
||||
const target = commits[index];
|
||||
if (target) selectCommit(commitKey(target), true);
|
||||
else if (key) {
|
||||
const split = key.lastIndexOf("\0");
|
||||
void goToRef(key.slice(split + 1), split >= 0 ? key.slice(0, split) : currentRepo());
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.listWrap}>
|
||||
{earlier && (
|
||||
@@ -1647,52 +1701,15 @@ function CommitList() {
|
||||
mine={opts.highlightMine && isMine(c, user)}
|
||||
onBranch={!!onBranch?.has(commitKey(c))}
|
||||
opts={opts}
|
||||
remoteNames={new Set((c as RepoCommit).remoteNames ?? remoteNames)}
|
||||
remoteNames={rowRemoteNames((c as RepoCommit).remoteNames, remoteNames)}
|
||||
onPick={pick}
|
||||
hoverColor={hoverColor}
|
||||
highlighted={highlighted}
|
||||
highlightedNodes={highlightedNodes}
|
||||
onHover={(edge) => {
|
||||
setHoverColor(edge);
|
||||
if (!edge) return;
|
||||
const source = displayedGraph.find((c) => c.parents.some((p) => `${c.id}\0${p}` === edge));
|
||||
const target = source?.parents.find((p) => `${source.id}\0${p}` === edge);
|
||||
if (
|
||||
!source ||
|
||||
!target ||
|
||||
commits.some((c) => commitKey(c) === target) ||
|
||||
boundaryRequests.current.has(target)
|
||||
)
|
||||
return;
|
||||
const original = all.find((c) => commitKey(c) === source.id);
|
||||
if (!original) return;
|
||||
boundaryRequests.current.add(target);
|
||||
const split = target.lastIndexOf("\0"),
|
||||
id = target.slice(split + 1);
|
||||
void loadLogDetails({ ...original, id })
|
||||
.then((details) =>
|
||||
setBoundaryTitles((current) =>
|
||||
new Map(current).set(target, `${id.slice(0, 8)} ${details.message.split("\n")[0]}`),
|
||||
),
|
||||
)
|
||||
.catch(() => undefined);
|
||||
}}
|
||||
onFragment={fragment}
|
||||
onNavigate={(index, key) => {
|
||||
const target = commits[index];
|
||||
if (target) selectCommit(commitKey(target), true);
|
||||
else if (key) {
|
||||
const split = key.lastIndexOf("\0");
|
||||
void goToRef(key.slice(split + 1), split >= 0 ? key.slice(0, split) : currentRepo());
|
||||
}
|
||||
}}
|
||||
targetTitle={(index, key) => {
|
||||
const target = commits[index];
|
||||
return target
|
||||
? `${target.shortId} ${target.summary} — ${target.author}`
|
||||
: (key && boundaryTitles.get(key)) ||
|
||||
`${tx("Commit is outside the loaded history")} · ${key?.split("\0").at(-1)?.slice(0, 8) ?? ""}`;
|
||||
}}
|
||||
onHover={rowHandlers.onHover}
|
||||
onFragment={rowHandlers.onFragment}
|
||||
onNavigate={rowHandlers.onNavigate}
|
||||
targetTitle={targetTitle}
|
||||
columns={columns}
|
||||
bookmarked={!!bookmarks[commitKey(c)]}
|
||||
/>
|
||||
@@ -1804,6 +1821,22 @@ function LogColumnHeader({
|
||||
);
|
||||
}
|
||||
|
||||
type RowEvents = {
|
||||
onHover: (edge: string | null) => void;
|
||||
onFragment: (from: string, target: string, expand: boolean) => void;
|
||||
onNavigate: (index: number, key?: string) => void;
|
||||
};
|
||||
|
||||
const remoteNameSets = new WeakMap<readonly string[], ReadonlySet<string>>();
|
||||
|
||||
/** A commit's own remote names (multi-root logs) as one set per array, else the shared one. */
|
||||
function rowRemoteNames(own: readonly string[] | undefined, shared: ReadonlySet<string>): ReadonlySet<string> {
|
||||
if (!own) return shared;
|
||||
let set = remoteNameSets.get(own);
|
||||
if (!set) remoteNameSets.set(own, (set = new Set(own)));
|
||||
return set;
|
||||
}
|
||||
|
||||
const CommitRow = memo(function CommitRow({
|
||||
id,
|
||||
commit: c,
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { DEFAULT_DATE_FORMAT, formatClock, formatDay, formatPattern } from "./dateFormat";
|
||||
|
||||
describe("dateFormat", () => {
|
||||
const d = new Date(2026, 10, 5, 9, 7);
|
||||
const system = { ...DEFAULT_DATE_FORMAT, overrideSystem: false };
|
||||
const long = { year: "numeric", month: "short", day: "numeric" } as const;
|
||||
|
||||
it("formats like the locale methods it replaces", () => {
|
||||
expect(formatDay(d, system)).toBe(d.toLocaleDateString(undefined, long));
|
||||
expect(formatDay(d, system, true)).toBe(
|
||||
d.toLocaleDateString(undefined, { year: "2-digit", month: "2-digit", day: "2-digit" }),
|
||||
);
|
||||
expect(formatClock(d, system)).toBe(d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" }));
|
||||
expect(formatPattern(d, "MMMM MMM dd.MM.yyyy")).toBe("November Nov 05.11.2026");
|
||||
});
|
||||
|
||||
it("formats another date with the same cached formatter", () => {
|
||||
const e = new Date(2025, 0, 31);
|
||||
formatDay(d, system);
|
||||
expect(formatDay(e, system)).toBe(e.toLocaleDateString(undefined, long));
|
||||
});
|
||||
});
|
||||
@@ -34,13 +34,23 @@ export function dateFormatOptions(): DateFormatOptions {
|
||||
|
||||
const pad = (n: number, w = 2) => String(n).padStart(w, "0");
|
||||
|
||||
// `toLocale*String` with options builds a new `Intl.DateTimeFormat` on every call,
|
||||
// far too slow for lists that format a date per row.
|
||||
const formatters = new Map<string, Intl.DateTimeFormat>();
|
||||
function formatter(locale: string | undefined, options: Intl.DateTimeFormatOptions): Intl.DateTimeFormat {
|
||||
const key = `${locale ?? ""}|${JSON.stringify(options)}`;
|
||||
let f = formatters.get(key);
|
||||
if (!f) formatters.set(key, (f = new Intl.DateTimeFormat(locale, options)));
|
||||
return f;
|
||||
}
|
||||
|
||||
/** Formats the date part with a `dd.MM.yyyy`-style pattern. */
|
||||
export function formatPattern(d: Date, pattern: string): string {
|
||||
const tokens: Record<string, () => string> = {
|
||||
yyyy: () => String(d.getFullYear()),
|
||||
yy: () => pad(d.getFullYear() % 100),
|
||||
MMMM: () => d.toLocaleString("en", { month: "long" }),
|
||||
MMM: () => d.toLocaleString("en", { month: "short" }),
|
||||
MMMM: () => formatter("en", { month: "long" }).format(d),
|
||||
MMM: () => formatter("en", { month: "short" }).format(d),
|
||||
MM: () => pad(d.getMonth() + 1),
|
||||
M: () => String(d.getMonth() + 1),
|
||||
dd: () => pad(d.getDate()),
|
||||
@@ -50,7 +60,7 @@ export function formatPattern(d: Date, pattern: string): string {
|
||||
}
|
||||
|
||||
export function formatClock(d: Date, o: DateFormatOptions = dateFormatOptions()): string {
|
||||
if (!o.overrideSystem) return d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" });
|
||||
if (!o.overrideSystem) return formatter(undefined, { hour: "2-digit", minute: "2-digit" }).format(d);
|
||||
if (o.use24Hour) return `${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
const h = d.getHours() % 12 || 12;
|
||||
return `${h}:${pad(d.getMinutes())} ${d.getHours() < 12 ? "AM" : "PM"}`;
|
||||
@@ -60,8 +70,8 @@ export function formatClock(d: Date, o: DateFormatOptions = dateFormatOptions())
|
||||
export function formatDay(d: Date, o: DateFormatOptions = dateFormatOptions(), short = false): string {
|
||||
if (o.overrideSystem) return formatPattern(d, o.pattern);
|
||||
return short
|
||||
? d.toLocaleDateString(undefined, { year: "2-digit", month: "2-digit", day: "2-digit" })
|
||||
: d.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" });
|
||||
? formatter(undefined, { year: "2-digit", month: "2-digit", day: "2-digit" }).format(d)
|
||||
: formatter(undefined, { year: "numeric", month: "short", day: "numeric" }).format(d);
|
||||
}
|
||||
|
||||
/** Timestamp for lists: pretty ("Today 10:42", "5 min ago") when enabled, else date + time. */
|
||||
|
||||
Reference in new issue
Заблокувати користувача