From a75eb24b132e23bd299eb3ba137218d2cb5972bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Pen=CC=83alba?= Date: Thu, 23 Jul 2026 12:09:43 +0200 Subject: [PATCH] Make Home/End jump to a selected branch's first and last changesets --- .../src/components/graph/GraphCanvas.tsx | 14 ++++++- .../src/components/graph/geometry.test.ts | 38 +++++++++++++++++++ src/renderer/src/components/graph/geometry.ts | 18 +++++++++ 3 files changed, 69 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/components/graph/GraphCanvas.tsx b/src/renderer/src/components/graph/GraphCanvas.tsx index fd014b9..6bab7be 100644 --- a/src/renderer/src/components/graph/GraphCanvas.tsx +++ b/src/renderer/src/components/graph/GraphCanvas.tsx @@ -9,6 +9,7 @@ import { stripCoAuthorTrailers } from '@/lib/coauthors' import { reflowMessage } from '@/lib/reflow' import { subscribeAvatars } from './avatars' import { + branchEndpoint, captionAlpha, captionCenterOffset, contentSize, @@ -819,13 +820,24 @@ export function GraphCanvas({ } if (e.key === 'Home' || e.key === 'End') { e.preventDefault() + const edge = e.key === 'Home' ? 'first' : 'last' const current = s.selectedHash ? s.layout.nodeByHash.get(s.selectedHash) : null + const branchRow = current + ? null + : s.layout.rows.find((r) => rowMatchesSelection(r, s.selectedBranch)) if (current) { // Within the selected commit's branch: Home → its oldest commit, // End → its newest. - const target = rowEndpoint(s.layout, current, e.key === 'Home' ? 'first' : 'last') + const target = rowEndpoint(s.layout, current, edge) onSelectNode(target) reveal(target.commit.hash) + } else if (branchRow) { + // A branch is selected: same edges, walked from its own chain. + const target = branchEndpoint(s.layout, branchRow, edge) + if (target) { + onSelectNode(target) + reveal(target.commit.hash) + } } else if (e.key === 'Home') { // Nothing selected: Home keeps its classic meaning — frame the // home changeset. diff --git a/src/renderer/src/components/graph/geometry.test.ts b/src/renderer/src/components/graph/geometry.test.ts index 905e669..e0599df 100644 --- a/src/renderer/src/components/graph/geometry.test.ts +++ b/src/renderer/src/components/graph/geometry.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from 'bun:test' import type { Commit } from '@shared/types' import { + branchEndpoint, CAPSULE_HALF_H, CAPTION_FULL_ZOOM, CAPTION_GAP_WORLD, @@ -231,6 +232,43 @@ describe('graph geometry', () => { expect(f1.row).toBe(g2.row) expect(rowEndpoint(layout, g2, 'first').commit.hash).toBe('g1') expect(rowEndpoint(layout, f1, 'last').commit.hash).toBe('f2') + + // The branch-selected targets walk the same edges from the row itself, + // and a lane shared by two chains never leaks into the neighbor. + const feat1 = layout.rows.find((r) => r.name === 'feat1') + const feat2 = layout.rows.find((r) => r.name === 'feat2') + if (!feat1 || !feat2) throw new Error('missing row') + expect(branchEndpoint(layout, feat1, 'first')?.commit.hash).toBe('f1') + expect(branchEndpoint(layout, feat1, 'last')?.commit.hash).toBe('f2') + expect(branchEndpoint(layout, feat2, 'first')?.commit.hash).toBe('g1') + expect(branchEndpoint(layout, feat2, 'last')?.commit.hash).toBe('g2') + }) + + test('Home/End targets when the branch is selected: its oldest and newest commit', () => { + const layout = sampleLayout() + const main = layout.rows.find((r) => r.name === 'main') + const feature = layout.rows.find((r) => r.name === 'feature') + if (!main || !feature) throw new Error('missing row') + expect(branchEndpoint(layout, main, 'first')?.commit.hash).toBe('a') + expect(branchEndpoint(layout, main, 'last')?.commit.hash).toBe('m') + // A single-commit branch: both edges are that commit. + expect(branchEndpoint(layout, feature, 'first')?.commit.hash).toBe('f') + expect(branchEndpoint(layout, feature, 'last')?.commit.hash).toBe('f') + }) + + test('an empty branch resolves both Home/End edges to its anchor commit', () => { + // 'fresh' points at the HEAD tip: zero commits of its own. + const layout = layoutGraph({ + commits: [commit('b', ['a'], 'HEAD -> main, fresh'), commit('a', [])], + remotes: [], + headBranch: 'main', + detached: false, + defaultBranch: 'main' + }) + const fresh = layout.rows.find((r) => r.name === 'fresh') + if (!fresh) throw new Error('missing empty row') + expect(branchEndpoint(layout, fresh, 'first')?.commit.hash).toBe('b') + expect(branchEndpoint(layout, fresh, 'last')?.commit.hash).toBe('b') }) describe('revealRowDy — keep the clicked row clear of the diff pane', () => { diff --git a/src/renderer/src/components/graph/geometry.ts b/src/renderer/src/components/graph/geometry.ts index f1135e5..d12b333 100644 --- a/src/renderer/src/components/graph/geometry.ts +++ b/src/renderer/src/components/graph/geometry.ts @@ -297,6 +297,24 @@ export function rowEndpoint( return best } +/** The oldest ('first') or newest ('last') commit of a BRANCH row — the + * Home/End keyboard targets when the branch itself is selected. Walks the + * row's own chain, so a lane shared with another chain never leaks in. An + * empty branch owns no commits; both edges resolve to its anchor commit + * (the tip it points at). */ +export function branchEndpoint( + layout: GraphLayout, + row: GraphRow, + edge: 'first' | 'last' +): GraphNode | null { + let best: GraphNode | null = null + for (const n of layout.nodes) { + if (n.chain !== row.chain) continue + if (!best || (edge === 'first' ? n.column < best.column : n.column > best.column)) best = n + } + return best ?? layout.nodeByHash.get(row.tipHash) ?? null +} + /** * Keyboard navigation target from `node`: left/right walk the same row (older/ * newer), up/down jump to the column-nearest node one row away.