diff --git a/bun.lock b/bun.lock index 271e2c146..7d3cab469 100644 --- a/bun.lock +++ b/bun.lock @@ -63,7 +63,7 @@ }, "apps/opencode-plugin": { "name": "@plannotator/opencode", - "version": "0.26.1", + "version": "0.26.2", "devDependencies": { "@opencode-ai/plugin": "0.0.0-next-16775", "@plannotator/server": "workspace:*", @@ -80,7 +80,7 @@ }, "apps/pi-extension": { "name": "@plannotator/pi-extension", - "version": "0.26.1", + "version": "0.26.2", "dependencies": { "@joplin/turndown-plugin-gfm": "^1.0.64", "@pierre/diffs": "1.3.2", @@ -218,7 +218,7 @@ }, "packages/server": { "name": "@plannotator/server", - "version": "0.26.1", + "version": "0.26.2", "dependencies": { "@pierre/diffs": "1.3.2", "@plannotator/ai": "workspace:*", diff --git a/packages/review-editor/components/AllFilesCodeView.tsx b/packages/review-editor/components/AllFilesCodeView.tsx index 8e0932672..6ee97db9a 100644 --- a/packages/review-editor/components/AllFilesCodeView.tsx +++ b/packages/review-editor/components/AllFilesCodeView.tsx @@ -34,6 +34,7 @@ import { isContentlessBinaryPatch, isOversizedReviewStubPatch } from '@plannotat import { OversizedFileNotice } from './OversizedFileNotice'; import { ToolbarHost, type ToolbarHostHandle } from './ToolbarHost'; import { FileHeader } from './FileHeader'; +import { DiffHScrollbar } from './DiffHScrollbar'; import { BinaryFileNotice } from './BinaryFileNotice'; import { EditSessionHud } from './EditSessionHud'; import { FileCommentBanner } from './FileCommentBanner'; @@ -2125,7 +2126,7 @@ export const AllFilesCodeView: React.FC = ({ : null; return ( -
+
= ({ onHeightChange={() => refreshItem(item.id)} /> )} + {/* This file's own horizontal scrollbar (#1048), riding the sticky + header so it stays reachable anywhere in a long diff. Absolutely + positioned on the header's bottom edge so it costs no layout + height — itemMetrics.diffHeaderHeight must keep matching the + header, and a bar that appears only on overflow would otherwise + drift the virtualization estimate. */} + {!collapsed && diffOverflow !== 'wrap' && ( + + )}
); }); diff --git a/packages/review-editor/components/DiffHScrollbar.tsx b/packages/review-editor/components/DiffHScrollbar.tsx new file mode 100644 index 000000000..19415755d --- /dev/null +++ b/packages/review-editor/components/DiffHScrollbar.tsx @@ -0,0 +1,202 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; + +/** + * Per-file horizontal scrollbar for Pierre diffs (#1048). + * + * Pierre scrolls wide lines inside its own `[data-code]` element (in each + * file's shadow root), whose native scrollbar sits at the bottom of that + * file's content — off-screen on any diff taller than the panel, exactly + * when a clipped long line needs it. Pierre's own bar is suppressed (see + * `usePierreTheme`) and this one replaces it, rendered INSIDE the file's + * header so it rides that header's sticky positioning: every file gets its + * own bar, aligned to its own content, visible whenever the file is. + * + * The component locates its own target from its DOM position — either the + * `diffs-container` it sits inside (the all-files header portal) or the one + * alongside it (the single-file view) — so callers just place it near the + * diff and pass positioning classes. It self-hides when the file's content + * does not overflow horizontally, so wrapped and narrow diffs are + * unaffected. + * + * `scrollWidth` is POLLED rather than observed: Pierre renders into shadow + * roots, and shadow-content growth changes the scrollable overflow area + * without ever resizing an observable border box. + */ + +const POLL_MS = 200; + +/** + * Resolves the `diffs-container` this bar belongs to. Walks up from its own + * position (crossing shadow boundaries, since Pierre portals the all-files + * header into the item's shadow root) and, at each level, accepts either the + * ancestor itself or a single `diffs-container` beneath it — which is how the + * single-file view, where the bar is a sibling of the diff, resolves. + */ +function findDiffsHost(start: Node | null): HTMLElement | null { + const isHost = (node: Node | null): node is HTMLElement => + node instanceof HTMLElement && node.tagName.toLowerCase() === 'diffs-container'; + + let node: Node | null = start; + while (node) { + if (isHost(node)) return node; + if (node instanceof Element || node instanceof ShadowRoot) { + const nested = node.querySelectorAll('diffs-container'); + // Only unambiguous when this subtree renders exactly one file; the + // all-files surface resolves earlier, by ancestry. + if (nested.length === 1) return nested[0] as HTMLElement; + } + const parent: Node | null = node.parentNode; + node = parent instanceof ShadowRoot ? parent.host : parent; + } + return null; +} + +/** + * The horizontal scroller for a Pierre file host: the first `[data-code]` in + * its shadow root that actually overflows. In split + scroll mode there are + * two (deletions / additions) and Pierre's own scrollSyncManager keeps them + * in step, so driving the first is enough. + */ +function findOverflowingCodeEl(host: HTMLElement): HTMLElement | null { + const candidates = host.shadowRoot?.querySelectorAll('[data-code]'); + if (!candidates) return null; + for (const el of Array.from(candidates)) { + if (el.scrollWidth > el.clientWidth + 1) return el; + } + return null; +} + +export const DiffHScrollbar: React.FC<{ className?: string }> = ({ className = '' }) => { + const trackRef = useRef(null); + const scrollerRef = useRef(null); + const [metrics, setMetrics] = useState<{ scrollWidth: number; clientWidth: number } | null>(null); + const [thumbLeft, setThumbLeft] = useState(0); + const dragStateRef = useRef<{ pointerId: number; grabOffset: number; maxLeft: number } | null>(null); + + useEffect(() => { + let raf = 0; + const measure = () => { + cancelAnimationFrame(raf); + raf = requestAnimationFrame(() => { + const owner = findDiffsHost(trackRef.current); + const scroller = owner ? findOverflowingCodeEl(owner) : null; + scrollerRef.current = scroller; + if (!scroller) { + setMetrics((prev) => (prev === null ? prev : null)); + return; + } + const { scrollWidth, clientWidth } = scroller; + setMetrics((prev) => + prev && prev.scrollWidth === scrollWidth && prev.clientWidth === clientWidth + ? prev + : { scrollWidth, clientWidth }, + ); + }); + }; + + measure(); + const interval = setInterval(measure, POLL_MS); + window.addEventListener('resize', measure); + return () => { + cancelAnimationFrame(raf); + clearInterval(interval); + window.removeEventListener('resize', measure); + }; + }, []); + + // Follow the scroller's own horizontal scroll (keyboard, trackpad gesture, + // programmatic scrollTo). + useEffect(() => { + const scroller = scrollerRef.current; + const track = trackRef.current; + if (!metrics || !scroller || !track) return; + + const sync = () => { + const maxScroll = scroller.scrollWidth - scroller.clientWidth; + const trackWidth = track.clientWidth; + const thumbWidth = (scroller.clientWidth / scroller.scrollWidth) * trackWidth; + const maxLeft = trackWidth - thumbWidth; + const next = maxScroll > 0 ? (scroller.scrollLeft / maxScroll) * maxLeft : 0; + // Skip no-op writes — thumbLeft drives scrollLeft in the drag path, + // and echoing an unchanged value back would create a feedback cycle. + setThumbLeft((prev) => (Math.abs(prev - next) > 0.5 ? next : prev)); + }; + + sync(); + scroller.addEventListener('scroll', sync, { passive: true }); + return () => scroller.removeEventListener('scroll', sync); + }, [metrics]); + + const applyScroll = useCallback((nextThumbLeft: number, maxLeft: number) => { + const scroller = scrollerRef.current; + if (!scroller) return; + const clamped = Math.max(0, Math.min(maxLeft, nextThumbLeft)); + setThumbLeft(clamped); + const maxScroll = scroller.scrollWidth - scroller.clientWidth; + scroller.scrollLeft = maxLeft > 0 ? (clamped / maxLeft) * maxScroll : 0; + }, []); + + const thumbWidthFor = (trackWidth: number) => + metrics ? (metrics.clientWidth / metrics.scrollWidth) * trackWidth : 0; + + const onThumbPointerDown = (e: React.PointerEvent) => { + const track = trackRef.current; + if (!track || !metrics) return; + e.preventDefault(); + e.stopPropagation(); + const trackWidth = track.clientWidth; + dragStateRef.current = { + pointerId: e.pointerId, + grabOffset: e.clientX - track.getBoundingClientRect().left - thumbLeft, + maxLeft: trackWidth - thumbWidthFor(trackWidth), + }; + e.currentTarget.setPointerCapture(e.pointerId); + }; + + const onThumbPointerMove = (e: React.PointerEvent) => { + const drag = dragStateRef.current; + const track = trackRef.current; + if (!drag || drag.pointerId !== e.pointerId || !track) return; + applyScroll(e.clientX - track.getBoundingClientRect().left - drag.grabOffset, drag.maxLeft); + }; + + const onThumbPointerUp = (e: React.PointerEvent) => { + if (dragStateRef.current?.pointerId !== e.pointerId) return; + dragStateRef.current = null; + e.currentTarget.releasePointerCapture(e.pointerId); + }; + + const onTrackPointerDown = (e: React.PointerEvent) => { + // Page-jump on track click, like a native scrollbar. + if (e.target !== e.currentTarget || !metrics) return; + const trackWidth = e.currentTarget.clientWidth; + const thumbWidth = thumbWidthFor(trackWidth); + const clickX = e.clientX - e.currentTarget.getBoundingClientRect().left; + applyScroll(clickX - thumbWidth / 2, trackWidth - thumbWidth); + }; + + // Always mounted (it has to be in the DOM to find its own target), but it + // only paints once the file's content actually overflows. + const thumbWidthPct = metrics ? (metrics.clientWidth / metrics.scrollWidth) * 100 : 0; + + return ( +