Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
6 changes: 5 additions & 1 deletion src/panels/MainPanel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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();
}
Expand Down Expand Up @@ -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();

Expand Down
29 changes: 28 additions & 1 deletion src/utils/__tests__/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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');
});
});
22 changes: 22 additions & 0 deletions src/utils/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,25 @@ export function readInteractiveRebaseMode(): InteractiveRebaseMode {
vscode.workspace.getConfiguration('gitGraphPlus').get<string>('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<string>('graphStyle', DEFAULT_GRAPH_STYLE),
);
}
1 change: 1 addition & 0 deletions src/utils/message-bus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] } }
Expand Down
3 changes: 3 additions & 0 deletions webview-ui/src/App.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
9 changes: 9 additions & 0 deletions webview-ui/src/__tests__/App.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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', {
Expand Down
34 changes: 23 additions & 11 deletions webview-ui/src/components/graph/CommitGraph.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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[] = [];
Expand All @@ -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) {
Expand Down Expand Up @@ -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 }> = [];
Expand Down Expand Up @@ -1405,8 +1414,8 @@
{#each visiblePaths as path}
{@const pathColor = resolveGraphColor(graphColorsStore.palette, path.color, path.colorOverride)}
{#if path.d}
<path d={path.d} fill="none" stroke={pathColor} stroke-width="5" opacity="0.07" stroke-linecap="round" />
<path d={path.d} fill="none" stroke={pathColor} stroke-width="2" opacity="0.85" stroke-linecap="round" />
<path d={path.d} fill="none" stroke={pathColor} stroke-width="5" opacity="0.07" stroke-linecap="round" stroke-linejoin={uiStore.graphStyle === 'angular' ? 'miter' : undefined} />
<path d={path.d} fill="none" stroke={pathColor} stroke-width="2" opacity="0.85" stroke-linecap="round" stroke-linejoin={uiStore.graphStyle === 'angular' ? 'miter' : undefined} />
{/if}
{/each}

Expand All @@ -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}`}
<path
d="M {sx} {sy} Q {cx} {cy}, {ex} {ey}"
fill="none" stroke={linkColor} stroke-width="5" opacity="0.07" stroke-linecap="round"
d={linkD}
fill="none" stroke={linkColor} stroke-width="5" opacity="0.07" stroke-linecap="round" stroke-linejoin={uiStore.graphStyle === 'angular' ? 'miter' : undefined}
/>
<path
d="M {sx} {sy} Q {cx} {cy}, {ex} {ey}"
fill="none" stroke={linkColor} stroke-width="2" opacity="0.85" stroke-linecap="round"
d={linkD}
fill="none" stroke={linkColor} stroke-width="2" opacity="0.85" stroke-linecap="round" stroke-linejoin={uiStore.graphStyle === 'angular' ? 'miter' : undefined}
/>
{/each}

Expand Down
83 changes: 83 additions & 0 deletions webview-ui/src/components/graph/__tests__/CommitGraph.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<SVGPathElement>('.graph-lines path')).map(
(p) => p.getAttribute('d') ?? '',
);
}

beforeEach(() => {
i18n.setLocale('en');
// Reset shared singletons between tests.
Expand All @@ -49,6 +79,7 @@ beforeEach(() => {
branchStore.branches = [];
branchStore.worktrees = [];
uiStore.selectedCommitHash = null;
uiStore.graphStyle = 'rounded';
modalStore.closeAll();
});

Expand Down Expand Up @@ -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([
Expand Down
3 changes: 2 additions & 1 deletion webview-ui/src/lib/stores/ui.svelte.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -24,6 +24,7 @@ class UiStore {
operating = $state<string | null>(null);
badgeBarWidth = $state(4);
loadMoreCount = $state(50);
graphStyle = $state<GraphStyle>('rounded');
interactiveRebaseMode = $state<InteractiveRebaseMode>('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
Expand Down
3 changes: 3 additions & 0 deletions webview-ui/src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down