Skip to content
Merged
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
78 changes: 62 additions & 16 deletions src/renderer/src/components/graph/GraphCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import {
readPalette,
SUBJECT_FONT
} from './render'
import { hitKey, PING_MS, type SearchHit } from './searchGlow'
import { usePanInertia } from './usePanInertia'
import { useZoomAnimation } from './useZoomAnimation'
import { isDiscreteWheel, wheelZoomFactor } from './zoom'
Expand All @@ -49,6 +50,8 @@ export interface GraphCanvasHandle {
jumpToHead(): void
/** Bring a commit into view (search navigation, keyboard selection). */
reveal(hash: string): void
/** Bring a world position into view — branch-label hits have no commit. */
revealAt(column: number, row: number): void
}

interface Props {
Expand All @@ -61,8 +64,12 @@ interface Props {
selectedBranch: BranchSelection | null
/** Commits kept at full strength while the rest dim; null = no filter. */
matches: ReadonlySet<string> | null
/** The current search hit (louder ring). */
activeMatch: string | null
/** The current search hit — a commit, branch label, or tag chip. */
activeHit: SearchHit | null
/** Chains whose branch label is itself a hit; null = not searching. */
hitBranches: ReadonlySet<number> | null
/** Commits whose tag chip is a hit; null = not searching. */
hitTags: ReadonlySet<string> | null
/** Uncommitted change count — drawn as the dashed WIP node on the HEAD row. */
changesCount: number
/** Dashed "same change" links between backport twins (see links.ts). */
Expand Down Expand Up @@ -145,7 +152,9 @@ export function GraphCanvas({
selectedHash,
selectedBranch,
matches,
activeMatch,
activeHit,
hitBranches,
hitTags,
changesCount,
links,
controls,
Expand All @@ -167,6 +176,8 @@ export function GraphCanvas({
// clicks stay clicks. `buttons === 0` checks recover from off-window releases.
const panRef = useRef<{ id: number; x: number; y: number; panned: boolean } | null>(null)
const initializedRef = useRef(false)
/** The current hit's arrival ping progress (0 → 1; 1 = settled, no ping). */
const matchPulseRef = useRef(1)
const [tooltip, setTooltip] = useState<Tooltip | null>(null)
const [cursor, setCursor] = useState<'default' | 'pointer' | 'grabbing'>('default')

Expand All @@ -193,7 +204,9 @@ export function GraphCanvas({
selectedHash,
selectedBranch,
matches,
activeMatch,
activeHit,
hitBranches,
hitTags,
wip,
dayMarks,
links
Expand All @@ -203,7 +216,9 @@ export function GraphCanvas({
selectedHash,
selectedBranch,
matches,
activeMatch,
activeHit,
hitBranches,
hitTags,
wip,
dayMarks,
links
Expand All @@ -230,7 +245,10 @@ export function GraphCanvas({
selectedBranch: s.selectedBranch,
hoverHash: hoverRef.current,
matches: s.matches,
activeMatch: s.activeMatch,
activeHit: s.activeHit,
hitBranches: s.hitBranches,
hitTags: s.hitTags,
matchPulse: matchPulseRef.current,
wip: s.wip,
dayMarks: s.dayMarks,
links: s.links
Expand Down Expand Up @@ -357,22 +375,28 @@ export function GraphCanvas({
}
}, [centerOn, fit])

const reveal = useCallback(
(hash: string) => {
const node = sceneRef.current.layout.nodeByHash.get(hash)
if (!node) return
const revealAt = useCallback(
(column: number, row: number) => {
const view = viewRef.current
const { width, height } = sizeRef.current
const sx = nodeX(node.column) * view.scale + view.x
const sy = nodeY(node.row) * view.scale + view.y
const sx = nodeX(column) * view.scale + view.x
const sy = nodeY(row) * view.scale + view.y
const pad = 60
if (sx < pad || sx > width - pad || sy < HEADER_H + pad / 2 || sy > height - pad / 2) {
centerOn(node.column, node.row)
centerOn(column, row)
}
},
[centerOn]
)

const reveal = useCallback(
(hash: string) => {
const node = sceneRef.current.layout.nodeByHash.get(hash)
if (node) revealAt(node.column, node.row)
},
[revealAt]
)

// Every animated zoom entry point takes over the view — stop a drag fling
// first so the anchor point doesn't slide while the scale glides.
const zoomStepAt = useCallback(
Expand All @@ -389,12 +413,13 @@ export function GraphCanvas({
zoomOut: () => zoomStepAt(sizeRef.current.width / 2, sizeRef.current.height / 2, 0.8),
fit,
jumpToHead,
reveal
reveal,
revealAt
}
return () => {
controls.current = null
}
}, [controls, zoomStepAt, fit, jumpToHead, reveal])
}, [controls, zoomStepAt, fit, jumpToHead, reveal, revealAt])

// Backing-store sizing, DPR-aware; re-runs on wrapper resize.
useEffect(() => {
Expand Down Expand Up @@ -440,13 +465,34 @@ export function GraphCanvas({
selectedHash,
selectedBranch,
matches,
activeMatch,
activeHit,
hitBranches,
hitTags,
wip,
links,
theme,
invalidate
])
useEffect(() => subscribeAvatars(invalidate), [invalidate])
// The current hit's arrival ping: a short, FINITE rAF loop — it runs
// PING_MS per target change and stops, so an idle graph burns zero frames.
// Keyed on the hit's stable identity, not the object: the hits array is
// rebuilt on every layout/search recompute and must not re-fire the ping.
// Reduced-motion users get the steady glow with no ping.
const activeHitKey = hitKey(activeHit)
useEffect(() => {
matchPulseRef.current = 1
if (!activeHitKey) return
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return
matchPulseRef.current = 0
const start = performance.now()
let raf = requestAnimationFrame(function tick() {
matchPulseRef.current = Math.min(1, (performance.now() - start) / PING_MS)
invalidate()
if (matchPulseRef.current < 1) raf = requestAnimationFrame(tick)
})
return () => cancelAnimationFrame(raf)
}, [activeHitKey, invalidate])
// biome-ignore lint/correctness/useExhaustiveDependencies: theme isn't read here — a theme change is the trigger to drop the palette cache and redraw
useEffect(() => {
paletteRef.current = null
Expand Down
67 changes: 46 additions & 21 deletions src/renderer/src/components/graph/GraphToolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,7 @@ export function GraphToolbar({
onMatchStep
}: Props) {
const [open, setOpen] = useState<'branches' | 'authors' | 'date' | 'view' | 'focus' | null>(null)
const searchInput = useRef<HTMLInputElement>(null)
const anchors = useRef<Record<string, HTMLButtonElement | null>>({})
const anchorFor = (id: string) => (el: HTMLButtonElement | null) => {
anchors.current[id] = el
Expand Down Expand Up @@ -270,6 +271,7 @@ export function GraphToolbar({
<div className={`graph-search${searching ? ' is-active' : ''}`}>
<Icon.Search size={13} />
<input
ref={searchInput}
type="text"
placeholder="Find commits…"
aria-label="Find commits"
Expand All @@ -282,27 +284,50 @@ export function GraphToolbar({
/>
{searching && (
<>
<span className="graph-search__count">
{matchCount === 0 ? '0' : `${matchIndex + 1}/${matchCount}`}
</span>
<button
type="button"
className="icon-btn graph-search__step"
aria-label="Previous match"
disabled={matchCount === 0}
onClick={() => onMatchStep(-1)}
>
<Icon.Prev size={12} />
</button>
<button
type="button"
className="icon-btn graph-search__step"
aria-label="Next match"
disabled={matchCount === 0}
onClick={() => onMatchStep(1)}
>
<Icon.Next size={12} />
</button>
{/* With no hits matchIndex is -1, so this renders "0/0" — the
compact zero state; anything wordier crowds the box. */}
<span className="graph-search__count">{`${matchIndex + 1}/${matchCount}`}</span>
{/* Chevrons drawn a notch thicker than the stock 1.7px icon
stroke: at stepper size the default renders a hairline. The
three actions sit FLUSH, one cluster (the browser find-bar
convention) — hover lights only the button under the cursor,
so they never need separating air. */}
<div className="graph-search__actions">
<button
type="button"
className="icon-btn graph-search__step"
aria-label="Previous match"
data-tip="Previous match (⇧Enter)"
disabled={matchCount === 0}
onClick={() => onMatchStep(-1)}
>
<Icon.Prev size={14} strokeWidth={2.4} />
</button>
<button
type="button"
className="icon-btn graph-search__step"
aria-label="Next match"
data-tip="Next match (Enter)"
disabled={matchCount === 0}
onClick={() => onMatchStep(1)}
>
<Icon.Next size={14} strokeWidth={2.4} />
</button>
<button
type="button"
className="icon-btn graph-search__step"
aria-label="Clear search"
data-tip="Clear (Esc)"
onClick={() => {
onSearch('')
// Keep the caret in the field — clearing is a restart of
// the search, not the end of it (the find-bar convention).
searchInput.current?.focus()
}}
>
<Icon.Close size={13} strokeWidth={2.2} />
</button>
</div>
</>
)}
</div>
Expand Down
74 changes: 48 additions & 26 deletions src/renderer/src/components/graph/GraphView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
import { linkableChains, twinHashes } from './links'
import { relatedBranches } from './related'
import { releaseLineVersion, releaseVersionWithOverride } from './releases'
import { computeSearchHits } from './searchGlow'
import { useBackportLinks } from './useBackportLinks'
import { useGraphLog } from './useGraphLog'

Expand Down Expand Up @@ -155,39 +156,58 @@ export function GraphView({
}, [commits])

// Search terms + author filter dim everything they don't match. Hits are
// ordered newest-first for Enter/arrow navigation between them.
// TYPED (commit / branch label / tag chip — searching a ref name finds the
// ref itself, never the commit it decorates) and ordered newest-first for
// Enter/arrow navigation between them.
const searching = filterTerms(search).length > 0
const matchList = useMemo((): string[] | null => {
const terms = filterTerms(search)
if (terms.length === 0 && authorFilter === null) return null
const hits: string[] = []
for (let i = layout.nodes.length - 1; i >= 0; i--) {
const c = layout.nodes[i].commit
if (authorFilter && !authorFilter.has(c.authorEmail.toLowerCase())) continue
if (terms.length > 0) {
const hay = `${c.subject} ${c.authorName} ${c.hash}`.toLowerCase()
if (!terms.every((t) => hay.includes(t))) continue
}
hits.push(c.hash)
}
return hits
}, [layout, search, authorFilter])
const matches = useMemo(() => (matchList ? new Set(matchList) : null), [matchList])
const matchCount = searching ? (matchList?.length ?? 0) : 0
const activeMatch =
searching && matchList && matchList.length > 0
const matchList = useMemo(
() => computeSearchHits(filterTerms(search), authorFilter, layout.nodes, layout.rows),
[layout, search, authorFilter]
)
/** Commits kept at full strength while everything else dims. */
const matches = useMemo(() => {
if (!searching && authorFilter === null) return null
const keep = new Set<string>()
for (const hit of matchList) if (hit.kind === 'commit') keep.add(hit.hash)
return keep
}, [matchList, searching, authorFilter])
/** Chains whose branch label is itself a hit (gold pill treatment). */
const hitBranches = useMemo(() => {
if (!searching) return null
const chains = new Set<number>()
for (const hit of matchList) if (hit.kind === 'branch') chains.add(hit.chain)
return chains
}, [matchList, searching])
/** Commits whose tag chip is a hit (gold chip treatment). */
const hitTags = useMemo(() => {
if (!searching) return null
const hashes = new Set<string>()
for (const hit of matchList) if (hit.kind === 'tag') hashes.add(hit.hash)
return hashes
}, [matchList, searching])
const matchCount = searching ? matchList.length : 0
const activeHit =
searching && matchList.length > 0
? matchList[((matchIndex % matchList.length) + matchList.length) % matchList.length]
: null

// New search → restart at the newest hit and bring it into view.
// New search → restart at the newest hit and bring it into view. A branch
// hit reveals its LABEL's spot (the row start); the label may have no
// commit at all to reveal through (empty branches).
// biome-ignore lint/correctness/useExhaustiveDependencies: the query change is the intentional trigger
useEffect(() => setMatchIndex(0), [search])
useEffect(() => {
if (activeMatch) controls.current?.reveal(activeMatch)
}, [activeMatch])
if (!activeHit) return
if (activeHit.kind === 'branch') {
const row = layout.rows.find((r) => r.chain === activeHit.chain)
if (row) controls.current?.revealAt(row.startColumn, row.index)
} else {
controls.current?.reveal(activeHit.hash)
}
}, [activeHit, layout])

const stepMatch = (dir: 1 | -1) => {
if (!matchList || matchList.length === 0) return
if (matchList.length === 0) return
setMatchIndex((i) => i + dir)
}

Expand Down Expand Up @@ -347,7 +367,7 @@ export function GraphView({
search={search}
onSearch={setSearch}
matchCount={matchCount}
matchIndex={activeMatch && matchList ? matchList.indexOf(activeMatch) : -1}
matchIndex={activeHit ? matchList.indexOf(activeHit) : -1}
onMatchStep={stepMatch}
/>
<div className="graph-stage">
Expand Down Expand Up @@ -378,7 +398,9 @@ export function GraphView({
selectedHash={selectedCommit?.hash ?? null}
selectedBranch={selectedBranch}
matches={matches}
activeMatch={activeMatch}
activeHit={activeHit}
hitBranches={hitBranches}
hitTags={hitTags}
changesCount={changesCount}
links={links}
controls={controls}
Expand Down
Loading
Loading