Skip to content
Merged
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
190 changes: 121 additions & 69 deletions src/ui/components/panes/DiffPane.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,21 @@
import type { ScrollBoxRenderable } from "@opentui/core";
import { useCallback, useEffect, useLayoutEffect, useMemo, useState, type RefObject } from "react";
import {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
type RefObject,
} from "react";
import type { DiffFile, LayoutMode } from "../../../core/types";
import type { VisibleAgentNote } from "../../lib/agentAnnotations";
import { measureDiffSectionMetrics } from "../../lib/sectionHeights";
import { diffHunkId, diffSectionId } from "../../lib/ids";
import type { AppTheme } from "../../themes";
import { DiffSection } from "./DiffSection";
import { DiffSectionPlaceholder } from "./DiffSectionPlaceholder";
import { VerticalScrollbar, type VerticalScrollbarHandle } from "../scrollbar/VerticalScrollbar";

const EMPTY_VISIBLE_AGENT_NOTES: VisibleAgentNote[] = [];

Expand Down Expand Up @@ -120,12 +129,20 @@ export function DiffPane({
// other files can still use placeholders and viewport windowing.
const windowingEnabled = !wrapLines;
const [scrollViewport, setScrollViewport] = useState({ top: 0, height: 0 });
const scrollbarRef = useRef<VerticalScrollbarHandle>(null);
const prevScrollTopRef = useRef(0);

useEffect(() => {
const updateViewport = () => {
const nextTop = scrollRef.current?.scrollTop ?? 0;
const nextHeight = scrollRef.current?.viewport.height ?? 0;

// Detect scroll activity and show scrollbar
if (nextTop !== prevScrollTopRef.current) {
scrollbarRef.current?.show();
prevScrollTopRef.current = nextTop;
}

setScrollViewport((current) =>
current.top === nextTop && current.height === nextHeight
? current
Expand Down Expand Up @@ -218,6 +235,22 @@ export function DiffPane({
[sectionMetrics],
);

// Calculate total content height including separators and headers
const totalContentHeight = useMemo(() => {
let total = 0;
for (let index = 0; index < files.length; index += 1) {
// Separator between files (except first)
if (index > 0) {
total += 1;
}
// File header
total += 1;
// File body
total += estimatedBodyHeights[index] ?? 0;
}
return total;
}, [files.length, estimatedBodyHeights]);

const visibleWindowedFileIds = useMemo(() => {
if (!windowingEnabled) {
return null;
Expand Down Expand Up @@ -310,6 +343,14 @@ export function DiffPane({
wrapLines,
]);

// Configure scroll step size to scroll exactly 1 line per step
useEffect(() => {
const scrollBox = scrollRef.current;
if (scrollBox) {
scrollBox.verticalScrollBar.scrollStep = 1;
}
}, [scrollRef]);

return (
<box
style={{
Expand All @@ -323,74 +364,85 @@ export function DiffPane({
}}
>
{files.length > 0 ? (
<scrollbox
ref={scrollRef}
width="100%"
height="100%"
scrollY={true}
viewportCulling={true}
focused={pagerMode}
rootOptions={{ backgroundColor: theme.panel }}
wrapperOptions={{ backgroundColor: theme.panel }}
viewportOptions={{ backgroundColor: theme.panel }}
contentOptions={{ backgroundColor: theme.panel }}
verticalScrollbarOptions={{ visible: false }}
horizontalScrollbarOptions={{ visible: false }}
>
<box style={{ width: "100%", flexDirection: "column", overflow: "visible" }}>
{files.map((file, index) => {
const shouldRenderSection = visibleWindowedFileIds?.has(file.id) ?? true;
const shouldPrefetchVisibleHighlight =
Boolean(selectedHighlightKey) &&
prefetchAnchorKey === selectedHighlightKey &&
visibleViewportFileIds.has(file.id);

return shouldRenderSection ? (
<DiffSection
key={file.id}
file={file}
headerLabelWidth={headerLabelWidth}
headerStatsWidth={headerStatsWidth}
layout={layout}
selected={file.id === selectedFileId}
selectedHunkIndex={file.id === selectedFileId ? selectedHunkIndex : -1}
shouldLoadHighlight={
file.id === selectedFileId ||
adjacentPrefetchFileIds.has(file.id) ||
shouldPrefetchVisibleHighlight
}
onHighlightReady={
file.id === selectedFileId ? handleSelectedHighlightReady : undefined
}
separatorWidth={separatorWidth}
showSeparator={index > 0}
showLineNumbers={showLineNumbers}
showHunkHeaders={showHunkHeaders}
wrapLines={wrapLines}
theme={theme}
viewWidth={diffContentWidth}
visibleAgentNotes={
visibleAgentNotesByFile.get(file.id) ?? EMPTY_VISIBLE_AGENT_NOTES
}
onOpenAgentNotesAtHunk={(hunkIndex) => onOpenAgentNotesAtHunk(file.id, hunkIndex)}
onSelect={() => onSelectFile(file.id)}
/>
) : (
<DiffSectionPlaceholder
key={file.id}
bodyHeight={estimatedBodyHeights[index] ?? 0}
file={file}
headerLabelWidth={headerLabelWidth}
headerStatsWidth={headerStatsWidth}
separatorWidth={separatorWidth}
showSeparator={index > 0}
theme={theme}
onSelect={() => onSelectFile(file.id)}
/>
);
})}
</box>
</scrollbox>
<box style={{ position: "relative", width: "100%", height: "100%", flexGrow: 1 }}>
<scrollbox
ref={scrollRef}
width="100%"
height="100%"
scrollY={true}
viewportCulling={true}
focused={pagerMode}
rootOptions={{ backgroundColor: theme.panel }}
wrapperOptions={{ backgroundColor: theme.panel }}
viewportOptions={{ backgroundColor: theme.panel }}
contentOptions={{ backgroundColor: theme.panel }}
verticalScrollbarOptions={{ visible: false }}
horizontalScrollbarOptions={{ visible: false }}
>
<box style={{ width: "100%", flexDirection: "column", overflow: "visible" }}>
{files.map((file, index) => {
const shouldRenderSection = visibleWindowedFileIds?.has(file.id) ?? true;
const shouldPrefetchVisibleHighlight =
Boolean(selectedHighlightKey) &&
prefetchAnchorKey === selectedHighlightKey &&
visibleViewportFileIds.has(file.id);

return shouldRenderSection ? (
<DiffSection
key={file.id}
file={file}
headerLabelWidth={headerLabelWidth}
headerStatsWidth={headerStatsWidth}
layout={layout}
selected={file.id === selectedFileId}
selectedHunkIndex={file.id === selectedFileId ? selectedHunkIndex : -1}
shouldLoadHighlight={
file.id === selectedFileId ||
adjacentPrefetchFileIds.has(file.id) ||
shouldPrefetchVisibleHighlight
}
onHighlightReady={
file.id === selectedFileId ? handleSelectedHighlightReady : undefined
}
separatorWidth={separatorWidth}
showSeparator={index > 0}
showLineNumbers={showLineNumbers}
showHunkHeaders={showHunkHeaders}
wrapLines={wrapLines}
theme={theme}
viewWidth={diffContentWidth}
visibleAgentNotes={
visibleAgentNotesByFile.get(file.id) ?? EMPTY_VISIBLE_AGENT_NOTES
}
onOpenAgentNotesAtHunk={(hunkIndex) =>
onOpenAgentNotesAtHunk(file.id, hunkIndex)
}
onSelect={() => onSelectFile(file.id)}
/>
) : (
<DiffSectionPlaceholder
key={file.id}
bodyHeight={estimatedBodyHeights[index] ?? 0}
file={file}
headerLabelWidth={headerLabelWidth}
headerStatsWidth={headerStatsWidth}
separatorWidth={separatorWidth}
showSeparator={index > 0}
theme={theme}
onSelect={() => onSelectFile(file.id)}
/>
);
})}
</box>
</scrollbox>
<VerticalScrollbar
ref={scrollbarRef}
scrollRef={scrollRef}
contentHeight={totalContentHeight}
height={scrollViewport.height}
theme={theme}
/>
</box>
) : (
<box style={{ flexGrow: 1, alignItems: "center", justifyContent: "center" }}>
<text fg={theme.muted}>No files match the current filter.</text>
Expand Down
163 changes: 163 additions & 0 deletions src/ui/components/scrollbar/VerticalScrollbar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import type { MouseEvent as TuiMouseEvent } from "@opentui/core";
import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useRef,
useState,
type RefObject,
} from "react";
import type { AppTheme } from "../../themes";

const HIDE_DELAY_MS = 2000;
const SCROLLBAR_WIDTH = 1;

export interface VerticalScrollbarHandle {
show: () => void;
}

interface VerticalScrollbarProps {
scrollRef: RefObject<{
scrollTop: number;
scrollTo: (y: number) => void;
viewport: { height: number };
} | null>;
contentHeight: number;
theme: AppTheme;
height: number;
onActivity?: () => void;
}

export const VerticalScrollbar = forwardRef<VerticalScrollbarHandle, VerticalScrollbarProps>(
function VerticalScrollbar({ scrollRef, contentHeight, theme, height, onActivity }, ref) {
const [isVisible, setIsVisible] = useState(false);
const [isDragging, setIsDragging] = useState(false);
const [dragStartY, setDragStartY] = useState(0);
const [dragStartScroll, setDragStartScroll] = useState(0);
const hideTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);

const show = useCallback(() => {
setIsVisible(true);
if (hideTimeoutRef.current) {
clearTimeout(hideTimeoutRef.current);
}
hideTimeoutRef.current = setTimeout(() => {
if (!isDragging) {
setIsVisible(false);
}
}, HIDE_DELAY_MS);
onActivity?.();
}, [isDragging, onActivity]);

useImperativeHandle(ref, () => ({ show }), [show]);

useEffect(() => {
return () => {
if (hideTimeoutRef.current) {
clearTimeout(hideTimeoutRef.current);
}
};
}, []);

// Don't show if content fits in viewport
const viewportHeight = height;
const shouldShow = contentHeight > viewportHeight && isVisible;

// Calculate thumb metrics
const trackHeight = viewportHeight;
const scrollRatio = viewportHeight / contentHeight;
const thumbHeight = Math.max(SCROLLBAR_WIDTH, Math.floor(trackHeight * scrollRatio));
const maxThumbY = trackHeight - thumbHeight;

const scrollTop = scrollRef.current?.scrollTop ?? 0;
const maxScroll = contentHeight - viewportHeight;
const scrollPercent = maxScroll > 0 ? scrollTop / maxScroll : 0;
const thumbY = Math.floor(scrollPercent * maxThumbY);

const handleMouseDown = (event: TuiMouseEvent) => {
if (event.button !== 0) return;

const currentScrollTop = scrollRef.current?.scrollTop ?? 0;
setIsDragging(true);
setDragStartY(event.y);
setDragStartScroll(currentScrollTop);
show();
event.preventDefault();
event.stopPropagation();
};

const handleMouseDrag = (event: TuiMouseEvent) => {
if (!isDragging) return;

const deltaY = event.y - dragStartY;
const pixelsPerRow = maxThumbY / maxScroll;
const scrollDelta = deltaY / pixelsPerRow;
const newScrollTop = Math.max(0, Math.min(maxScroll, dragStartScroll + scrollDelta));

scrollRef.current?.scrollTo(newScrollTop);
show();
event.preventDefault();
event.stopPropagation();
};

const handleMouseUp = (event?: TuiMouseEvent) => {
if (!isDragging) return;
setIsDragging(false);
// Restart hide timer
if (hideTimeoutRef.current) {
clearTimeout(hideTimeoutRef.current);
}
hideTimeoutRef.current = setTimeout(() => {
setIsVisible(false);
}, HIDE_DELAY_MS);
event?.preventDefault();
event?.stopPropagation();
};

if (!shouldShow) {
return null;
}

return (
<box
style={{
position: "absolute",
top: 0,
right: 0,
width: SCROLLBAR_WIDTH,
height: trackHeight,
backgroundColor: theme.panel,
zIndex: 10,
}}
>
{/* Track background */}
<box
style={{
position: "absolute",
top: 0,
left: 0,
width: SCROLLBAR_WIDTH,
height: trackHeight,
backgroundColor: theme.border,
}}
/>
{/* Thumb */}
<box
style={{
position: "absolute",
top: thumbY,
left: 0,
width: SCROLLBAR_WIDTH,
height: thumbHeight,
backgroundColor: isDragging ? theme.accent : theme.accentMuted,
}}
onMouseDown={handleMouseDown}
onMouseDrag={handleMouseDrag}
onMouseUp={handleMouseUp}
onMouseDragEnd={handleMouseUp}
/>
</box>
);
},
);
Loading
Loading