Skip to content

Commit c042493

Browse files
committed
feat(graph): add jump-to-HEAD button and HEAD search matching (#56)
Add a location button beside the search box that scrolls the graph to the current HEAD commit, emphasized only when HEAD is off-screen. Also match the HEAD keyword in search so typing HEAD navigates to it.
1 parent 901de4e commit c042493

11 files changed

Lines changed: 212 additions & 4 deletions

File tree

webview-ui/src/App.svelte

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@ import AmendModal from './components/modals/AmendModal.svelte';
5454
let bisectMessage = $state<string | null>(null);
5555
let searchMatchedHashes = $state<Set<string> | null>(null);
5656
let searchNavigateHash = $state<string | null>(null);
57+
let headOffscreen = $state(false);
58+
let headJumpNonce = $state(0);
5759
let remoteFilter = $state<string[]>([]);
5860
let branchFilter = $state<string[]>([]);
5961
let resizing = $state(false);
@@ -279,6 +281,10 @@ import AmendModal from './components/modals/AmendModal.svelte';
279281
searchNavigateHash = hash;
280282
}
281283
284+
function handleJumpToHead() {
285+
headJumpNonce++;
286+
}
287+
282288
function handleFilterChange(filter: string[]) {
283289
remoteFilter = filter;
284290
if (filter.length > 0) {
@@ -448,6 +454,8 @@ import AmendModal from './components/modals/AmendModal.svelte';
448454
branches={branchStore.branches}
449455
{branchFilter}
450456
onBranchFilterChange={handleBranchFilterChange}
457+
{headOffscreen}
458+
onJumpToHead={handleJumpToHead}
451459
/>
452460
{/if}
453461
{#if bisectMessage}
@@ -460,7 +468,7 @@ import AmendModal from './components/modals/AmendModal.svelte';
460468
{/if}
461469
{#if !uiStore.commitDetailFullscreen}
462470
<div class="graph-area">
463-
<CommitGraph {searchMatchedHashes} {searchNavigateHash} bisectActive={bisectMessage !== null} bisectCulpritHash={bisectMessage?.includes('is the first bad commit') ? bisectMessage.match(/^([a-f0-9]{7,40})/)?.[1] ?? null : null} {remoteFilter} />
471+
<CommitGraph {searchMatchedHashes} {searchNavigateHash} headJumpNonce={headJumpNonce} onHeadOffscreenChange={(v) => headOffscreen = v} bisectActive={bisectMessage !== null} bisectCulpritHash={bisectMessage?.includes('is the first bad commit') ? bisectMessage.match(/^([a-f0-9]{7,40})/)?.[1] ?? null : null} {remoteFilter} />
464472
</div>
465473
{/if}
466474
{#if uiStore.showBottomPanel && (uiStore.selectedCommitHash || uiStore.comparing)}

webview-ui/src/components/common/SearchBar.svelte

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414
branches?: BranchInfo[];
1515
branchFilter?: string[];
1616
onBranchFilterChange?: (filter: string[]) => void;
17+
headOffscreen?: boolean;
18+
onJumpToHead?: () => void;
1719
}
1820
1921
let {
@@ -25,6 +27,8 @@
2527
branches = [],
2628
branchFilter = [],
2729
onBranchFilterChange = () => {},
30+
headOffscreen = false,
31+
onJumpToHead = () => {},
2832
}: Props = $props();
2933
3034
let query = $state('');
@@ -39,6 +43,8 @@
3943
4044
const branchFilterActive = $derived(branchFilter.length > 0);
4145
46+
const hasHead = $derived(commitStore.headHash !== null);
47+
4248
const localBranches = $derived(
4349
(remoteFilter.length === 0 || remoteFilter.includes('local'))
4450
? branches.filter(b => !b.remote)
@@ -99,6 +105,7 @@
99105
...commit.refs
100106
.filter(r => r.type === 'branch' || r.type === 'remote-branch' || r.type === 'tag')
101107
.flatMap(r => r.type === 'remote-branch' && r.remote ? [r.name, `${r.remote}/${r.name}`] : [r.name]),
108+
...(commit.refs.some(r => r.type === 'head') ? ['HEAD'] : []),
102109
].join(' ').toLowerCase();
103110
104111
if (haystack.includes(q)) {
@@ -223,6 +230,17 @@
223230
{/if}
224231
</div>
225232

233+
<button
234+
class="head-btn"
235+
class:active={headOffscreen}
236+
onclick={() => onJumpToHead()}
237+
disabled={!hasHead}
238+
aria-label={t('search.jumpToHead')}
239+
use:tooltip={t('search.jumpToHead')}
240+
>
241+
<i class="codicon codicon-location"></i>
242+
</button>
243+
226244
<div class="filter-wrap">
227245
<button
228246
class="filter-btn"
@@ -466,6 +484,37 @@
466484
flex-shrink: 0;
467485
}
468486
487+
.head-btn {
488+
display: flex;
489+
align-items: center;
490+
justify-content: center;
491+
width: 30px;
492+
height: 30px;
493+
flex-shrink: 0;
494+
background: transparent;
495+
color: var(--text-secondary);
496+
border: 1px solid var(--border-color);
497+
border-radius: 6px;
498+
cursor: pointer;
499+
font-size: 14px;
500+
transition: color 0.1s, border-color 0.1s;
501+
}
502+
503+
.head-btn:hover:not(:disabled) {
504+
color: var(--text-primary);
505+
border-color: var(--vscode-focusBorder, #007fd4);
506+
}
507+
508+
.head-btn.active {
509+
color: var(--vscode-focusBorder, #007fd4);
510+
border-color: var(--vscode-focusBorder, #007fd4);
511+
}
512+
513+
.head-btn:disabled {
514+
opacity: 0.4;
515+
cursor: default;
516+
}
517+
469518
.filter-btn {
470519
display: flex;
471520
align-items: center;

webview-ui/src/components/common/__tests__/SearchBar.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,23 @@ describe('SearchBar — filter UI', () => {
263263
});
264264
});
265265

266+
describe('SearchBar — HEAD keyword', () => {
267+
it('typing HEAD matches the commit carrying a head ref', async () => {
268+
setCommits([
269+
commit({ hash: 'h1', subject: 'one' }),
270+
commit({ hash: 'h2', subject: 'two', refs: [{ type: 'head', name: 'HEAD' }] }),
271+
]);
272+
const onResults = vi.fn();
273+
const { container } = render(SearchBar, { ...baseProps, onResults });
274+
const input = container.querySelector<HTMLInputElement>('.search-input')!;
275+
await fireEvent.input(input, { target: { value: 'HEAD' } });
276+
vi.advanceTimersByTime(150);
277+
const matched = onResults.mock.calls.at(-1)![0] as Set<string>;
278+
expect(matched.has('h2')).toBe(true);
279+
expect(matched.has('h1')).toBe(false);
280+
});
281+
});
282+
266283
describe('SearchBar — branch filter', () => {
267284
const branches: BranchInfo[] = [
268285
{ name: 'main', current: true, ahead: 0, behind: 0, hash: 'h' },
@@ -314,3 +331,30 @@ describe('SearchBar — branch filter', () => {
314331
expect(onBranchFilterChange).toHaveBeenCalledWith([]);
315332
});
316333
});
334+
335+
describe('SearchBar — jump to HEAD button', () => {
336+
it('is disabled when no commit is HEAD', () => {
337+
setCommits([commit({ hash: 'h1' })]);
338+
const { container } = render(SearchBar, { ...baseProps });
339+
const btn = container.querySelector<HTMLButtonElement>('.head-btn')!;
340+
expect(btn).toBeTruthy();
341+
expect(btn.disabled).toBe(true);
342+
});
343+
344+
it('is enabled and calls onJumpToHead when clicked', async () => {
345+
setCommits([commit({ hash: 'h1', refs: [{ type: 'head', name: 'HEAD' }] })]);
346+
const onJumpToHead = vi.fn();
347+
const { container } = render(SearchBar, { ...baseProps, onJumpToHead });
348+
const btn = container.querySelector<HTMLButtonElement>('.head-btn')!;
349+
expect(btn.disabled).toBe(false);
350+
await fireEvent.click(btn);
351+
expect(onJumpToHead).toHaveBeenCalled();
352+
});
353+
354+
it('has the active class when headOffscreen is true', () => {
355+
setCommits([commit({ hash: 'h1', refs: [{ type: 'head', name: 'HEAD' }] })]);
356+
const { container } = render(SearchBar, { ...baseProps, headOffscreen: true });
357+
const btn = container.querySelector<HTMLButtonElement>('.head-btn')!;
358+
expect(btn.classList.contains('active')).toBe(true);
359+
});
360+
});

webview-ui/src/components/graph/CommitGraph.svelte

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
import { tooltip } from '../../lib/actions/tooltip';
2727
import { getSquashChain } from '../../lib/utils/squash';
2828
import { resolveDrop, dragRebaseMessage, dragMergeMessage } from '../../lib/utils/dragDrop';
29-
import { computeNavigationTarget, computeScrollTop, computeJumpTarget, type ScrollAlign } from '../../lib/graph-navigation';
29+
import { computeNavigationTarget, computeScrollTop, computeJumpTarget, isRowOffscreen, type ScrollAlign } from '../../lib/graph-navigation';
3030
import LinkifiedText from '../common/LinkifiedText.svelte';
3131
3232
@@ -73,9 +73,11 @@
7373
bisectActive?: boolean;
7474
bisectCulpritHash?: string | null;
7575
remoteFilter?: string[];
76+
headJumpNonce?: number;
77+
onHeadOffscreenChange?: (offscreen: boolean) => void;
7678
}
7779
78-
let { searchMatchedHashes = null, searchNavigateHash = null, bisectActive = false, bisectCulpritHash = null, remoteFilter = [] }: Props = $props();
80+
let { searchMatchedHashes = null, searchNavigateHash = null, bisectActive = false, bisectCulpritHash = null, remoteFilter = [], headJumpNonce = 0, onHeadOffscreenChange = () => {} }: Props = $props();
7981
8082
const vscode = getVsCodeApi();
8183
@@ -366,6 +368,39 @@
366368
}
367369
});
368370
371+
// HEAD's row index, recomputed only when the commit set or HEAD changes - not
372+
// on every scroll - so the offscreen check below stays O(1) per scroll frame
373+
// instead of re-scanning displayCommits each time scrollTop updates.
374+
const headRowIndex = $derived.by(() => {
375+
const headHash = commitStore.headHash;
376+
return headHash ? displayCommits.findIndex(c => c.hash === headHash) : -1;
377+
});
378+
379+
// Tell the toolbar's "jump to HEAD" button whether HEAD is currently off-screen,
380+
// so it emphasizes itself only when scrolling is actually needed. Recomputed on
381+
// scroll (scrollTop), resize (viewportHeight) and data changes (headRowIndex).
382+
// Guarded so we only notify the parent when the boolean actually flips.
383+
let lastHeadOffscreen: boolean | null = null;
384+
$effect(() => {
385+
const offscreen = isRowOffscreen(headRowIndex, ROW_HEIGHT, scrollTop, viewportHeight);
386+
if (offscreen !== lastHeadOffscreen) {
387+
lastHeadOffscreen = offscreen;
388+
onHeadOffscreenChange(offscreen);
389+
}
390+
});
391+
392+
// Scroll HEAD into view (centered) when the toolbar button is clicked. The nonce
393+
// (not the hash) drives this so repeated clicks re-scroll even when HEAD hasn't
394+
// changed. The initial value (0) is skipped so opening the graph never jumps.
395+
let lastHeadJumpNonce = 0;
396+
$effect(() => {
397+
if (headJumpNonce !== lastHeadJumpNonce) {
398+
lastHeadJumpNonce = headJumpNonce;
399+
const headHash = commitStore.headHash;
400+
if (headHash) scrollHashIntoView(headHash, 'center');
401+
}
402+
});
403+
369404
// A reload or repo switch replaces the commit set; drop the jump path so it can't
370405
// reference commits that are no longer present.
371406
$effect(() => {

webview-ui/src/lib/__tests__/graph-navigation.test.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, it, expect } from 'vitest';
2-
import { computeNavigationTarget, computeScrollTop, computeJumpTarget } from '../graph-navigation';
2+
import { computeNavigationTarget, computeScrollTop, computeJumpTarget, isRowOffscreen } from '../graph-navigation';
33

44
// Newest-first list. Linear chain a <- b <- c with a merge:
55
// a (top/newest) parents: [b]
@@ -226,3 +226,29 @@ describe('computeJumpTarget', () => {
226226
expect(computeJumpTarget(jg, 'a', 'up', ['a'])).toEqual({ target: null, path: ['a'] });
227227
});
228228
});
229+
230+
describe('isRowOffscreen', () => {
231+
it('row above the viewport is offscreen', () => {
232+
// row 0 spans 0..30px; viewport is 300..900
233+
expect(isRowOffscreen(0, 30, 300, 600)).toBe(true);
234+
});
235+
236+
it('row below the viewport is offscreen', () => {
237+
// row 40 spans 1200..1230px; viewport is 0..600
238+
expect(isRowOffscreen(40, 30, 0, 600)).toBe(true);
239+
});
240+
241+
it('row inside the viewport is on-screen', () => {
242+
// row 10 spans 300..330px; viewport is 0..600
243+
expect(isRowOffscreen(10, 30, 0, 600)).toBe(false);
244+
});
245+
246+
it('a partially visible row counts as on-screen', () => {
247+
// row 20 spans 600..630px; viewport is 0..610 (overlaps 600..610)
248+
expect(isRowOffscreen(20, 30, 0, 610)).toBe(false);
249+
});
250+
251+
it('a negative index (no HEAD) is never offscreen', () => {
252+
expect(isRowOffscreen(-1, 30, 0, 600)).toBe(false);
253+
});
254+
});

webview-ui/src/lib/graph-navigation.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,3 +143,22 @@ export function computeJumpTarget(
143143
const trimmed = pushed.length > maxPath ? pushed.slice(pushed.length - maxPath) : pushed;
144144
return { target, path: trimmed };
145145
}
146+
147+
/**
148+
* True when the row at `rowIndex` is entirely outside the visible viewport
149+
* (fully above the top or fully below the bottom). A row that is even partially
150+
* visible counts as on-screen. Uses the real viewport - not the render buffer -
151+
* so it reflects what the user can actually see. A negative `rowIndex` (e.g. no
152+
* HEAD in the list) is treated as on-screen so callers don't emphasize a no-op.
153+
*/
154+
export function isRowOffscreen(
155+
rowIndex: number,
156+
rowHeight: number,
157+
scrollTop: number,
158+
viewportHeight: number,
159+
): boolean {
160+
if (rowIndex < 0) return false;
161+
const top = rowIndex * rowHeight;
162+
const bottom = top + rowHeight;
163+
return bottom <= scrollTop || top >= scrollTop + viewportHeight;
164+
}

webview-ui/src/lib/i18n/en.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,7 @@ export const en: Record<string, string> = {
275275
'search.noResults': 'No results',
276276
'search.prev': 'Previous match (Shift+Enter)',
277277
'search.next': 'Next match (Enter)',
278+
'search.jumpToHead': 'Jump to HEAD',
278279
'search.filters': 'Filters',
279280
'search.authorFilter': 'Author filter',
280281
'search.sourceFilter': 'Source',

webview-ui/src/lib/i18n/ko.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,7 @@ export const ko: Record<string, string> = {
275275
'search.noResults': '결과 없음',
276276
'search.prev': '이전 결과 (Shift+Enter)',
277277
'search.next': '다음 결과 (Enter)',
278+
'search.jumpToHead': 'HEAD로 이동',
278279
'search.filters': '필터',
279280
'search.authorFilter': '작성자 필터',
280281
'search.sourceFilter': '소스',

webview-ui/src/lib/i18n/zh.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,7 @@ export const zh: Record<string, string> = {
275275
'search.noResults': '无结果',
276276
'search.prev': '上一个匹配(Shift+Enter)',
277277
'search.next': '下一个匹配(Enter)',
278+
'search.jumpToHead': '跳转到 HEAD',
278279
'search.filters': '筛选',
279280
'search.authorFilter': '作者筛选',
280281
'search.sourceFilter': '来源',

webview-ui/src/lib/stores/__tests__/commits.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,3 +102,17 @@ describe('commitStore lookups', () => {
102102
expect(map.get('zzz')).toBeUndefined();
103103
});
104104
});
105+
106+
describe('commitStore.headHash', () => {
107+
it('returns the hash of the commit carrying a head ref', () => {
108+
const head = makeCommit('h1');
109+
head.refs = [{ type: 'head', name: 'HEAD' }];
110+
commitStore.commits = [makeCommit('h0'), head];
111+
expect(commitStore.headHash).toBe('h1');
112+
});
113+
114+
it('returns null when no commit carries a head ref', () => {
115+
commitStore.commits = [makeCommit('h0'), makeCommit('h1')];
116+
expect(commitStore.headHash).toBeNull();
117+
});
118+
});

0 commit comments

Comments
 (0)