From 6c254eda7b4091d3ebc21d229e10c6b35f946cb1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Pen=CC=83alba?= Date: Sat, 4 Jul 2026 18:45:14 +0200 Subject: [PATCH 1/4] Make search results more visible --- .../src/components/graph/GraphCanvas.tsx | 20 +++++ .../src/components/graph/GraphToolbar.tsx | 8 +- src/renderer/src/components/graph/render.ts | 85 +++++++++++++++++-- .../src/components/graph/searchGlow.test.ts | 79 +++++++++++++++++ .../src/components/graph/searchGlow.ts | 78 +++++++++++++++++ src/renderer/src/styles/features/graph.css | 18 +++- 6 files changed, 276 insertions(+), 12 deletions(-) create mode 100644 src/renderer/src/components/graph/searchGlow.test.ts create mode 100644 src/renderer/src/components/graph/searchGlow.ts diff --git a/src/renderer/src/components/graph/GraphCanvas.tsx b/src/renderer/src/components/graph/GraphCanvas.tsx index 27962bc..b015fe6 100644 --- a/src/renderer/src/components/graph/GraphCanvas.tsx +++ b/src/renderer/src/components/graph/GraphCanvas.tsx @@ -37,6 +37,7 @@ import { readPalette, SUBJECT_FONT } from './render' +import { PING_MS } from './searchGlow' import { usePanInertia } from './usePanInertia' import { useZoomAnimation } from './useZoomAnimation' import { isDiscreteWheel, wheelZoomFactor } from './zoom' @@ -167,6 +168,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(null) const [cursor, setCursor] = useState<'default' | 'pointer' | 'grabbing'>('default') @@ -231,6 +234,7 @@ export function GraphCanvas({ hoverHash: hoverRef.current, matches: s.matches, activeMatch: s.activeMatch, + matchPulse: matchPulseRef.current, wip: s.wip, dayMarks: s.dayMarks, links: s.links @@ -447,6 +451,22 @@ export function GraphCanvas({ invalidate ]) useEffect(() => subscribeAvatars(invalidate), [invalidate]) + // The current hit's arrival ping: a short, FINITE rAF loop — it runs + // PING_MS per activeMatch change and stops, so an idle graph burns zero + // frames. Reduced-motion users get the steady glow with no ping. + useEffect(() => { + matchPulseRef.current = 1 + if (!activeMatch) 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) + }, [activeMatch, 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 diff --git a/src/renderer/src/components/graph/GraphToolbar.tsx b/src/renderer/src/components/graph/GraphToolbar.tsx index bdba881..d6d58b6 100644 --- a/src/renderer/src/components/graph/GraphToolbar.tsx +++ b/src/renderer/src/components/graph/GraphToolbar.tsx @@ -285,23 +285,27 @@ export function GraphToolbar({ {matchCount === 0 ? '0' : `${matchIndex + 1}/${matchCount}`} + {/* Chevrons drawn a notch thicker than the stock 1.7px icon + stroke: at stepper size the default renders a hairline. */} )} diff --git a/src/renderer/src/components/graph/render.ts b/src/renderer/src/components/graph/render.ts index 001c709..230538f 100644 --- a/src/renderer/src/components/graph/render.ts +++ b/src/renderer/src/components/graph/render.ts @@ -39,6 +39,7 @@ import { rowMatchesSelection } from './layout' import { type BackportLink, linkedHashes } from './links' +import { ACTIVE_GLOW, HIT_GLOW, litChains, pingRings, withAlpha } from './searchGlow' export interface GraphPalette { dark: boolean @@ -150,6 +151,9 @@ export interface SceneState { matches: ReadonlySet | null /** The current search hit, ringed louder than its fellow matches. */ activeMatch: string | null + /** The current hit's arrival ping: 0 = just landed … 1 = settled (no ping). + * GraphCanvas animates it over PING_MS whenever activeMatch changes. */ + matchPulse: number wip: { column: number; row: number; count: number; color: number } | null dayMarks: DayMark[] /** Dashed "same change" links between backport twins (see links.ts). */ @@ -169,6 +173,9 @@ const HEADER_LABEL_PAD = 6 export const SUBJECT_FONT = 13 export const SUBJECT_WEIGHT = 600 const DIM_ALPHA = 0.15 +/** Ghosted branch labels (no hit on the chain): stronger than DIM_ALPHA — a + * label is a wayfinding anchor, it must stay locatable while receding. */ +const LABEL_GHOST_ALPHA = 0.25 /** Font metrics of the caption face, for baseline-exact DOM overlays. */ export interface CaptionMetrics { @@ -294,7 +301,7 @@ export function drawScene(ctx: CanvasRenderingContext2D, scene: SceneState): voi drawNodes(ctx, scene, c0, c1, labelBoxes, twinsOf(scene.links)) drawWip(ctx, scene) drawEmptyHeadBadge(ctx, scene, c0, c1) - drawLabels(ctx, scene, labelBoxes) + drawLabels(ctx, scene, labelBoxes, litChainsFor(scene)) drawHeader(ctx, scene) } @@ -429,6 +436,20 @@ function drawEdges(ctx: CanvasRenderingContext2D, scene: SceneState, c0: number, ctx.globalAlpha = 1 } +// Chains holding at least one filter/search match, cached per matches set +// (stable between frames) so the 60fps pan path never re-derives it. +const litChainsCache = new WeakMap, ReadonlySet>() +function litChainsFor(scene: SceneState): ReadonlySet | null { + const { matches } = scene + if (matches === null) return null + let lit = litChainsCache.get(matches) + if (!lit) { + lit = litChains(scene.layout.nodes, matches) + litChainsCache.set(matches, lit) + } + return lit +} + // Twin markers: which commits participate in any link. Cached per links array // (stable between frames) so the 60fps pan path never re-derives the set. const twinCache = new WeakMap>() @@ -536,6 +557,25 @@ function drawNodes( ctx.fill() ctx.globalAlpha = dim ? DIM_ALPHA : 1 + // Search hits wear the text-editor find treatment, in gold: every hit a + // soft radial glow behind the node, the CURRENT hit a wider corona (plus + // a ring and an arrival ping, below). Gold, not the branch hue — hits + // must read across all branch colors at a glance. The glow fades to a + // zero-alpha stop of the SAME color: fading to `transparent` would drag + // the gradient through black and dirty the halo's rim. + const isActiveMatch = node.commit.hash === scene.activeMatch + const isHit = scene.activeMatch !== null && scene.matches?.has(node.commit.hash) === true + if (isHit) { + const glowR = NODE_R + (isActiveMatch ? ACTIVE_GLOW : HIT_GLOW) + const glow = ctx.createRadialGradient(x, y, NODE_R - 2, x, y, glowR) + glow.addColorStop(0, withAlpha(palette.match, isActiveMatch ? 0.55 : 0.35)) + glow.addColorStop(1, withAlpha(palette.match, 0)) + ctx.fillStyle = glow + ctx.beginPath() + ctx.arc(x, y, glowR, 0, Math.PI * 2) + ctx.fill() + } + // Selection halo, under everything else on the node. const isSelected = node.commit.hash === scene.selectedHash if (isSelected) { @@ -570,8 +610,10 @@ function drawNodes( } } - // Ring in the branch color; louder states stack on top. - const isActiveMatch = node.commit.hash === scene.activeMatch + // Ring in the branch color; louder states stack on top. The current hit + // keeps its branch/merge ring — the gold glow and corona carry the find + // state, so branch identity (and the merge ring's incoming-color rule) + // survive being the current match. const isHover = node.commit.hash === scene.hoverHash const emphasized = isHover || isActiveMatch if (node.mergeColor !== null) { @@ -588,17 +630,39 @@ function drawNodes( ctx.arc(x, y, NODE_R - 2, 0, Math.PI * 2) ctx.stroke() ctx.lineWidth = emphasized ? 2.5 : 2 - ctx.strokeStyle = isActiveMatch ? palette.match : branchStroke(palette, node.mergeColor) + ctx.strokeStyle = branchStroke(palette, node.mergeColor) ctx.beginPath() ctx.arc(x, y, NODE_R + 0.5, 0, Math.PI * 2) ctx.stroke() } else { ctx.lineWidth = emphasized ? 2.5 : 2 - ctx.strokeStyle = isActiveMatch ? palette.match : branchStroke(palette, node.color) + ctx.strokeStyle = branchStroke(palette, node.color) ctx.beginPath() ctx.arc(x, y, NODE_R + 0.5, 0, Math.PI * 2) ctx.stroke() } + if (isActiveMatch) { + // The current hit's gold corona ring — the same radius and weight as + // the selection ring, so "current find" and "selected" speak one + // grammar in two colors. When a hit is also selected, the accent ring + // (drawn below) wins the radius and the gold glow still marks the find. + ctx.lineWidth = 2 + ctx.strokeStyle = palette.match + ctx.beginPath() + ctx.arc(x, y, NODE_R + 4, 0, Math.PI * 2) + ctx.stroke() + // Arrival ping: staggered expanding rings that fade as they travel — + // the eye is drawn by motion exactly once, then the steady corona + // holds the spot (pingRings returns [] once settled). + for (const ring of pingRings(scene.matchPulse)) { + ctx.globalAlpha = ring.alpha + ctx.lineWidth = ring.width + ctx.beginPath() + ctx.arc(x, y, NODE_R + 4 + ring.grow, 0, Math.PI * 2) + ctx.stroke() + } + ctx.globalAlpha = dim ? DIM_ALPHA : 1 + } if (isSelected) { // Outer accent ring: "this is picked" — selection only. The home // changeset wears the house badge below instead, so being at home @@ -904,7 +968,9 @@ function drawWip(ctx: CanvasRenderingContext2D, scene: SceneState): void { function drawLabels( ctx: CanvasRenderingContext2D, scene: SceneState, - labelBoxes: LabelBox[] + labelBoxes: LabelBox[], + /** Chains holding at least one match, or null when nothing is filtering. */ + lit: ReadonlySet | null ): void { const { palette } = scene ctx.font = `600 ${LABEL_FONT}px ${palette.font}` @@ -912,7 +978,12 @@ function drawLabels( ctx.textAlign = 'left' for (const { row, rect, sticky } of labelBoxes) { const head = row.isHead - ctx.globalAlpha = row.kind === 'unnamed' ? 0.8 : 1 + // While a filter/search dims commits, labels of hitless branches ghost + // with them — a full-strength label over dimmed commits would claim a + // hit the branch doesn't have. (Empty branches have no nodes, so they + // ghost too: a zero-commit branch can never hold a match.) + const ghost = lit !== null && !lit.has(row.chain) + ctx.globalAlpha = ghost ? LABEL_GHOST_ALPHA : row.kind === 'unnamed' ? 0.8 : 1 ctx.beginPath() ctx.roundRect(rect.x, rect.y, rect.w, rect.h, 5) // A sticky pill floats over diagram content — a soft shadow makes the diff --git a/src/renderer/src/components/graph/searchGlow.test.ts b/src/renderer/src/components/graph/searchGlow.test.ts new file mode 100644 index 0000000..5474941 --- /dev/null +++ b/src/renderer/src/components/graph/searchGlow.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, test } from 'bun:test' +import { litChains, pingRings, withAlpha } from './searchGlow' + +describe('pingRings', () => { + test('settled pulse draws nothing', () => { + expect(pingRings(1)).toEqual([]) + expect(pingRings(1.5)).toEqual([]) + }) + + test('out-of-range pulse draws nothing', () => { + expect(pingRings(-0.1)).toEqual([]) + }) + + test('launch moment: only the first ring, at the start radius', () => { + const rings = pingRings(0.01) + expect(rings.length).toBe(1) + expect(rings[0].grow).toBeLessThan(1) + expect(rings[0].alpha).toBeGreaterThan(0.6) + }) + + test('mid-ping: both staggered rings, the older one further out and fainter', () => { + const rings = pingRings(0.6) + expect(rings.length).toBe(2) + const [first, second] = rings + expect(first.grow).toBeGreaterThan(second.grow) + expect(first.alpha).toBeLessThan(second.alpha) + }) + + test('rings expand and fade monotonically over the pulse', () => { + let lastGrow = -1 + let lastAlpha = 2 + for (const pulse of [0.1, 0.3, 0.5, 0.7, 0.9]) { + const first = pingRings(pulse)[0] + expect(first.grow).toBeGreaterThan(lastGrow) + expect(first.alpha).toBeLessThan(lastAlpha) + lastGrow = first.grow + lastAlpha = first.alpha + } + }) +}) + +describe('withAlpha', () => { + test('expands #rrggbb tokens', () => { + expect(withAlpha('#d6a027', 0.5)).toBe('rgba(214, 160, 39, 0.5)') + }) + + test('expands #rgb shorthand', () => { + expect(withAlpha('#f00', 0.25)).toBe('rgba(255, 0, 0, 0.25)') + }) + + test('zero alpha is honored (transparent gradient stop)', () => { + expect(withAlpha('#9a6700', 0)).toBe('rgba(154, 103, 0, 0)') + }) + + test('non-hex colors pass through untouched', () => { + expect(withAlpha('hsl(214 72% 64%)', 0.5)).toBe('hsl(214 72% 64%)') + }) + + test('cached calls stay stable', () => { + expect(withAlpha('#d6a027', 0.5)).toBe(withAlpha('#d6a027', 0.5)) + }) +}) + +describe('litChains', () => { + const node = (chain: number, hash: string) => ({ chain, commit: { hash } }) + + test('collects only chains holding a match', () => { + const nodes = [node(0, 'a'), node(0, 'b'), node(1, 'c'), node(2, 'd')] + const lit = litChains(nodes, new Set(['b', 'd'])) + expect(lit.has(0)).toBe(true) + expect(lit.has(1)).toBe(false) + expect(lit.has(2)).toBe(true) + }) + + test('no matches lights nothing (every label ghosts)', () => { + const nodes = [node(0, 'a'), node(1, 'b')] + expect(litChains(nodes, new Set()).size).toBe(0) + }) +}) diff --git a/src/renderer/src/components/graph/searchGlow.ts b/src/renderer/src/components/graph/searchGlow.ts new file mode 100644 index 0000000..eb62266 --- /dev/null +++ b/src/renderer/src/components/graph/searchGlow.ts @@ -0,0 +1,78 @@ +// Pure logic behind the Graph search highlight — the text-editor find pattern +// translated to canvas: every hit glows warm gold, the CURRENT hit wears a +// wider corona and lands with a brief sonar ping. Kept free of canvas types +// so the geometry and color math are directly testable. + +/** How far past the node radius a hit's glow fades to nothing (world px). */ +export const HIT_GLOW = 9 +/** The current hit's corona reaches further — "you are here" among hits. */ +export const ACTIVE_GLOW = 16 + +/** Duration of the arrival ping (ms) — GraphCanvas drives pulse 0→1 over it. */ +export const PING_MS = 650 +/** How far the ping travels past its start radius (world px). */ +const PING_SPAN = 20 +/** The second ring launches when the first is 35% out — a sonar double-tap. */ +const PING_STAGGER = 0.35 + +/** One expanding ring of the arrival ping. */ +export interface PingRing { + /** World px the ring has grown past its start radius. */ + grow: number + alpha: number + width: number +} + +/** The arrival ping at `pulse` (0 = just landed … 1 = settled): two staggered + * rings that expand while fading with an ease-out square — fast birth, soft + * death. Returns [] once settled, so the steady state draws nothing extra. */ +export function pingRings(pulse: number): PingRing[] { + if (pulse < 0 || pulse >= 1) return [] + const rings: PingRing[] = [] + for (const start of [0, PING_STAGGER]) { + const p = (pulse - start) / (1 - start) + if (p <= 0 || p >= 1) continue + const fade = (1 - p) ** 2 + rings.push({ grow: p * PING_SPAN, alpha: fade * 0.7, width: 0.5 + 1.5 * fade }) + } + return rings +} + +// Alpha'd colors are built per (color, alpha) once — the draw loop asks for +// the same handful every frame. +const alphaCache = new Map() + +/** `color` (a #rgb/#rrggbb design token) with `alpha` applied. Radial glows + * need per-stop alpha and canvas gradient stops take color strings only, so + * alpha is injected into the token here. Unknown formats pass through + * (opaque) rather than break the draw. */ +export function withAlpha(color: string, alpha: number): string { + const key = `${color}/${alpha}` + let value = alphaCache.get(key) + if (value) return value + const hex = color.startsWith('#') ? color.slice(1) : null + if (hex && (hex.length === 3 || hex.length === 6)) { + const full = hex.length === 3 ? hex.replace(/./g, (c) => c + c) : hex + const n = Number.parseInt(full, 16) + if (!Number.isNaN(n)) { + value = `rgba(${(n >> 16) & 0xff}, ${(n >> 8) & 0xff}, ${n & 0xff}, ${alpha})` + } + } + value ??= color + alphaCache.set(key, value) + return value +} + +/** The chains (GraphRow.chain / GraphNode.chain) holding at least one match. + * Branch labels of chains with none ghost alongside their dimmed commits — + * a lit label over dimmed commits would claim a hit the branch doesn't have. */ +export function litChains( + nodes: readonly { chain: number; commit: { hash: string } }[], + matches: ReadonlySet +): ReadonlySet { + const lit = new Set() + for (const node of nodes) { + if (matches.has(node.commit.hash)) lit.add(node.chain) + } + return lit +} diff --git a/src/renderer/src/styles/features/graph.css b/src/renderer/src/styles/features/graph.css index 69e0cf0..9730ed9 100644 --- a/src/renderer/src/styles/features/graph.css +++ b/src/renderer/src/styles/features/graph.css @@ -306,12 +306,24 @@ } .graph-search__count { font-size: 11px; - color: var(--fg-faint); + color: var(--fg-muted); + /* Fixed-width digits: stepping "3/66 → 4/66" must not jitter the row. */ + font-variant-numeric: tabular-nums; white-space: nowrap; } +/* Quiet steppers: the chevron reads from its glyph alone. The stock bordered + icon-btn drowned a 12px hairline chevron in its own border at this size — + so no box until hover, and disabled fades the glyph, not a box. */ .graph-search__step { - width: 20px; - height: 20px; + width: 22px; + height: 22px; + border: none; + background: none; +} +.graph-search__step:disabled { + color: var(--fg-faint); + opacity: 0.45; + background: none; } /* ── Filter popover pickers (GraphToolbar.tsx) ───────────────────────────── */ From 0c555c21321e4c00cf094e2cb877fe1e4803229a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Pen=CC=83alba?= Date: Sat, 4 Jul 2026 19:40:34 +0200 Subject: [PATCH 2/4] Also search for branches and tags --- .../src/components/graph/GraphCanvas.tsx | 68 +++++++---- .../src/components/graph/GraphToolbar.tsx | 69 +++++++---- .../src/components/graph/GraphView.tsx | 74 ++++++++---- src/renderer/src/components/graph/render.ts | 114 +++++++++++++++--- .../src/components/graph/searchGlow.test.ts | 80 +++++++++++- .../src/components/graph/searchGlow.ts | 81 ++++++++++++- src/renderer/src/styles/features/graph.css | 7 ++ 7 files changed, 401 insertions(+), 92 deletions(-) diff --git a/src/renderer/src/components/graph/GraphCanvas.tsx b/src/renderer/src/components/graph/GraphCanvas.tsx index b015fe6..c265882 100644 --- a/src/renderer/src/components/graph/GraphCanvas.tsx +++ b/src/renderer/src/components/graph/GraphCanvas.tsx @@ -37,7 +37,7 @@ import { readPalette, SUBJECT_FONT } from './render' -import { PING_MS } from './searchGlow' +import { hitKey, PING_MS, type SearchHit } from './searchGlow' import { usePanInertia } from './usePanInertia' import { useZoomAnimation } from './useZoomAnimation' import { isDiscreteWheel, wheelZoomFactor } from './zoom' @@ -50,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 { @@ -62,8 +64,12 @@ interface Props { selectedBranch: BranchSelection | null /** Commits kept at full strength while the rest dim; null = no filter. */ matches: ReadonlySet | 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 | null + /** Commits whose tag chip is a hit; null = not searching. */ + hitTags: ReadonlySet | 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). */ @@ -146,7 +152,9 @@ export function GraphCanvas({ selectedHash, selectedBranch, matches, - activeMatch, + activeHit, + hitBranches, + hitTags, changesCount, links, controls, @@ -196,7 +204,9 @@ export function GraphCanvas({ selectedHash, selectedBranch, matches, - activeMatch, + activeHit, + hitBranches, + hitTags, wip, dayMarks, links @@ -206,7 +216,9 @@ export function GraphCanvas({ selectedHash, selectedBranch, matches, - activeMatch, + activeHit, + hitBranches, + hitTags, wip, dayMarks, links @@ -233,7 +245,9 @@ 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, @@ -361,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( @@ -393,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(() => { @@ -444,7 +465,9 @@ export function GraphCanvas({ selectedHash, selectedBranch, matches, - activeMatch, + activeHit, + hitBranches, + hitTags, wip, links, theme, @@ -452,11 +475,14 @@ export function GraphCanvas({ ]) useEffect(() => subscribeAvatars(invalidate), [invalidate]) // The current hit's arrival ping: a short, FINITE rAF loop — it runs - // PING_MS per activeMatch change and stops, so an idle graph burns zero - // frames. Reduced-motion users get the steady glow with no ping. + // 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 (!activeMatch) return + if (!activeHitKey) return if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return matchPulseRef.current = 0 const start = performance.now() @@ -466,7 +492,7 @@ export function GraphCanvas({ if (matchPulseRef.current < 1) raf = requestAnimationFrame(tick) }) return () => cancelAnimationFrame(raf) - }, [activeMatch, invalidate]) + }, [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 diff --git a/src/renderer/src/components/graph/GraphToolbar.tsx b/src/renderer/src/components/graph/GraphToolbar.tsx index d6d58b6..50a2886 100644 --- a/src/renderer/src/components/graph/GraphToolbar.tsx +++ b/src/renderer/src/components/graph/GraphToolbar.tsx @@ -193,6 +193,7 @@ export function GraphToolbar({ onMatchStep }: Props) { const [open, setOpen] = useState<'branches' | 'authors' | 'date' | 'view' | 'focus' | null>(null) + const searchInput = useRef(null) const anchors = useRef>({}) const anchorFor = (id: string) => (el: HTMLButtonElement | null) => { anchors.current[id] = el @@ -270,6 +271,7 @@ export function GraphToolbar({
{searching && ( <> - - {matchCount === 0 ? '0' : `${matchIndex + 1}/${matchCount}`} - + {/* With no hits matchIndex is -1, so this renders "0/0" — the + compact zero state; anything wordier crowds the box. */} + {`${matchIndex + 1}/${matchCount}`} {/* Chevrons drawn a notch thicker than the stock 1.7px icon - stroke: at stepper size the default renders a hairline. */} - - + 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. */} +
+ + + +
)}
diff --git a/src/renderer/src/components/graph/GraphView.tsx b/src/renderer/src/components/graph/GraphView.tsx index fa07416..2e66d88 100644 --- a/src/renderer/src/components/graph/GraphView.tsx +++ b/src/renderer/src/components/graph/GraphView.tsx @@ -23,6 +23,7 @@ import { } from './layout' import { linkableChains, twinHashes } from './links' import { relatedBranches } from './related' +import { computeSearchHits } from './searchGlow' import { releaseLineVersion, releaseVersionWithOverride } from './releases' import { useBackportLinks } from './useBackportLinks' import { useGraphLog } from './useGraphLog' @@ -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() + 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() + 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() + 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) } @@ -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} />
@@ -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} diff --git a/src/renderer/src/components/graph/render.ts b/src/renderer/src/components/graph/render.ts index 230538f..4d122c5 100644 --- a/src/renderer/src/components/graph/render.ts +++ b/src/renderer/src/components/graph/render.ts @@ -39,7 +39,14 @@ import { rowMatchesSelection } from './layout' import { type BackportLink, linkedHashes } from './links' -import { ACTIVE_GLOW, HIT_GLOW, litChains, pingRings, withAlpha } from './searchGlow' +import { + ACTIVE_GLOW, + HIT_GLOW, + litChains, + pingRings, + type SearchHit, + withAlpha +} from './searchGlow' export interface GraphPalette { dark: boolean @@ -149,10 +156,15 @@ export interface SceneState { /** Commits kept at full strength while everything else dims (filters/search), * or null when nothing is filtering. */ matches: ReadonlySet | null - /** The current search hit, ringed louder than its fellow matches. */ - activeMatch: string | null + /** The current search hit — a commit node, a branch LABEL, or a tag chip; + * it wears the loud treatment its fellow hits wear softly. */ + activeHit: SearchHit | null + /** Chains whose branch label is itself a hit; null = not searching. */ + hitBranches: ReadonlySet | null + /** Commits whose tag chip is a hit; null = not searching. */ + hitTags: ReadonlySet | null /** The current hit's arrival ping: 0 = just landed … 1 = settled (no ping). - * GraphCanvas animates it over PING_MS whenever activeMatch changes. */ + * GraphCanvas animates it over PING_MS whenever the hit changes. */ matchPulse: number wip: { column: number; row: number; count: number; color: number } | null dayMarks: DayMark[] @@ -444,7 +456,9 @@ function litChainsFor(scene: SceneState): ReadonlySet | null { if (matches === null) return null let lit = litChainsCache.get(matches) if (!lit) { - lit = litChains(scene.layout.nodes, matches) + // hitBranches shares the matches set's lifetime (both derive from the + // same search recompute), so keying the cache on matches alone is safe. + lit = litChains(scene.layout.nodes, matches, scene.hitBranches) litChainsCache.set(matches, lit) } return lit @@ -557,14 +571,16 @@ function drawNodes( ctx.fill() ctx.globalAlpha = dim ? DIM_ALPHA : 1 - // Search hits wear the text-editor find treatment, in gold: every hit a + // Commit hits wear the text-editor find treatment, in gold: every hit a // soft radial glow behind the node, the CURRENT hit a wider corona (plus // a ring and an arrival ping, below). Gold, not the branch hue — hits // must read across all branch colors at a glance. The glow fades to a // zero-alpha stop of the SAME color: fading to `transparent` would drag - // the gradient through black and dirty the halo's rim. - const isActiveMatch = node.commit.hash === scene.activeMatch - const isHit = scene.activeMatch !== null && scene.matches?.has(node.commit.hash) === true + // the gradient through black and dirty the halo's rim. (Branch and tag + // hits glow their pill/chip instead — drawLabels / drawNodeText.) + const isActiveMatch = + scene.activeHit?.kind === 'commit' && scene.activeHit.hash === node.commit.hash + const isHit = scene.activeHit !== null && scene.matches?.has(node.commit.hash) === true if (isHit) { const glowR = NODE_R + (isActiveMatch ? ACTIVE_GLOW : HIT_GLOW) const glow = ctx.createRadialGradient(x, y, NODE_R - 2, x, y, glowR) @@ -921,17 +937,37 @@ function drawNodeText( // Tag chips share the label band; a sticky label sliding over one wins — // the chip yields and reappears as the user pans on. if (labelBoxes.some((b) => b.sticky && intersects(b.rect, chip))) return + // A tag-name hit is the CHIP itself, in the same gold find treatment the + // branch labels wear: the chip stays at full strength (its commit didn't + // match — the tag did, so the node beneath keeps its dim), glow behind, + // gold border, and corona + ping when current. + const isHitTag = scene.hitTags?.has(node.commit.hash) === true + const isActiveTag = + scene.activeHit?.kind === 'tag' && scene.activeHit.hash === node.commit.hash + if (isHitTag) ctx.globalAlpha = 1 ctx.beginPath() ctx.roundRect(chip.x, chip.y, chip.w, chip.h, 4) + if (isHitTag) { + ctx.save() + ctx.shadowColor = withAlpha(palette.match, isActiveTag ? 0.95 : 0.65) + ctx.shadowBlur = isActiveTag ? 12 : 8 + ctx.fillStyle = palette.labelBg + ctx.fill() + ctx.restore() + } ctx.fillStyle = palette.labelBg ctx.fill() - ctx.strokeStyle = palette.tag + ctx.strokeStyle = isHitTag ? palette.match : palette.tag ctx.lineWidth = 1 - ctx.globalAlpha *= 0.8 + if (!isHitTag) ctx.globalAlpha *= 0.8 ctx.stroke() - ctx.globalAlpha = dimmed(scene, node.commit.hash) ? DIM_ALPHA : 1 + ctx.globalAlpha = isHitTag ? 1 : dimmed(scene, node.commit.hash) ? DIM_ALPHA : 1 ctx.fillStyle = palette.tag ctx.fillText(label, x, chip.y + 8) + if (isActiveTag) { + drawRectPing(ctx, scene, chip, 4) + ctx.globalAlpha = 1 + } } } @@ -983,16 +1019,33 @@ function drawLabels( // hit the branch doesn't have. (Empty branches have no nodes, so they // ghost too: a zero-commit branch can never hold a match.) const ghost = lit !== null && !lit.has(row.chain) - ctx.globalAlpha = ghost ? LABEL_GHOST_ALPHA : row.kind === 'unnamed' ? 0.8 : 1 + // A branch-name hit is the LABEL itself: the same gold find treatment + // commits get, pill-shaped — glow behind it, and for the current hit a + // corona ring plus the arrival ping (below). litChains keeps hit labels + // out of the ghost set, so a hit always draws at full strength. + const isHitLabel = scene.hitBranches?.has(row.chain) === true + const isActiveHit = scene.activeHit?.kind === 'branch' && scene.activeHit.chain === row.chain + const inkAlpha = ghost ? LABEL_GHOST_ALPHA : row.kind === 'unnamed' ? 0.8 : 1 + // The opaque base NEVER ghosts: a translucent pill lets the capsule and + // spine bleed through the text and reads as a glitch. Ghosting fades the + // pill's ink — tint, border, name — while the body keeps masking the + // diagram behind it. + ctx.globalAlpha = row.kind === 'unnamed' ? 0.8 : 1 ctx.beginPath() ctx.roundRect(rect.x, rect.y, rect.w, rect.h, 5) // A sticky pill floats over diagram content — a soft shadow makes the - // layering read as deliberate. - if (sticky) { + // layering read as deliberate. A hit's gold bloom replaces it: a shadow, + // not a radial gradient — it hugs the rounded shape. + if (isHitLabel || sticky) { ctx.save() - ctx.shadowColor = 'rgba(0, 0, 0, 0.3)' - ctx.shadowBlur = 6 - ctx.shadowOffsetY = 1 + if (isHitLabel) { + ctx.shadowColor = withAlpha(palette.match, isActiveHit ? 0.95 : 0.65) + ctx.shadowBlur = isActiveHit ? 14 : 9 + } else { + ctx.shadowColor = 'rgba(0, 0, 0, 0.3)' + ctx.shadowBlur = 6 + ctx.shadowOffsetY = 1 + } ctx.fillStyle = palette.labelBg ctx.fill() ctx.restore() @@ -1012,10 +1065,35 @@ function drawLabels( } ctx.fillStyle = head ? palette.onAccent : branchText(palette, row.color) ctx.fillText(row.name, rect.x + 8, rect.y + rect.h / 2 + 0.5) + if (isActiveHit) drawRectPing(ctx, scene, rect, 5) ctx.globalAlpha = 1 } } +/** The current hit's corona + arrival ping around a pill/chip rect — the + * rectangular twin of the node treatment in drawNodes, shared by branch + * labels and tag chips. */ +function drawRectPing( + ctx: CanvasRenderingContext2D, + scene: SceneState, + rect: { x: number; y: number; w: number; h: number }, + radius: number +): void { + ctx.strokeStyle = scene.palette.match + ctx.lineWidth = 2 + ctx.beginPath() + ctx.roundRect(rect.x - 2.5, rect.y - 2.5, rect.w + 5, rect.h + 5, radius + 2) + ctx.stroke() + for (const ring of pingRings(scene.matchPulse)) { + ctx.globalAlpha = ring.alpha + ctx.lineWidth = ring.width + const grow = 2.5 + ring.grow + ctx.beginPath() + ctx.roundRect(rect.x - grow, rect.y - grow, rect.w + 2 * grow, rect.h + 2 * grow, radius + grow) + ctx.stroke() + } +} + /** Screen-space sticky date header, panning horizontally with the diagram. */ function drawHeader(ctx: CanvasRenderingContext2D, scene: SceneState): void { const { view, width, dpr, palette } = scene diff --git a/src/renderer/src/components/graph/searchGlow.test.ts b/src/renderer/src/components/graph/searchGlow.test.ts index 5474941..57687cd 100644 --- a/src/renderer/src/components/graph/searchGlow.test.ts +++ b/src/renderer/src/components/graph/searchGlow.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from 'bun:test' -import { litChains, pingRings, withAlpha } from './searchGlow' +import { computeSearchHits, hitKey, litChains, pingRings, withAlpha } from './searchGlow' describe('pingRings', () => { test('settled pulse draws nothing', () => { @@ -76,4 +76,82 @@ describe('litChains', () => { const nodes = [node(0, 'a'), node(1, 'b')] expect(litChains(nodes, new Set()).size).toBe(0) }) + + test('branch-label hits light their chain regardless of commits', () => { + const nodes = [node(0, 'a'), node(1, 'b')] + const lit = litChains(nodes, new Set(['a']), new Set([2])) + expect(lit.has(0)).toBe(true) + expect(lit.has(1)).toBe(false) + expect(lit.has(2)).toBe(true) + }) +}) + +describe('computeSearchHits', () => { + const mkNode = (hash: string, subject: string, column: number, row = 0, refs: string[] = []) => ({ + commit: { hash, subject, authorName: 'Dani', authorEmail: 'dani@x.com' }, + refs: refs.map((name) => ({ name, isTag: name.startsWith('v') })), + column, + row + }) + const mkRow = (chain: number, name: string, startColumn: number, index = chain) => ({ + chain, + name, + index, + startColumn + }) + + test('nothing to filter by yields no hits', () => { + expect(computeSearchHits([], null, [mkNode('a', 'x', 0)], [mkRow(0, 'main', 0)])).toEqual([]) + }) + + test('a branch name finds the LABEL, not any commit', () => { + const nodes = [mkNode('a', 'Modified cs 8', 5)] + const rows = [mkRow(0, 'main', 0), mkRow(1, 'something6-1', 6)] + expect(computeSearchHits(['something6-1'], null, nodes, rows)).toEqual([ + { kind: 'branch', chain: 1 } + ]) + }) + + test('a tag name finds the CHIP; multiple matching tags are one stop', () => { + const nodes = [mkNode('a', 'Release', 3, 0, ['v1.2.0', 'v1.2.0-rc1'])] + expect(computeSearchHits(['v1.2.0'], null, nodes, [])).toEqual([{ kind: 'tag', hash: 'a' }]) + }) + + test('mixed hits order newest-first, ref hits just before their commit', () => { + const nodes = [mkNode('old', 'fix parser', 1, 0), mkNode('new', 'fix diff', 4, 0, ['vfix'])] + const rows = [mkRow(1, 'fix-layout', 2, 1)] + expect(computeSearchHits(['fix'], null, nodes, rows)).toEqual([ + { kind: 'tag', hash: 'new' }, + { kind: 'commit', hash: 'new' }, + { kind: 'branch', chain: 1 }, + { kind: 'commit', hash: 'old' } + ]) + }) + + test('the author filter gates commits but never branches or tags', () => { + const nodes = [mkNode('a', 'fix things', 1, 0, ['vfix'])] + const rows = [mkRow(0, 'fix-layout', 0)] + const hits = computeSearchHits(['fix'], new Set(['other@x.com']), nodes, rows) + expect(hits).toEqual([ + { kind: 'tag', hash: 'a' }, + { kind: 'branch', chain: 0 } + ]) + }) + + test('author filter alone (no terms) yields commit hits only', () => { + const nodes = [mkNode('a', 'anything', 0)] + const rows = [mkRow(0, 'main', 0)] + const hits = computeSearchHits([], new Set(['dani@x.com']), nodes, rows) + expect(hits).toEqual([{ kind: 'commit', hash: 'a' }]) + }) +}) + +describe('hitKey', () => { + test('stable, distinct identities per kind', () => { + expect(hitKey(null)).toBeNull() + expect(hitKey({ kind: 'commit', hash: 'a' })).toBe('commit:a') + expect(hitKey({ kind: 'tag', hash: 'a' })).toBe('tag:a') + expect(hitKey({ kind: 'branch', chain: 3 })).toBe('branch:3') + expect(hitKey({ kind: 'commit', hash: 'a' })).not.toBe(hitKey({ kind: 'tag', hash: 'a' })) + }) }) diff --git a/src/renderer/src/components/graph/searchGlow.ts b/src/renderer/src/components/graph/searchGlow.ts index eb62266..dcbe7fe 100644 --- a/src/renderer/src/components/graph/searchGlow.ts +++ b/src/renderer/src/components/graph/searchGlow.ts @@ -63,14 +63,91 @@ export function withAlpha(color: string, alpha: number): string { return value } +/** A search result: a commit, a branch LABEL, or a tag chip. Searching a + * branch or tag name finds the ref itself — its pill/chip glows and is what + * Enter steps to — never the commit it happens to decorate. */ +export type SearchHit = + | { kind: 'commit'; hash: string } + | { kind: 'branch'; chain: number } + | { kind: 'tag'; hash: string } + +interface SearchableNode { + commit: { hash: string; subject: string; authorName: string; authorEmail: string } + refs: readonly { name: string; isTag: boolean }[] + column: number + row: number +} + +interface SearchableRow { + chain: number + name: string + index: number + startColumn: number +} + +/** All search results, ordered newest-first (descending column — the order + * Enter steps through). The author filter gates commits only: branches and + * tags have no author. With no terms it degrades to the author filter's + * commit set, which feeds dimming while the search box is empty. */ +export function computeSearchHits( + terms: readonly string[], + authorFilter: ReadonlySet | null, + nodes: readonly SearchableNode[], + rows: readonly SearchableRow[] +): SearchHit[] { + if (terms.length === 0 && authorFilter === null) return [] + const matchesTerms = (hay: string) => terms.every((t) => hay.includes(t)) + const entries: { hit: SearchHit; column: number; order: number }[] = [] + for (const node of nodes) { + const c = node.commit + const byAuthor = authorFilter === null || authorFilter.has(c.authorEmail.toLowerCase()) + if (byAuthor && matchesTerms(`${c.subject} ${c.authorName} ${c.hash}`.toLowerCase())) { + entries.push({ hit: { kind: 'commit', hash: c.hash }, column: node.column, order: node.row }) + } + // One tag hit per COMMIT, not per tag: all its tags render as one chip, + // and one chip on screen must be one stop when stepping. + if (terms.length > 0 && node.refs.some((r) => r.isTag && matchesTerms(r.name.toLowerCase()))) { + entries.push({ + hit: { kind: 'tag', hash: c.hash }, + column: node.column, + // The chip sits above its node — visit it just before the commit. + order: node.row - 0.5 + }) + } + } + if (terms.length > 0) { + for (const row of rows) { + if (matchesTerms(row.name.toLowerCase())) { + entries.push({ + hit: { kind: 'branch', chain: row.chain }, + column: row.startColumn, + order: row.index - 0.5 + }) + } + } + } + entries.sort((a, b) => b.column - a.column || a.order - b.order) + return entries.map((e) => e.hit) +} + +/** Stable identity for a hit: effects key off it so the arrival ping re-fires + * only when the TARGET changes, not when the hits array is rebuilt. */ +export function hitKey(hit: SearchHit | null): string | null { + if (hit === null) return null + return hit.kind === 'branch' ? `branch:${hit.chain}` : `${hit.kind}:${hit.hash}` +} + /** The chains (GraphRow.chain / GraphNode.chain) holding at least one match. * Branch labels of chains with none ghost alongside their dimmed commits — * a lit label over dimmed commits would claim a hit the branch doesn't have. */ export function litChains( nodes: readonly { chain: number; commit: { hash: string } }[], - matches: ReadonlySet + matches: ReadonlySet, + /** Chains lit regardless of their commits — a branch-label hit stays at + * full strength even though none of its commits matched. */ + extra: ReadonlySet | null = null ): ReadonlySet { - const lit = new Set() + const lit = new Set(extra ?? []) for (const node of nodes) { if (matches.has(node.commit.hash)) lit.add(node.chain) } diff --git a/src/renderer/src/styles/features/graph.css b/src/renderer/src/styles/features/graph.css index 9730ed9..866b1b8 100644 --- a/src/renderer/src/styles/features/graph.css +++ b/src/renderer/src/styles/features/graph.css @@ -311,6 +311,13 @@ font-variant-numeric: tabular-nums; white-space: nowrap; } +/* Prev/next/clear sit flush — one cluster, no inter-button air (the browser + find-bar convention): only one is ever hovered, and its hover box is the + separation. The container's gap still keeps the count clear of them. */ +.graph-search__actions { + display: flex; + align-items: center; +} /* Quiet steppers: the chevron reads from its glyph alone. The stock bordered icon-btn drowned a 12px hairline chevron in its own border at this size — so no box until hover, and disabled fades the glyph, not a box. */ From 1a35cf0b9737f34a6ca6851ee5659265ca0638c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Pen=CC=83alba?= Date: Sat, 4 Jul 2026 19:45:02 +0200 Subject: [PATCH 3/4] Also ghost the uncommited node --- src/renderer/src/components/graph/render.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/components/graph/render.ts b/src/renderer/src/components/graph/render.ts index 4d122c5..c0d7189 100644 --- a/src/renderer/src/components/graph/render.ts +++ b/src/renderer/src/components/graph/render.ts @@ -975,13 +975,17 @@ function drawWip(ctx: CanvasRenderingContext2D, scene: SceneState): void { const { palette } = scene if (!scene.wip) return const { column, row, count, color } = scene.wip + // The WIP node is never a hit (it isn't a commit), so while a filter or + // search dims the diagram it recedes with the non-matches — a full-strength + // "uncommitted" beside dimmed commits would read as a result. + const dim = scene.matches !== null const x = nodeX(column) const y = nodeY(row) const tipX = nodeX(column - 1) ctx.strokeStyle = branchStroke(palette, color) ctx.setLineDash([3, 3]) ctx.lineWidth = 1.5 - ctx.globalAlpha = 0.9 + ctx.globalAlpha = dim ? DIM_ALPHA : 0.9 ctx.beginPath() ctx.moveTo(tipX + NODE_R, y) ctx.lineTo(x - NODE_R, y) @@ -990,7 +994,7 @@ function drawWip(ctx: CanvasRenderingContext2D, scene: SceneState): void { ctx.arc(x, y, NODE_R, 0, Math.PI * 2) ctx.stroke() ctx.setLineDash([]) - ctx.globalAlpha = 1 + ctx.globalAlpha = dim ? DIM_ALPHA : 1 ctx.fillStyle = palette.subject ctx.font = `600 10px ${palette.font}` ctx.textAlign = 'center' @@ -998,7 +1002,9 @@ function drawWip(ctx: CanvasRenderingContext2D, scene: SceneState): void { ctx.fillText(count > 99 ? '99+' : `+${count}`, x, y + 0.5) // Placed exactly like a commit's caption — same size, weight, capsule gap // and left edge — so the WIP node reads as one more entry in the chain. + // drawCaption multiplies the current alpha, so the dim carries through. drawCaption(ctx, scene, 'uncommitted', x - NODE_R, y, COL_W * 3.4) + ctx.globalAlpha = 1 } function drawLabels( @@ -1054,6 +1060,7 @@ function drawLabels( ctx.fillStyle = palette.labelBg ctx.fill() } + ctx.globalAlpha = inkAlpha ctx.fillStyle = head ? palette.accent : branchFill(palette, row.color, 0.15) ctx.fill() if (!head) { From 3cf4fbfdd1c6091687148dad92232604ce72a029 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Pen=CC=83alba?= Date: Sat, 4 Jul 2026 19:52:31 +0200 Subject: [PATCH 4/4] Fix lint --- src/renderer/src/components/graph/GraphView.tsx | 2 +- src/renderer/src/components/graph/render.ts | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/renderer/src/components/graph/GraphView.tsx b/src/renderer/src/components/graph/GraphView.tsx index 2e66d88..aacb53c 100644 --- a/src/renderer/src/components/graph/GraphView.tsx +++ b/src/renderer/src/components/graph/GraphView.tsx @@ -23,8 +23,8 @@ import { } from './layout' import { linkableChains, twinHashes } from './links' import { relatedBranches } from './related' -import { computeSearchHits } from './searchGlow' import { releaseLineVersion, releaseVersionWithOverride } from './releases' +import { computeSearchHits } from './searchGlow' import { useBackportLinks } from './useBackportLinks' import { useGraphLog } from './useGraphLog' diff --git a/src/renderer/src/components/graph/render.ts b/src/renderer/src/components/graph/render.ts index c0d7189..f523806 100644 --- a/src/renderer/src/components/graph/render.ts +++ b/src/renderer/src/components/graph/render.ts @@ -942,8 +942,7 @@ function drawNodeText( // match — the tag did, so the node beneath keeps its dim), glow behind, // gold border, and corona + ping when current. const isHitTag = scene.hitTags?.has(node.commit.hash) === true - const isActiveTag = - scene.activeHit?.kind === 'tag' && scene.activeHit.hash === node.commit.hash + const isActiveTag = scene.activeHit?.kind === 'tag' && scene.activeHit.hash === node.commit.hash if (isHitTag) ctx.globalAlpha = 1 ctx.beginPath() ctx.roundRect(chip.x, chip.y, chip.w, chip.h, 4)