import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Icon } from '@/components/icon/Icon'; import { FileTypeIcon } from '@/components/icons/FileTypeIcon'; import { Button } from '@/components/ui/button'; import { useI18n } from '@/lib/i18n'; import { groupHunksByFile } from '@/lib/walkthrough/model'; import type { WalkthroughStopView, WalkthroughView } from '@/lib/walkthrough/model'; import type { WalkthroughHunk, WalkthroughStopImportance } from '@/lib/walkthrough/types'; import { cn } from '@/lib/utils'; import { WalkthroughHunkRun } from './WalkthroughHunkRun'; import { stopElementId } from './stopElementId'; interface WalkthroughStreamProps { view: WalkthroughView; activeStopId: string | null; scrollToStopId: string | null; onActiveStopChange: (stopId: string) => void; onScrollHandled: () => void; renderSideBySide: boolean; wrapLines: boolean; } const IMPORTANCE_CLASS: Record = { critical: 'bg-status-error/10 text-status-error', normal: 'bg-surface-muted text-muted-foreground', context: 'bg-surface-muted text-muted-foreground', }; const StopHeader = ({ stopView }: { stopView: WalkthroughStopView }) => { const { t } = useI18n(); const { stop } = stopView; return (
{stopView.position}

{stop.title}

{/* Same height as the step badge, so a row with an importance pill is exactly as tall as one without: vertical padding on a smaller type size was pushing past the tallest element in the row. */} {stop.importance !== 'normal' && ( {stop.importance === 'critical' ? t('walkthrough.importance.critical') : t('walkthrough.importance.context')} )}

{stop.prose}

{stopView.isStale && (

{stopView.hunks.length === 0 ? t('walkthrough.stop.staleAll') : t('walkthrough.stop.stalePartial', { count: stopView.missingHunkIds.length })}

)}
); }; /** * Sticky so the file you are reading stays named while you scroll through its * hunks — the path is the main orientation cue in a long stream, and as a plain * caption it was easy to scroll straight past. */ const FileHeader = ({ path }: { path: string }) => (
{path}
); const HunkRuns = ({ hunks, renderSideBySide, wrapLines, }: { hunks: WalkthroughHunk[]; renderSideBySide: boolean; wrapLines: boolean; }) => { const runs = useMemo(() => groupHunksByFile(hunks), [hunks]); return ( <> {runs.map((run, index) => (
))} ); }; /** * Everything the walkthrough covers, plus everything it does not, in one * continuous scroll: a stop's explanation sits directly above the code it * explains. */ export const WalkthroughStream = memo(function WalkthroughStream({ view, activeStopId, scrollToStopId, onActiveStopChange, onScrollHandled, renderSideBySide, wrapLines, }: WalkthroughStreamProps) { const { t } = useI18n(); const scrollRef = useRef(null); const [uncoveredOpen, setUncoveredOpen] = useState(false); // Set while a click-driven jump is in flight. Without it the observer reports // every stop the viewport passes over on the way to the target and the // highlight ends up on whichever one happened to be reported last — the // sidebar showing step 5 while the stream shows step 6. const navigatingRef = useRef(null); const navigationTimerRef = useRef(null); useEffect(() => () => { if (navigationTimerRef.current !== null) window.clearTimeout(navigationTimerRef.current); }, []); // Scrolling is driven by the DOM rather than a virtualizer: only the visible // stops mount their diff viewers, and each stop is its own element, so there // is nothing to translate between index space and pixel space. useEffect(() => { if (!scrollToStopId) return; const element = document.getElementById(stopElementId(scrollToStopId)); if (element) { navigatingRef.current = scrollToStopId; // Instant, not smooth: picking a step is a jump to a known destination, // and a long animation only creates a window for the highlight to drift // through everything in between. element.scrollIntoView({ behavior: 'auto', block: 'start' }); // The observer fires asynchronously after the jump, and an element that // was already in view may not fire at all — so the mute is released on a // timer as well as on arrival. if (navigationTimerRef.current !== null) window.clearTimeout(navigationTimerRef.current); navigationTimerRef.current = window.setTimeout(() => { navigatingRef.current = null; navigationTimerRef.current = null; }, 250); } onScrollHandled(); }, [scrollToStopId, onScrollHandled]); const handleIntersection = useCallback( (entries: IntersectionObserverEntry[]) => { const visible = entries .filter((entry) => entry.isIntersecting) .sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top)[0]; if (!visible) return; const stopId = visible.target.getAttribute('data-stop-id'); if (!stopId) return; const navigatingTo = navigatingRef.current; if (navigatingTo) { // Arrived: hand control back to free scrolling. if (stopId === navigatingTo) navigatingRef.current = null; return; } onActiveStopChange(stopId); }, [onActiveStopChange] ); useEffect(() => { const root = scrollRef.current; if (!root) return; const observer = new IntersectionObserver(handleIntersection, { root, // Only count a stop as active once its header reaches the upper band of // the viewport, so scrolling through a long diff does not flicker the // active step back and forth. rootMargin: '0px 0px -70% 0px', threshold: 0, }); for (const stopView of view.stops) { const element = document.getElementById(stopElementId(stopView.stop.id)); if (element) observer.observe(element); } return () => observer.disconnect(); }, [handleIntersection, view.stops]); const uncoveredRuns = useMemo(() => groupHunksByFile(view.uncoveredHunks), [view.uncoveredHunks]); return (
{view.stops.map((stopView) => (
{stopView.hunks.length > 0 ? ( ) : (

{t('walkthrough.stop.noCode')}

)}
))} {view.uncoveredHunks.length > 0 && (
{!uncoveredOpen && (

{t('walkthrough.uncovered.description')}

)} {uncoveredOpen && uncoveredRuns.map((run, index) => (
))}
)}
); });