From 5baa9041426c0ae1744c8c65150873a839354f4d Mon Sep 17 00:00:00 2001 From: Ammar Date: Fri, 12 Dec 2025 19:22:33 -0600 Subject: [PATCH 1/4] =?UTF-8?q?=F0=9F=A4=96=20fix:=20drag-select=20review?= =?UTF-8?q?=20diff=20lines=20via=20indicator?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/shared/DiffRenderer.tsx | 82 ++++++++++++++++++- ...SelectableDiffRenderer.dragSelect.test.tsx | 71 ++++++++++++++++ 2 files changed, 150 insertions(+), 3 deletions(-) create mode 100644 src/browser/components/shared/SelectableDiffRenderer.dragSelect.test.tsx diff --git a/src/browser/components/shared/DiffRenderer.tsx b/src/browser/components/shared/DiffRenderer.tsx index 3b02316c1..9c1773211 100644 --- a/src/browser/components/shared/DiffRenderer.tsx +++ b/src/browser/components/shared/DiffRenderer.tsx @@ -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; + onMouseEnter?: React.MouseEventHandler; + isInteractive?: boolean; + lineIndex?: number; } -const DiffIndicator: React.FC = ({ type, reviewButton }) => ( - +const DiffIndicator: React.FC = ({ + type, + reviewButton, + onMouseDown, + onMouseEnter, + isInteractive, + lineIndex, +}) => ( + ( searchConfig, enableHighlighting = true, }) => { + const dragAnchorRef = React.useRef(null); + const isDraggingRef = React.useRef(false); + + React.useEffect(() => { + const stopDragging = () => { + isDraggingRef.current = 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(null); @@ -742,6 +780,31 @@ export const SelectableDiffRenderer = React.memo( }; }, [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; + isDraggingRef.current = true; + setSelection({ startIndex: anchor, endIndex: lineIndex }); + }, + [onLineClick, onReviewNote, selection] + ); + + const updateDragSelection = React.useCallback((lineIndex: number) => { + if (!isDraggingRef.current || dragAnchorRef.current === null) { + return; + } + + setSelection({ startIndex: dragAnchorRef.current, endIndex: lineIndex }); + }, []); + const handleCommentButtonClick = (lineIndex: number, shiftKey: boolean) => { // Notify parent that this hunk should become active onLineClick?.(); @@ -819,6 +882,19 @@ export const SelectableDiffRenderer = React.memo( > { + if (!onReviewNote) return; + if (e.button !== 0) return; + e.preventDefault(); + e.stopPropagation(); + startDragSelection(displayIndex, e.shiftKey); + }} + onMouseEnter={() => { + if (!onReviewNote) return; + updateDragSelection(displayIndex); + }} reviewButton={ onReviewNote && ( @@ -837,7 +913,7 @@ export const SelectableDiffRenderer = React.memo( Add review comment
- (Shift-click to select range) + (Shift-click or drag to select range)
) diff --git a/src/browser/components/shared/SelectableDiffRenderer.dragSelect.test.tsx b/src/browser/components/shared/SelectableDiffRenderer.dragSelect.test.tsx new file mode 100644 index 000000000..88c6875c5 --- /dev/null +++ b/src/browser/components/shared/SelectableDiffRenderer.dragSelect.test.tsx @@ -0,0 +1,71 @@ +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 { 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( + + + + ); + + await waitFor(() => { + const indicators = container.querySelectorAll('[data-diff-indicator="true"]'); + expect(indicators.length).toBe(3); + }); + + const indicators = Array.from( + container.querySelectorAll('[data-diff-indicator="true"]') + ); + + fireEvent.mouseDown(indicators[0], { button: 0 }); + fireEvent.mouseEnter(indicators[2]); + fireEvent.mouseUp(window); + + const textarea = getByPlaceholderText(/Add a review note/i); + fireEvent.change(textarea, { target: { value: "please review" } }); + fireEvent.keyDown(textarea, { key: "Enter" }); + + await waitFor(() => { + expect(onReviewNote).toHaveBeenCalledTimes(1); + }); + + const callArg = onReviewNote.mock.calls[0]?.[0] as { + selectedDiff?: string; + userNote?: string; + lineRange?: string; + }; + + expect(callArg.userNote).toBe("please review"); + expect(callArg.lineRange).toBe("+1-3"); + expect(callArg.selectedDiff).toBe(content); + }); +}); From 4c440f4868aaa104c2f8139e3dc864ca5ff9de6a Mon Sep 17 00:00:00 2001 From: Ammar Date: Sat, 13 Dec 2025 19:50:14 -0600 Subject: [PATCH 2/4] =?UTF-8?q?=F0=9F=A4=96=20test:=20stabilize=20drag-sel?= =?UTF-8?q?ect=20coverage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/shared/DiffRenderer.tsx | 1 + ...SelectableDiffRenderer.dragSelect.test.tsx | 44 ++++++++++--------- 2 files changed, 25 insertions(+), 20 deletions(-) diff --git a/src/browser/components/shared/DiffRenderer.tsx b/src/browser/components/shared/DiffRenderer.tsx index 9c1773211..ff4ca9838 100644 --- a/src/browser/components/shared/DiffRenderer.tsx +++ b/src/browser/components/shared/DiffRenderer.tsx @@ -860,6 +860,7 @@ export const SelectableDiffRenderer = React.memo(
{ @@ -27,13 +28,15 @@ describe("SelectableDiffRenderer drag selection", () => { const { container, getByPlaceholderText } = render( - + + + ); @@ -50,22 +53,23 @@ describe("SelectableDiffRenderer drag selection", () => { fireEvent.mouseEnter(indicators[2]); fireEvent.mouseUp(window); - const textarea = getByPlaceholderText(/Add a review note/i); - fireEvent.change(textarea, { target: { value: "please review" } }); - fireEvent.keyDown(textarea, { key: "Enter" }); + const textarea = (await waitFor(() => + getByPlaceholderText(/Add a review note/i) + )) as HTMLTextAreaElement; await waitFor(() => { - expect(onReviewNote).toHaveBeenCalledTimes(1); - }); + const selectedLines = Array.from( + container.querySelectorAll('.selectable-diff-line[data-selected="true"]') + ); + expect(selectedLines.length).toBe(3); - const callArg = onReviewNote.mock.calls[0]?.[0] as { - selectedDiff?: string; - userNote?: string; - lineRange?: string; - }; + const allLines = Array.from(container.querySelectorAll(".selectable-diff-line")); + expect(allLines.length).toBe(3); - expect(callArg.userNote).toBe("please review"); - expect(callArg.lineRange).toBe("+1-3"); - expect(callArg.selectedDiff).toBe(content); + // Input should render *after* the last selected line (line 2). + const inputWrapper = allLines[2]?.nextElementSibling; + expect(inputWrapper).toBeTruthy(); + expect(inputWrapper?.querySelector("textarea")).toBe(textarea); + }); }); }); From 9e7e8c5ec4b65bed81f1165151522119a07b4b2e Mon Sep 17 00:00:00 2001 From: Ammar Date: Sat, 13 Dec 2025 20:02:23 -0600 Subject: [PATCH 3/4] =?UTF-8?q?=F0=9F=A4=96=20fix:=20hide=20tooltip=20duri?= =?UTF-8?q?ng=20drag=20selection?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/browser/components/shared/DiffRenderer.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/browser/components/shared/DiffRenderer.tsx b/src/browser/components/shared/DiffRenderer.tsx index ff4ca9838..d5474303c 100644 --- a/src/browser/components/shared/DiffRenderer.tsx +++ b/src/browser/components/shared/DiffRenderer.tsx @@ -898,7 +898,7 @@ export const SelectableDiffRenderer = React.memo( }} reviewButton={ onReviewNote && ( - +