From b682f299e05f0d150a368c3f4aee7f3359b6e7eb Mon Sep 17 00:00:00 2001 From: cst8t <1810150+cst8t@users.noreply.github.com> Date: Thu, 9 Jul 2026 00:05:31 +0100 Subject: [PATCH 1/3] refactor(log): overhaul commit graph rendering --- src-tauri/src/git/cli.rs | 4 +- src-tauri/src/git/gix_handler.rs | 2 +- src-tauri/src/git/types.rs | 2 + src-tauri/tests/git.rs | 51 ++- src/api/commands.ts | 3 +- src/components/ProjectView.tsx | 17 +- src/components/centre/CentrePanel.css | 25 +- src/components/centre/CentrePanel.tsx | 5 + src/components/centre/CommitGraphGutter.tsx | 195 ++++++++++ src/components/centre/LogView.test.tsx | 61 ++- src/components/centre/LogView.tsx | 161 +------- src/hooks/useGitLog.test.ts | 20 +- src/hooks/useGitLog.ts | 25 +- src/types.ts | 1 + src/utils/commitGraph.test.ts | 392 ++++++++++++++++++-- src/utils/commitGraph.ts | 237 +++++++++--- 16 files changed, 917 insertions(+), 284 deletions(-) create mode 100644 src/components/centre/CommitGraphGutter.tsx diff --git a/src-tauri/src/git/cli.rs b/src-tauri/src/git/cli.rs index 744e9d0..d68d1ea 100644 --- a/src-tauri/src/git/cli.rs +++ b/src-tauri/src/git/cli.rs @@ -2907,11 +2907,13 @@ impl GitOperationHandler for CliGitHandler { "--date=iso-strict", pretty.as_str(), ]; + if request.topo_order || request.scope == CommitLogScope::AllRefs { + args.push("--topo-order"); + } if request.scope == CommitLogScope::AllRefs { args.push("--branches"); args.push("--tags"); args.push("--remotes"); - args.push("--topo-order"); } let output = match Self::run_git(args.as_slice(), Some(&repo_path)) { diff --git a/src-tauri/src/git/gix_handler.rs b/src-tauri/src/git/gix_handler.rs index e64f0fc..805ed26 100644 --- a/src-tauri/src/git/gix_handler.rs +++ b/src-tauri/src/git/gix_handler.rs @@ -1156,7 +1156,7 @@ impl GitOperationHandler for GixGitHandler { &self, request: &CommitHistoryRequest, ) -> GitResult> { - if request.scope == CommitLogScope::AllRefs { + if request.topo_order || request.scope == CommitLogScope::AllRefs { return self.cli_fallback.get_commit_history(request); } diff --git a/src-tauri/src/git/types.rs b/src-tauri/src/git/types.rs index 15a2fa8..a2445ab 100644 --- a/src-tauri/src/git/types.rs +++ b/src-tauri/src/git/types.rs @@ -439,6 +439,8 @@ pub struct CommitHistoryRequest { pub commit_date_mode: CommitDateMode, #[serde(default)] pub scope: CommitLogScope, + #[serde(default)] + pub topo_order: bool, } #[derive(Debug, Clone, Deserialize)] diff --git a/src-tauri/tests/git.rs b/src-tauri/tests/git.rs index 5dba63f..23814c3 100644 --- a/src-tauri/tests/git.rs +++ b/src-tauri/tests/git.rs @@ -190,6 +190,7 @@ fn history_request(dir: &TempDir) -> CommitHistoryRequest { offset: None, commit_date_mode: Default::default(), scope: Default::default(), + topo_order: false, } } @@ -1190,6 +1191,7 @@ fn commit_creates_entry_in_log() { offset: None, commit_date_mode: Default::default(), scope: Default::default(), + topo_order: false, }) .expect("get_commit_history"); assert!(commits.iter().any(|c| c.message == "add b.txt")); @@ -1564,6 +1566,7 @@ fn commit_history_all_refs_includes_branch_outside_detached_head() { offset: None, commit_date_mode: Default::default(), scope: CommitLogScope::CurrentCheckout, + topo_order: false, }) .expect("get current checkout history"); @@ -1575,6 +1578,7 @@ fn commit_history_all_refs_includes_branch_outside_detached_head() { offset: None, commit_date_mode: Default::default(), scope: CommitLogScope::AllRefs, + topo_order: false, }) .expect("get all refs history"); @@ -1630,6 +1634,7 @@ fn assert_commit_history_includes_merge_parents(handler: impl GitOperationHandle offset: None, commit_date_mode: Default::default(), scope: CommitLogScope::CurrentCheckout, + topo_order: false, }) .expect("get commit history"); @@ -1672,6 +1677,7 @@ fn assert_commit_history_includes_ref_decorations(handler: impl GitOperationHand offset: None, commit_date_mode: Default::default(), scope: CommitLogScope::CurrentCheckout, + topo_order: false, }) .expect("get commit history"); @@ -1791,6 +1797,7 @@ fn commit_history_respects_limit() { offset: None, commit_date_mode: Default::default(), scope: Default::default(), + topo_order: false, }) .expect("get_commit_history"); assert_eq!(commits.len(), 3); @@ -1812,6 +1819,7 @@ fn commit_history_returns_commits_newest_first() { offset: None, commit_date_mode: Default::default(), scope: Default::default(), + topo_order: false, }) .expect("get_commit_history"); assert_eq!(commits[0].message, "third"); @@ -1952,15 +1960,18 @@ fn gix_commit_history_matches_git_log_order_for_merge_commits() { .map(ToString::to_string) .collect(); + let history_request = |topo_order| CommitHistoryRequest { + repo_path: dir.path().to_str().unwrap().to_string(), + limit: Some(5), + after_hash: None, + offset: None, + commit_date_mode: Default::default(), + scope: Default::default(), + topo_order, + }; + let commits = gix_handler() - .get_commit_history(&CommitHistoryRequest { - repo_path: dir.path().to_str().unwrap().to_string(), - limit: Some(5), - after_hash: None, - offset: None, - commit_date_mode: Default::default(), - scope: Default::default(), - }) + .get_commit_history(&history_request(false)) .expect("get gix commit history"); let actual_messages: Vec = commits .iter() @@ -1968,6 +1979,30 @@ fn gix_commit_history_matches_git_log_order_for_merge_commits() { .collect(); assert_eq!(actual_messages, expected_messages); + + let expected_topo_messages: Vec = + git_stdout(dir.path(), &["log", "--topo-order", "-5", "--format=%s"]) + .lines() + .map(ToString::to_string) + .collect(); + + let cli_topo_commits = handler() + .get_commit_history(&history_request(true)) + .expect("get cli topo commit history"); + let cli_topo_messages: Vec = cli_topo_commits + .iter() + .map(|commit| commit.message.clone()) + .collect(); + assert_eq!(cli_topo_messages, expected_topo_messages); + + let gix_topo_commits = gix_handler() + .get_commit_history(&history_request(true)) + .expect("get gix topo commit history"); + let gix_topo_messages: Vec = gix_topo_commits + .iter() + .map(|commit| commit.message.clone()) + .collect(); + assert_eq!(gix_topo_messages, expected_topo_messages); } // commit_details: CLI handler diff --git a/src/api/commands.ts b/src/api/commands.ts index 9451dab..e26b0d2 100644 --- a/src/api/commands.ts +++ b/src/api/commands.ts @@ -140,8 +140,9 @@ export function getCommitHistory( afterHash?: string, offset?: number, scope?: CommitLogScope, + topoOrder?: boolean, ): Promise { - return invoke("get_commit_history", {request: {repoPath, limit, afterHash, offset, scope}}); + return invoke("get_commit_history", {request: {repoPath, limit, afterHash, offset, scope, topoOrder}}); } export function verifyCommits(repoPath: string, hashes: string[]): Promise { diff --git a/src/components/ProjectView.tsx b/src/components/ProjectView.tsx index 8190623..0da9850 100644 --- a/src/components/ProjectView.tsx +++ b/src/components/ProjectView.tsx @@ -83,6 +83,16 @@ const EMPTY_COMMIT_MARKERS: CommitMarkers = { upstreamRef: null, }; +const SHOW_COMMIT_GRAPH_KEY = "gitmun.showCommitGraph"; + +function readShowCommitGraphPreference(): boolean { + try { + return localStorage.getItem(SHOW_COMMIT_GRAPH_KEY) === "true"; + } catch { + return false; + } +} + function isStagingOperationKind(kind: LongRunningOperationKind): kind is StagingOperationKind { return kind === "stage" || kind === "stageAll" || kind === "unstage" || kind === "unstageAll"; } @@ -393,6 +403,7 @@ export function ProjectView({ const [wrapDiffLines, setWrapDiffLines] = useState(false); const [rowStriping, setRowStriping] = useState("Off"); const [showCommitGraphButton, setShowCommitGraphButton] = useState(false); + const [showCommitGraph, setShowCommitGraph] = useState(readShowCommitGraphPreference); const [searchQuery, setSearchQuery] = useState(""); const [windowFocused, setWindowFocused] = useState(() => ( typeof document === "undefined" ? true : document.hasFocus() @@ -415,6 +426,9 @@ export function ProjectView({ const initials = (displayName ?? "?") .split(" ").map(w => w[0]).join("").slice(0, 2).toUpperCase(); const [identityAvatar, setIdentityAvatar] = useState(null); + const handleCommitGraphVisibilityChange = useCallback((visible: boolean) => { + setShowCommitGraph(visible); + }, []); const currentBranch = status?.currentBranch ?? null; const { @@ -427,7 +441,7 @@ export function ProjectView({ loadMoreError: logLoadMoreError, pageSize: logPageSize, refresh: refreshLog, - } = useGitLog(repoPath, logScope, windowFocused); + } = useGitLog(repoPath, logScope, windowFocused, showCommitGraphButton && showCommitGraph); const stagedFiles = status?.stagedFiles ?? []; const unstagedFiles = status?.changedFiles ?? []; const unversionedFiles = status?.unversionedFiles ?? []; @@ -2623,6 +2637,7 @@ export function ProjectView({ logScope={logScope} rowStriping={rowStriping} showCommitGraphButton={showCommitGraphButton} + onCommitGraphVisibilityChange={handleCommitGraphVisibilityChange} onLogScopeChange={setLogScope} detachedHead={status?.detachedHead ?? false} shallow={status?.shallow ?? false} diff --git a/src/components/centre/CentrePanel.css b/src/components/centre/CentrePanel.css index 9c9a09a..fe32aa8 100644 --- a/src/components/centre/CentrePanel.css +++ b/src/components/centre/CentrePanel.css @@ -844,24 +844,15 @@ .log-view__graph-connector { stroke-width: 3; - stroke-linecap: round; -} - -.log-view__graph-vertical { - position: absolute; - width: 3px; - margin-left: -1px; - border-radius: 999px; + stroke-linejoin: round; } -.log-view__graph-vertical--top { - top: -1px; - height: calc(50% + 1px); +.log-view__graph-connector--curve { + stroke-linecap: round; } -.log-view__graph-vertical--bottom { - top: 50%; - bottom: -1px; +.log-view__graph-connector--vertical { + stroke-linecap: square; } .log-view__graph-node { @@ -870,15 +861,11 @@ border-radius: 50%; min-width: 8px; min-height: 8px; - box-shadow: 0 0 0 1.5px var(--bg-surface); -} - -.log-view__row--selected .log-view__graph-node { - box-shadow: 0 0 0 1.5px var(--selection-bg); } .log-view__graph-piece--dimmed { background: var(--border-strong, var(--border)) !important; + fill: var(--border-strong, var(--border)) !important; stroke: var(--border-strong, var(--border)) !important; } diff --git a/src/components/centre/CentrePanel.tsx b/src/components/centre/CentrePanel.tsx index 33edc90..356632e 100644 --- a/src/components/centre/CentrePanel.tsx +++ b/src/components/centre/CentrePanel.tsx @@ -68,6 +68,7 @@ type CentrePanelProps = { logScope: CommitLogScope; rowStriping: RowStriping; showCommitGraphButton: boolean; + onCommitGraphVisibilityChange?: (visible: boolean) => void; onLogScopeChange: (scope: CommitLogScope) => void; detachedHead: boolean; shallow: boolean; @@ -219,6 +220,10 @@ export function CentrePanel(props: CentrePanelProps) { const submoduleChanges = props.submodules.filter(submodule => submodule.state !== "clean").length; const totalChanges = props.stagedFiles.length + props.unstagedFiles.length + props.unversionedFiles.length + submoduleChanges; + React.useEffect(() => { + props.onCommitGraphVisibilityChange?.(effectiveShowCommitGraph); + }, [effectiveShowCommitGraph, props.onCommitGraphVisibilityChange]); + const handleToggleCommitGraph = () => { setShowCommitGraph(previous => { const next = !previous; diff --git a/src/components/centre/CommitGraphGutter.tsx b/src/components/centre/CommitGraphGutter.tsx new file mode 100644 index 0000000..ccb9619 --- /dev/null +++ b/src/components/centre/CommitGraphGutter.tsx @@ -0,0 +1,195 @@ +import { useTranslation } from "react-i18next"; +import type { CommitHistoryItem } from "../../types"; +import type { CommitGraphRow, CommitGraphSegment } from "../../utils/commitGraph"; + +const GRAPH_LANE_WIDTH = 12; +const GRAPH_NODE_RADIUS = 4; +const GRAPH_UNIT_HEIGHT = 100; + +function graphLaneColour(lane: number): string { + const colours = [ + "var(--accent)", + "var(--green)", + "var(--yellow)", + "var(--red)", + "#c4b5fd", + "#6ee7b7", + "#f0abfc", + "#93c5fd", + ]; + return colours[lane % colours.length]; +} + +function graphWidth(laneCount: number): number { + return Math.max(14, laneCount * GRAPH_LANE_WIDTH); +} + +function graphX(lane: number, laneCount: number): number { + return 5 + Math.min(lane, laneCount - 1) * GRAPH_LANE_WIDTH; +} + +function graphPieceClass(baseClass: string, active: boolean | null): string { + if (active === null) return baseClass; + return `${baseClass} ${active ? "log-view__graph-piece--active" : "log-view__graph-piece--dimmed"}`; +} + +function formatGraphNumber(value: number): string { + return Number.isInteger(value) ? String(value) : value.toFixed(2); +} + +function graphCurvePath( + fromLane: number, + toLane: number, + fromUnit: number, + toUnit: number, + laneCount: number, +): string { + const fromX = graphX(fromLane, laneCount); + const toX = graphX(toLane, laneCount); + const verticalDistance = toUnit - fromUnit; + const controlY1 = fromUnit + verticalDistance * 0.44; + const controlY2 = fromUnit + verticalDistance * 0.56; + return [ + `M ${formatGraphNumber(fromX)} ${formatGraphNumber(fromUnit)}`, + `C ${formatGraphNumber(fromX)} ${formatGraphNumber(controlY1)}`, + `${formatGraphNumber(toX)} ${formatGraphNumber(controlY2)}`, + `${formatGraphNumber(toX)} ${formatGraphNumber(toUnit)}`, + ].join(" "); +} + +function graphVerticalPath(lane: number, fromUnit: number, toUnit: number, laneCount: number): string { + const x = graphX(lane, laneCount); + return `M ${formatGraphNumber(x)} ${formatGraphNumber(fromUnit)} L ${formatGraphNumber(x)} ${formatGraphNumber(toUnit)}`; +} + +function graphTitle( + commit: CommitHistoryItem, + t: (key: string, options?: Record) => string, +): string { + const lines = [ + t("log.graphCommit", { hash: commit.shortHash }), + t("log.graphLaneHint"), + ]; + const firstParent = commit.parentHashes[0]; + const mergedParents = commit.parentHashes.slice(1); + if (firstParent) { + lines.push(t("log.graphFirstParent", { hash: firstParent.slice(0, 7) })); + } + if (mergedParents.length > 0) { + lines.push(t("log.graphMergedParents", { hashes: mergedParents.map(hash => hash.slice(0, 7)).join(", ") })); + } + if (commit.refDecorations.length > 0) { + lines.push(t("log.graphRefs", { refs: commit.refDecorations.map(ref => ref.name).join(", ") })); + } + return lines.join("\n"); +} + +function segmentKey(segment: CommitGraphSegment, index: number): string { + if (segment.kind === "curve") { + return `${index}-curve-${segment.fromLane}-${segment.toLane}-${segment.hash}`; + } + return `${index}-${segment.kind}-${segment.lane}-${segment.hash}`; +} + +function graphGradientId(commitHash: string, segment: CommitGraphSegment, index: number): string { + const key = segmentKey(segment, index).replace(/[^A-Za-z0-9_-]/g, "_"); + return `graph-gradient-${commitHash.slice(0, 12)}-${key}`; +} + +export function CommitGraphGutter({ + row, + laneCount, + commit, + highlightedHashes, +}: { + row: CommitGraphRow | undefined; + laneCount: number; + commit: CommitHistoryItem; + highlightedHashes: Set | null; +}) { + const { t } = useTranslation("centre"); + const width = graphWidth(laneCount); + if (!row) { + return