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
88 changes: 84 additions & 4 deletions src/browser/components/shared/DiffRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -175,10 +175,31 @@ interface DiffIndicatorProps {
type: DiffLineType;
/** Render review button overlay on hover */
reviewButton?: React.ReactNode;
/** When provided, enables drag-to-select behavior in SelectableDiffRenderer */
onMouseDown?: React.MouseEventHandler<HTMLSpanElement>;
onMouseEnter?: React.MouseEventHandler<HTMLSpanElement>;
isInteractive?: boolean;
lineIndex?: number;
}

const DiffIndicator: React.FC<DiffIndicatorProps> = ({ type, reviewButton }) => (
<span className="relative inline-block w-4 shrink-0 text-center select-none">
const DiffIndicator: React.FC<DiffIndicatorProps> = ({
type,
reviewButton,
onMouseDown,
onMouseEnter,
isInteractive,
lineIndex,
}) => (
<span
data-diff-indicator={true}
data-line-index={lineIndex}
className={cn(
"relative inline-block w-4 shrink-0 text-center select-none",
isInteractive && "cursor-pointer"
)}
onMouseDown={onMouseDown}
onMouseEnter={onMouseEnter}
>
<span
className={cn("transition-opacity", reviewButton && "group-hover:opacity-0")}
style={{ color: getIndicatorColor(type) }}
Expand Down Expand Up @@ -657,6 +678,23 @@ export const SelectableDiffRenderer = React.memo<SelectableDiffRendererProps>(
searchConfig,
enableHighlighting = true,
}) => {
const dragAnchorRef = React.useRef<number | null>(null);
const [isDragging, setIsDragging] = React.useState(false);

React.useEffect(() => {
const stopDragging = () => {
setIsDragging(false);
dragAnchorRef.current = null;
};

window.addEventListener("mouseup", stopDragging);
window.addEventListener("blur", stopDragging);

return () => {
window.removeEventListener("mouseup", stopDragging);
window.removeEventListener("blur", stopDragging);
};
}, []);
const { theme } = useTheme();
const [selection, setSelection] = React.useState<LineSelection | null>(null);

Expand Down Expand Up @@ -742,6 +780,34 @@ export const SelectableDiffRenderer = React.memo<SelectableDiffRendererProps>(
};
}, [lineData, showLineNumbers]);

const startDragSelection = React.useCallback(
(lineIndex: number, shiftKey: boolean) => {
if (!onReviewNote) {
return;
}

// Notify parent that this hunk should become active
onLineClick?.();

const anchor = shiftKey && selection ? selection.startIndex : lineIndex;
dragAnchorRef.current = anchor;
setIsDragging(true);
setSelection({ startIndex: anchor, endIndex: lineIndex });
},
[onLineClick, onReviewNote, selection]
);

const updateDragSelection = React.useCallback(
(lineIndex: number) => {
if (!isDragging || dragAnchorRef.current === null) {
return;
}

setSelection({ startIndex: dragAnchorRef.current, endIndex: lineIndex });
},
[isDragging]
);

const handleCommentButtonClick = (lineIndex: number, shiftKey: boolean) => {
// Notify parent that this hunk should become active
onLineClick?.();
Expand Down Expand Up @@ -797,6 +863,7 @@ export const SelectableDiffRenderer = React.memo<SelectableDiffRendererProps>(
<React.Fragment key={displayIndex}>
<div
className={cn(SELECTABLE_DIFF_LINE_CLASS, "flex w-full relative cursor-text group")}
data-selected={isSelected ? "true" : "false"}
style={{
background: isSelected
? "hsl(from var(--color-review-accent) h s l / 0.16)"
Expand All @@ -819,9 +886,22 @@ export const SelectableDiffRenderer = React.memo<SelectableDiffRendererProps>(
>
<DiffIndicator
type={lineInfo.type}
lineIndex={displayIndex}
isInteractive={Boolean(onReviewNote)}
onMouseDown={(e) => {
if (!onReviewNote) return;
if (e.button !== 0) return;
e.preventDefault();
e.stopPropagation();
startDragSelection(displayIndex, e.shiftKey);
}}
onMouseEnter={() => {
if (!onReviewNote) return;
updateDragSelection(displayIndex);
}}
reviewButton={
onReviewNote && (
<Tooltip>
<Tooltip {...(selection || isDragging ? { open: false } : {})}>
<TooltipTrigger asChild>
<button
className="pointer-events-none absolute inset-0 flex items-center justify-center rounded-sm text-[var(--color-review-accent)]/60 opacity-0 transition-opacity group-hover:pointer-events-auto group-hover:opacity-100 hover:text-[var(--color-review-accent)] active:scale-90"
Expand All @@ -837,7 +917,7 @@ export const SelectableDiffRenderer = React.memo<SelectableDiffRendererProps>(
<TooltipContent side="bottom" align="start">
Add review comment
<br />
(Shift-click to select range)
(Shift-click or drag to select range)
</TooltipContent>
</Tooltip>
)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import React from "react";
import { afterEach, beforeEach, describe, expect, mock, test, type Mock } from "bun:test";
import { GlobalWindow } from "happy-dom";
import { cleanup, fireEvent, render, waitFor } from "@testing-library/react";

import { ThemeProvider } from "@/browser/contexts/ThemeContext";
import { TooltipProvider } from "@/browser/components/ui/tooltip";
import { SelectableDiffRenderer } from "./DiffRenderer";

describe("SelectableDiffRenderer drag selection", () => {
let onReviewNote: Mock<(data: unknown) => void>;

beforeEach(() => {
globalThis.window = new GlobalWindow() as unknown as Window & typeof globalThis;
globalThis.document = globalThis.window.document;

onReviewNote = mock(() => undefined);
});

afterEach(() => {
cleanup();
globalThis.window = undefined as unknown as Window & typeof globalThis;
globalThis.document = undefined as unknown as Document;
});

test("dragging on the indicator column selects a line range", async () => {
const content = "+const a = 1;\n+const b = 2;\n+const c = 3;";

const { container, getByPlaceholderText } = render(
<ThemeProvider forcedTheme="dark">
<TooltipProvider>
<SelectableDiffRenderer
content={content}
filePath="src/test.ts"
onReviewNote={onReviewNote}
maxHeight="none"
enableHighlighting={false}
/>
</TooltipProvider>
</ThemeProvider>
);

await waitFor(() => {
const indicators = container.querySelectorAll('[data-diff-indicator="true"]');
expect(indicators.length).toBe(3);
});

const indicators = Array.from(
container.querySelectorAll<HTMLSpanElement>('[data-diff-indicator="true"]')
);

fireEvent.mouseDown(indicators[0], { button: 0 });
fireEvent.mouseEnter(indicators[2]);
fireEvent.mouseUp(window);

const textarea = (await waitFor(() =>
getByPlaceholderText(/Add a review note/i)
)) as HTMLTextAreaElement;

await waitFor(() => {
const selectedLines = Array.from(
container.querySelectorAll<HTMLElement>('.selectable-diff-line[data-selected="true"]')
);
expect(selectedLines.length).toBe(3);

const allLines = Array.from(container.querySelectorAll<HTMLElement>(".selectable-diff-line"));
expect(allLines.length).toBe(3);

// Input should render *after* the last selected line (line 2).
const inputWrapper = allLines[2]?.nextElementSibling;
expect(inputWrapper).toBeTruthy();
expect(inputWrapper?.querySelector("textarea")).toBe(textarea);
});
});
});