diff --git a/src/features/vcs/GitLog.tsx b/src/features/vcs/GitLog.tsx index b2964ea7d..18fccf7c3 100644 --- a/src/features/vcs/GitLog.tsx +++ b/src/features/vcs/GitLog.tsx @@ -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(null); + const rowHandlers = useMemo( + () => ({ + 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 (
{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>(); + +/** 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): ReadonlySet { + 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, diff --git a/src/platform/dateFormat.test.ts b/src/platform/dateFormat.test.ts new file mode 100644 index 000000000..94bb49729 --- /dev/null +++ b/src/platform/dateFormat.test.ts @@ -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)); + }); +}); diff --git a/src/platform/dateFormat.ts b/src/platform/dateFormat.ts index 8552f135d..919e2a9bd 100644 --- a/src/platform/dateFormat.ts +++ b/src/platform/dateFormat.ts @@ -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(); +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> = { 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. */