From 9720340880c2e663d3497d252ce3465d42b560db Mon Sep 17 00:00:00 2001 From: Dave Gaeddert Date: Sun, 1 Feb 2026 21:34:08 -0600 Subject: [PATCH 1/2] Add self-contained DiffMinimap with internal scroll tracking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The minimap now attaches its own passive scroll listener and ResizeObserver on the scrollContainer prop, directly mutating its viewport indicator div. This removes the forwardRef/ useImperativeHandle plumbing and the useScrollPosition hook from CodeViewer, simplifying parent↔minimap communication. --- src/components/CodeViewer/DiffMinimap.tsx | 162 ++++++++++++++++++++ src/components/CodeViewer/HunkNavigator.tsx | 16 +- src/components/CodeViewer/index.tsx | 75 ++++++++- src/index.css | 11 ++ 4 files changed, 247 insertions(+), 17 deletions(-) create mode 100644 src/components/CodeViewer/DiffMinimap.tsx diff --git a/src/components/CodeViewer/DiffMinimap.tsx b/src/components/CodeViewer/DiffMinimap.tsx new file mode 100644 index 00000000..3353c2f7 --- /dev/null +++ b/src/components/CodeViewer/DiffMinimap.tsx @@ -0,0 +1,162 @@ +import { useCallback, useEffect, useRef } from "react"; +import type { ReviewState } from "../../types"; +import { isHunkTrusted } from "../../types"; +import { usePrefersReducedMotion } from "../../hooks"; + +// --- Public types --- + +export type HunkStatus = + | "pending" + | "trusted" + | "approved" + | "rejected" + | "classifying"; + +export interface MinimapMarker { + id: string; + topFraction: number; + heightFraction: number; + status: HunkStatus; + isFocused: boolean; +} + +interface DiffMinimapProps { + markers: MinimapMarker[]; + scrollContainer: HTMLElement | null; + onMarkerClick: (index: number) => void; +} + +// --- Helpers --- + +export function getHunkStatus( + hunkId: string, + reviewState: ReviewState | null, + trustList: string[], + classifyingHunkIds: Set, +): HunkStatus { + if (classifyingHunkIds.has(hunkId)) return "classifying"; + const hunkState = reviewState?.hunks[hunkId]; + if (!hunkState) return "pending"; + if (hunkState.status === "approved") return "approved"; + if (hunkState.status === "rejected") return "rejected"; + if (isHunkTrusted(hunkState, trustList)) return "trusted"; + return "pending"; +} + +const STATUS_COLORS: Record = { + pending: "bg-stone-500", + trusted: "bg-cyan-400", + approved: "bg-lime-400", + rejected: "bg-rose-400", + classifying: "bg-violet-400", +}; + +// --- Component --- + +export function DiffMinimap({ + markers, + scrollContainer, + onMarkerClick, +}: DiffMinimapProps) { + const viewportRef = useRef(null); + const rafId = useRef(0); + const prefersReducedMotion = usePrefersReducedMotion(); + + // Self-manage scroll tracking: attach passive scroll listener + ResizeObserver + // on the scrollContainer and directly mutate the viewport indicator div. + useEffect(() => { + if (!scrollContainer) return; + + const update = () => { + const el = viewportRef.current; + if (!el) return; + const { scrollTop, scrollHeight, clientHeight } = scrollContainer; + const topPercent = + scrollHeight > 0 ? (scrollTop / scrollHeight) * 100 : 0; + const heightPercent = + scrollHeight > 0 ? Math.min(clientHeight / scrollHeight, 1) * 100 : 100; + el.style.top = `${topPercent}%`; + el.style.height = `${heightPercent}%`; + }; + + const scheduleUpdate = () => { + cancelAnimationFrame(rafId.current); + rafId.current = requestAnimationFrame(update); + }; + + // Initial measurement + update(); + + scrollContainer.addEventListener("scroll", scheduleUpdate, { + passive: true, + }); + + const observer = new ResizeObserver(scheduleUpdate); + observer.observe(scrollContainer); + + return () => { + cancelAnimationFrame(rafId.current); + scrollContainer.removeEventListener("scroll", scheduleUpdate); + observer.disconnect(); + }; + }, [scrollContainer]); + + const handleTrackClick = useCallback( + (e: React.MouseEvent) => { + // Only respond to clicks directly on the track (not on markers) + if (e.target !== e.currentTarget) return; + if (!scrollContainer) return; + + const rect = e.currentTarget.getBoundingClientRect(); + const fraction = (e.clientY - rect.top) / rect.height; + const maxScroll = + scrollContainer.scrollHeight - scrollContainer.clientHeight; + scrollContainer.scrollTo({ + top: fraction * maxScroll, + behavior: "smooth", + }); + }, + [scrollContainer], + ); + + return ( +