Skip to content
Open
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
6 changes: 3 additions & 3 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 11 additions & 1 deletion packages/review-editor/components/AllFilesCodeView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -2125,7 +2126,7 @@ export const AllFilesCodeView: React.FC<AllFilesCodeViewProps> = ({
: null;

return (
<div className="flex flex-col">
<div className="relative flex flex-col">
<FileHeader
filePath={filePath}
patch={file.patch}
Expand Down Expand Up @@ -2217,6 +2218,15 @@ export const AllFilesCodeView: React.FC<AllFilesCodeViewProps> = ({
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' && (
<DiffHScrollbar className="absolute inset-x-2 bottom-0 z-30" />
)}
</div>
);
});
Expand Down
202 changes: 202 additions & 0 deletions packages/review-editor/components/DiffHScrollbar.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLElement>('[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<HTMLDivElement>(null);
const scrollerRef = useRef<HTMLElement | null>(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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
if (dragStateRef.current?.pointerId !== e.pointerId) return;
dragStateRef.current = null;
e.currentTarget.releasePointerCapture(e.pointerId);
};

const onTrackPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
// 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 (
<div
ref={trackRef}
data-diff-hscrollbar="true"
aria-hidden="true"
onPointerDown={onTrackPointerDown}
className={`h-1.5 ${metrics ? 'cursor-default' : 'pointer-events-none'} ${className}`}
>
{metrics && (
<div
className="absolute top-0 h-full rounded-full bg-muted-foreground/40 transition-[background-color] hover:bg-muted-foreground/70"
style={{ left: thumbLeft, width: `${thumbWidthPct}%` }}
onPointerDown={onThumbPointerDown}
onPointerMove={onThumbPointerMove}
onPointerUp={onThumbPointerUp}
onPointerCancel={onThumbPointerUp}
/>
)}
</div>
);
};
8 changes: 8 additions & 0 deletions packages/review-editor/components/DiffViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { useOverlayViewport } from '@plannotator/ui/hooks/useOverlayViewport';
import { FileHeader } from './FileHeader';
import { BinaryFileNotice } from './BinaryFileNotice';
import { FileCommentBanner } from './FileCommentBanner';
import { DiffHScrollbar } from './DiffHScrollbar';
import { OversizedFileNotice } from './OversizedFileNotice';
import { isContentlessBinaryPatch, isOversizedReviewStubPatch } from '@plannotator/shared/diff-paths';
import { isFileScopedAnnotation, lineRangeForAnnotation } from '../utils/annotationScope';
Expand Down Expand Up @@ -709,6 +710,9 @@ export const DiffViewer: React.FC<DiffViewerProps> = ({

return (
<div className="h-full flex flex-col">
{/* `relative` anchors this file's horizontal scrollbar to the header's
bottom edge (#1048) — same placement as the all-files surface. */}
<div className="relative flex-none">
<FileHeader
filePath={filePath}
patch={patch}
Expand All @@ -735,6 +739,10 @@ export const DiffViewer: React.FC<DiffViewerProps> = ({
stageError={stageError}
onFileComment={setFileCommentAnchor}
/>
{!collapsed && diffOverflow !== 'wrap' && (
<DiffHScrollbar className="absolute inset-x-2 bottom-0 z-30" />
)}
</div>

{!collapsed && <OverlayScrollArea
className={`flex-1 min-h-0 relative ${isDraggingSplit ? 'select-none' : ''}`}
Expand Down
7 changes: 7 additions & 0 deletions packages/review-editor/hooks/usePierreTheme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,13 @@ export function usePierreTheme(options?: { fontFamily?: string; fontSize?: strin
[data-diff-type='split'][data-overflow='scroll'] > [data-code][data-additions] [data-content] {
min-width: 0 !important;
}
/* Pierre's own horizontal scrollbar sits at the bottom of each
file's content, which is off-screen on any diff taller than the
panel (#1048). DiffHScrollbar replaces it, pinned to the file
header, so suppress this one. Height only — the vertical width
is already 0 upstream. */
[data-code] { scrollbar-width: none !important; }
[data-code]::-webkit-scrollbar { height: 0 !important; }
.pn-token-hover {
text-decoration: underline;
text-decoration-color: ${primary || 'oklch(0.70 0.20 280)'};
Expand Down