From 5c0a37f3eaad44f3866b94597cf96469e9c623e5 Mon Sep 17 00:00:00 2001 From: Patrick Cheung Date: Fri, 25 Sep 2026 18:17:31 +0800 Subject: [PATCH] feat(graph): configurable rounded/angular graph line style --- package.json | 13 +++ src/panels/MainPanel.ts | 6 +- src/utils/__tests__/config.test.ts | 29 ++++++- src/utils/config.ts | 22 +++++ src/utils/message-bus.ts | 1 + webview-ui/src/App.svelte | 3 + webview-ui/src/__tests__/App.test.ts | 9 ++ .../src/components/graph/CommitGraph.svelte | 34 +++++--- .../graph/__tests__/CommitGraph.test.ts | 83 +++++++++++++++++++ webview-ui/src/lib/stores/ui.svelte.ts | 3 +- webview-ui/src/lib/types.ts | 3 + 11 files changed, 192 insertions(+), 14 deletions(-) diff --git a/package.json b/package.json index 8a1c2932..3c70be22 100644 --- a/package.json +++ b/package.json @@ -434,6 +434,19 @@ "default": "topological", "description": "Commit sort order in the graph" }, + "gitGraphPlus.graphStyle": { + "type": "string", + "enum": [ + "rounded", + "angular" + ], + "enumDescriptions": [ + "Draw graph lines with smooth rounded curves (default)", + "Draw graph lines as straight lines with right-angled elbows" + ], + "default": "rounded", + "markdownDescription": "Line style of the commit graph: `rounded` draws smooth `Q`/`C` beziers, `angular` draws straight lines with right-angled elbows." + }, "gitGraphPlus.showSignatureStatus": { "type": "boolean", "default": true, diff --git a/src/panels/MainPanel.ts b/src/panels/MainPanel.ts index 5869905d..82b2eb64 100644 --- a/src/panels/MainPanel.ts +++ b/src/panels/MainPanel.ts @@ -5,7 +5,7 @@ import { GitService, GitError } from '../git/git-service'; import { formatGitError, isAuthFailure, transportFromRemoteUrl } from '../git/git-error-formatter'; import { splitUpstreamRef } from '../git/git-parser'; import { samePath } from '../utils/path'; -import { readTimeoutMs, readInitialCommitCount, readLoadMoreCommitCount, readInteractiveRebaseMode } from '../utils/config'; +import { readTimeoutMs, readInitialCommitCount, readLoadMoreCommitCount, readInteractiveRebaseMode, readGraphStyle } from '../utils/config'; import { buildClassicRebaseCommand } from '../git/classic-rebase'; import { buildFullGraph } from '../git/git-graph-builder'; import { compileBranchColorRules, makeBranchColorResolver } from '../git/branch-color-resolver'; @@ -226,6 +226,9 @@ export class MainPanel { if (e.affectsConfiguration('gitGraphPlus.loadMoreCommitCount')) { this.post({ type: 'setLoadMoreCount', payload: { count: readLoadMoreCommitCount() } }); } + if (e.affectsConfiguration('gitGraphPlus.graphStyle')) { + this.post({ type: 'setGraphStyle', payload: { style: readGraphStyle() } }); + } if (e.affectsConfiguration('gitGraphPlus.branchColors')) { this.refreshAll(); } @@ -255,6 +258,7 @@ export class MainPanel { this.post({ type: 'setBadgeBarThickness', payload: { width: this.readBadgeBarWidth() } }); this.post({ type: 'setGraphColors', payload: { colors: this.readGraphColors() } }); this.post({ type: 'setLoadMoreCount', payload: { count: readLoadMoreCommitCount() } }); + this.post({ type: 'setGraphStyle', payload: { style: readGraphStyle() } }); this.post({ type: 'setInteractiveRebaseMode', payload: { mode: readInteractiveRebaseMode() } }); void this.postCommitLinkRules(); diff --git a/src/utils/__tests__/config.test.ts b/src/utils/__tests__/config.test.ts index 63667d34..4ee72ff1 100644 --- a/src/utils/__tests__/config.test.ts +++ b/src/utils/__tests__/config.test.ts @@ -13,7 +13,7 @@ vi.mock('vscode', () => ({ }, })); -import { readTimeoutMs, readInitialCommitCount, readLoadMoreCommitCount } from '../config'; +import { readTimeoutMs, readInitialCommitCount, readLoadMoreCommitCount, readGraphStyle } from '../config'; describe('readTimeoutMs', () => { // Back-compat alias so the existing timeout cases below read naturally. @@ -101,3 +101,30 @@ describe('readLoadMoreCommitCount', () => { expect(readLoadMoreCommitCount()).toBe(50); }); }); + +describe('readGraphStyle', () => { + beforeEach(() => { h.values = {}; }); + + it('defaults to rounded when unset', () => { + expect(readGraphStyle()).toBe('rounded'); + }); + + it('returns angular when configured', () => { + h.values.graphStyle = 'angular'; + expect(readGraphStyle()).toBe('angular'); + }); + + it('returns rounded when explicitly configured', () => { + h.values.graphStyle = 'rounded'; + expect(readGraphStyle()).toBe('rounded'); + }); + + it('falls back to rounded for unknown or non-string values', () => { + h.values.graphStyle = 'zigzag'; + expect(readGraphStyle()).toBe('rounded'); + h.values.graphStyle = 42; + expect(readGraphStyle()).toBe('rounded'); + h.values.graphStyle = null; + expect(readGraphStyle()).toBe('rounded'); + }); +}); diff --git a/src/utils/config.ts b/src/utils/config.ts index fa8fabed..de1d66a8 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -47,3 +47,25 @@ export function readInteractiveRebaseMode(): InteractiveRebaseMode { vscode.workspace.getConfiguration('gitGraphPlus').get('interactiveRebase.mode', 'ui'), ); } + +/** Line style used when drawing the commit graph. */ +export type GraphStyle = 'rounded' | 'angular'; + +/** Default value of `gitGraphPlus.graphStyle`. */ +export const DEFAULT_GRAPH_STYLE: GraphStyle = 'rounded'; + +/** Maps any raw setting value to a supported graph style (`rounded` fallback). */ +export function normalizeGraphStyle(value: unknown): GraphStyle { + return value === 'angular' ? 'angular' : 'rounded'; +} + +/** + * Reads `gitGraphPlus.graphStyle` — `rounded` (default) draws smooth curves, + * `angular` draws straight lines with right-angled elbows. Falls back to + * `rounded` when unset or invalid. + */ +export function readGraphStyle(): GraphStyle { + return normalizeGraphStyle( + vscode.workspace.getConfiguration('gitGraphPlus').get('graphStyle', DEFAULT_GRAPH_STYLE), + ); +} diff --git a/src/utils/message-bus.ts b/src/utils/message-bus.ts index deec2ab7..85138128 100644 --- a/src/utils/message-bus.ts +++ b/src/utils/message-bus.ts @@ -158,6 +158,7 @@ export type ExtensionMessage = | { type: 'setInteractiveRebaseMode'; payload: { mode: 'ui' | 'classic' } } | { type: 'setDefaults'; payload: ModalDefaults } | { type: 'setLoadMoreCount'; payload: { count: number } } + | { type: 'setGraphStyle'; payload: { style: 'rounded' | 'angular' } } | { type: 'setBadgeBarThickness'; payload: { width: number } } | { type: 'setGraphColors'; payload: { colors: string[] } } | { type: 'setCommitLinkRules'; payload: { rules: LinkRule[] } } diff --git a/webview-ui/src/App.svelte b/webview-ui/src/App.svelte index c649320f..a06c41f4 100644 --- a/webview-ui/src/App.svelte +++ b/webview-ui/src/App.svelte @@ -115,6 +115,9 @@ import AmendModal from './components/modals/AmendModal.svelte'; case 'setLoadMoreCount': uiStore.loadMoreCount = msg.payload.count; break; + case 'setGraphStyle': + uiStore.graphStyle = msg.payload.style; + break; case 'setInteractiveRebaseMode': uiStore.interactiveRebaseMode = msg.payload.mode; break; diff --git a/webview-ui/src/__tests__/App.test.ts b/webview-ui/src/__tests__/App.test.ts index 69b013b7..abcba88d 100644 --- a/webview-ui/src/__tests__/App.test.ts +++ b/webview-ui/src/__tests__/App.test.ts @@ -30,6 +30,7 @@ function resetStores() { uiStore.activeRepo = ''; uiStore.operating = null; uiStore.badgeBarWidth = 4; + uiStore.graphStyle = 'rounded'; uiStore.setError(null); // modalStore is a singleton across tests; one stuck open modal will render // through every subsequent App mount and break unrelated assertions. @@ -103,6 +104,14 @@ describe('App — message handling', () => { }); }); + it('setGraphStyle updates uiStore.graphStyle', async () => { + render(App); + postMsg('setGraphStyle', { style: 'angular' }); + await waitFor(() => { + expect(uiStore.graphStyle).toBe('angular'); + }); + }); + it('repoList populates uiStore.repos and activeRepo', async () => { render(App); postMsg('repoList', { diff --git a/webview-ui/src/components/graph/CommitGraph.svelte b/webview-ui/src/components/graph/CommitGraph.svelte index aa5d2742..2bb557b1 100644 --- a/webview-ui/src/components/graph/CommitGraph.svelte +++ b/webview-ui/src/components/graph/CommitGraph.svelte @@ -23,7 +23,7 @@ import SquashModal from '../modals/SquashModal.svelte'; import MultiCherryPickModal from '../modals/MultiCherryPickModal.svelte'; import { modalStore } from '../../lib/stores/modals.svelte'; - import type { Commit, CommitGraphData } from '../../lib/types'; + import type { Commit, CommitGraphData, GraphStyle } from '../../lib/types'; import { tooltip } from '../../lib/actions/tooltip'; import { getSquashChain } from '../../lib/utils/squash'; import { chainBranches } from '../../lib/utils/branchChain'; @@ -38,9 +38,10 @@ /** * Build SVG path `d` string from SourceGit Path points. - * Exactly mirrors SourceGit's DrawCurves rendering. + * Rounded (default) exactly mirrors SourceGit's DrawCurves rendering (Q/C + * beziers); angular draws straight lines with right-angled elbows. */ - function buildPathD(points: Array<{ x: number; y: number }>): string { + function buildPathD(points: Array<{ x: number; y: number }>, style: GraphStyle): string { if (points.length < 2) return ''; const parts: string[] = []; @@ -50,7 +51,15 @@ for (let i = 1; i < points.length; i++) { const cur = { x: laneX(points[i].x), y: points[i].y * ROW_HEIGHT }; - if (cur.x > last.x) { + if (style === 'angular') { + if (cur.x !== last.x) { + // Elbow: horizontal to the target lane, then vertical to the row. + parts.push(`L ${cur.x} ${last.y} L ${cur.x} ${cur.y}`); + } else { + // Same X: straight line + parts.push(`L ${cur.x} ${cur.y}`); + } + } else if (cur.x > last.x) { // Going right: QuadraticBezier with control at (cur.x, last.y) parts.push(`Q ${cur.x} ${last.y}, ${cur.x} ${cur.y}`); } else if (cur.x < last.x) { @@ -515,7 +524,7 @@ // Path geometry never changes, only which paths are on screen does. Precompute the // SVG "d" string once per path (on data change) so scrolling never rebuilds them. - let pathDs = $derived(displayPaths.map(p => buildPathD(p.points))); + let pathDs = $derived(displayPaths.map(p => buildPathD(p.points, uiStore.graphStyle))); let visiblePaths = $derived.by(() => { const out: Array<{ color: number; colorOverride?: string; d: string }> = []; @@ -1405,8 +1414,8 @@ {#each visiblePaths as path} {@const pathColor = resolveGraphColor(graphColorsStore.palette, path.color, path.colorOverride)} {#if path.d} - - + + {/if} {/each} @@ -1419,13 +1428,16 @@ {@const cy = link.control.y * ROW_HEIGHT} {@const ex = laneX(link.end.x)} {@const ey = link.end.y * ROW_HEIGHT} + {@const linkD = uiStore.graphStyle === 'angular' + ? `M ${sx} ${sy} L ${cx} ${cy} L ${ex} ${ey}` + : `M ${sx} ${sy} Q ${cx} ${cy}, ${ex} ${ey}`} {/each} diff --git a/webview-ui/src/components/graph/__tests__/CommitGraph.test.ts b/webview-ui/src/components/graph/__tests__/CommitGraph.test.ts index 096dbd59..793bba3b 100644 --- a/webview-ui/src/components/graph/__tests__/CommitGraph.test.ts +++ b/webview-ui/src/components/graph/__tests__/CommitGraph.test.ts @@ -35,6 +35,36 @@ function makeGraphData(commits: Commit[]): CommitGraphData { }; } +// Multi-lane data: one path that moves right, back left (cubic), then straight, +// plus one merge link. Exercises every branch of the geometry builder. +function makeStyledGraphData(): CommitGraphData { + const commits = [ + makeCommit('h1', 'first'), + makeCommit('h2', 'second', ['h1']), + makeCommit('h3', 'third', ['h2']), + ]; + return { + commits, + graph: commits.map((c, i) => ({ commit: c.hash, column: i, color: '#63b0f4', parents: [] })), + paths: [ + { points: [{ x: 0, y: 0 }, { x: 2, y: 1 }, { x: 0, y: 2 }, { x: 0, y: 3 }], color: 0 }, + ], + links: [ + { start: { x: 0, y: 0 }, control: { x: 1, y: 0 }, end: { x: 1, y: 1 }, color: 0 }, + ], + dots: commits.map((_, i) => ({ center: { x: 0, y: i }, color: 0, type: 'default' as const, localOnly: false, remoteTip: false })), + commitLeftMargin: commits.map(() => 24), + hasMore: false, + currentLimit: 1000, + }; +} + +function renderedPathDs(container: HTMLElement): string[] { + return Array.from(container.querySelectorAll('.graph-lines path')).map( + (p) => p.getAttribute('d') ?? '', + ); +} + beforeEach(() => { i18n.setLocale('en'); // Reset shared singletons between tests. @@ -49,6 +79,7 @@ beforeEach(() => { branchStore.branches = []; branchStore.worktrees = []; uiStore.selectedCommitHash = null; + uiStore.graphStyle = 'rounded'; modalStore.closeAll(); }); @@ -245,6 +276,58 @@ describe('CommitGraph smoke', () => { }); }); +describe('CommitGraph graph line style', () => { + it('renders straight right-angled elbows (L only, no Q/C) when graphStyle is angular', async () => { + uiStore.graphStyle = 'angular'; + commitStore.setData(makeStyledGraphData()); + const { container } = render(CommitGraph, {}); + await tick(); + + const ds = renderedPathDs(container); + expect(ds.length).toBeGreaterThan(0); + // Path elbows, then the merge link elbow. + expect(ds).toContain('M 0 0 L 2.1 0 L 2.1 30 L 0 30 L 0 60 L 0 90'); + expect(ds).toContain('M 0 0 L 1.05 0 L 1.05 30'); + for (const d of ds) { + expect(d).toContain('L'); + expect(d).not.toContain('Q'); + expect(d).not.toContain('C'); + } + for (const p of container.querySelectorAll('.graph-lines path')) { + expect(p.getAttribute('stroke-linejoin')).toBe('miter'); + } + }); + + it('renders rounded Q/C beziers by default', async () => { + commitStore.setData(makeStyledGraphData()); + const { container } = render(CommitGraph, {}); + await tick(); + + const ds = renderedPathDs(container); + expect(ds).toContain('M 0 0 Q 2.1 0, 2.1 30 C 2.1 49, 0 41, 0 60 L 0 90'); + expect(ds).toContain('M 0 0 Q 1.05 0, 1.05 30'); + expect(ds.some((d) => d.includes('Q'))).toBe(true); + expect(ds.some((d) => d.includes('C'))).toBe(true); + for (const p of container.querySelectorAll('.graph-lines path')) { + // Rounded mode keeps the previous DOM exactly: no explicit linejoin. + expect(p.getAttribute('stroke-linejoin')).toBeNull(); + } + }); + + it('renders rounded Q/C beziers when graphStyle is explicitly rounded', async () => { + uiStore.graphStyle = 'rounded'; + commitStore.setData(makeStyledGraphData()); + const { container } = render(CommitGraph, {}); + await tick(); + + const ds = renderedPathDs(container); + expect(ds).toContain('M 0 0 Q 2.1 0, 2.1 30 C 2.1 49, 0 41, 0 60 L 0 90'); + expect(ds).toContain('M 0 0 Q 1.05 0, 1.05 30'); + expect(ds.some((d) => d.includes('Q'))).toBe(true); + expect(ds.some((d) => d.includes('C'))).toBe(true); + }); +}); + describe('CommitGraph signature icon', () => { it('renders a signature icon for good/unverified commits', async () => { commitStore.setData(makeGraphData([ diff --git a/webview-ui/src/lib/stores/ui.svelte.ts b/webview-ui/src/lib/stores/ui.svelte.ts index 7df2ff17..037f2071 100644 --- a/webview-ui/src/lib/stores/ui.svelte.ts +++ b/webview-ui/src/lib/stores/ui.svelte.ts @@ -1,4 +1,4 @@ -import type { InteractiveRebaseMode } from '../types'; +import type { InteractiveRebaseMode, GraphStyle } from '../types'; export const BOTTOM_PANEL_DEFAULT_RATIO = 0.35; export const BOTTOM_PANEL_MIN_RATIO = 0.2; @@ -24,6 +24,7 @@ class UiStore { operating = $state(null); badgeBarWidth = $state(4); loadMoreCount = $state(50); + graphStyle = $state('rounded'); interactiveRebaseMode = $state('ui'); // True while a file is selected in the commit-details panel. Owned (synced) // by CommitDetails; read by the global Esc handler so the first Esc deselects diff --git a/webview-ui/src/lib/types.ts b/webview-ui/src/lib/types.ts index 7830c017..61a42330 100644 --- a/webview-ui/src/lib/types.ts +++ b/webview-ui/src/lib/types.ts @@ -16,6 +16,9 @@ export interface Commit { /** Simplified 3-state mapping of git's `%G?` verification codes. */ export type SignatureStatus = 'good' | 'none' | 'unverified'; +/** Line style used when drawing the commit graph (mirrors `gitGraphPlus.graphStyle`). */ +export type GraphStyle = 'rounded' | 'angular'; + /** On-demand signature details for a single commit (Details panel). */ export interface CommitSignature { status: SignatureStatus;