Skip to content
Draft
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
Original file line number Diff line number Diff line change
Expand Up @@ -1111,12 +1111,20 @@ export const ImmersiveReviewView: React.FC<ImmersiveReviewViewProps> = (props) =
const anchorIndex = shiftKey
? (selectedLineRangeRef.current?.startIndex ?? activeLineIndexRef.current ?? lineIndex)
: lineIndex;
setActiveLineIndex(lineIndex);
setActiveLineIndex((previousLineIndex) =>
previousLineIndex === lineIndex ? previousLineIndex : lineIndex
);

if (shiftKey) {
setSelectedLineRange({ startIndex: anchorIndex, endIndex: lineIndex });
setSelectedLineRange((previousRange) => {
if (previousRange?.startIndex === anchorIndex && previousRange?.endIndex === lineIndex) {
return previousRange;
}

return { startIndex: anchorIndex, endIndex: lineIndex };
});
} else {
setSelectedLineRange(null);
setSelectedLineRange((previousRange) => (previousRange === null ? previousRange : null));
}

if (isTouchExperience && !shiftKey && resolvedHunk) {
Expand Down
157 changes: 130 additions & 27 deletions src/browser/components/shared/DiffRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,14 @@ const getIndicatorChar = (type: DiffLineType): string => {

const REVIEW_RANGE_TINT = "hsl(from var(--color-review-accent) h s l / 0.08)";

const REVIEW_NOTE_MIN_ROWS = 2;
const REVIEW_NOTE_MAX_ROWS = 8;

const getTextareaRowsFromNoteText = (text: string): number => {
const explicitLineCount = text.split("\n").length;
return Math.min(REVIEW_NOTE_MAX_ROWS, Math.max(REVIEW_NOTE_MIN_ROWS, explicitLineCount));
};

const applyReviewRangeOverlay = (base: string, isActive: boolean): string => {
if (!isActive) return base;
return `linear-gradient(${REVIEW_RANGE_TINT}, ${REVIEW_RANGE_TINT}), ${base}`;
Expand Down Expand Up @@ -706,29 +714,43 @@ const ReviewNoteInput: React.FC<ReviewNoteInputProps> = React.memo(
initialNoteText,
}) => {
const { showOld, showNew } = getLineNumberModeFlags(lineNumberMode);
const [noteText, setNoteText] = React.useState(initialNoteText ?? "");
const textareaRef = React.useRef<HTMLTextAreaElement>(null);

// Auto-focus on mount
React.useEffect(() => {
textareaRef.current?.focus();
// Avoid scrollHeight reads during typing: in large immersive diffs those force full-grid
// layout on each keypress. Row count based on explicit newlines keeps input latency stable.
const syncTextareaRows = React.useCallback((textarea: HTMLTextAreaElement) => {
const nextRows = getTextareaRowsFromNoteText(textarea.value);
if (textarea.rows === nextRows) {
return;
}
textarea.rows = nextRows;
}, []);

// Keep the composer uncontrolled so typing does not trigger per-key React re-renders
// through immersive diff overlays. Parent-initiated prefill changes are synced here.
React.useEffect(() => {
setNoteText(initialNoteText ?? "");
}, [initialNoteText]);
const textarea = textareaRef.current;
if (!textarea) {
return;
}

textarea.value = initialNoteText ?? "";
syncTextareaRows(textarea);
}, [initialNoteText, syncTextareaRows]);

// Auto-expand textarea as user types
// Auto-focus on mount.
React.useEffect(() => {
const textarea = textareaRef.current;
if (!textarea) return;
if (!textarea) {
return;
}

textarea.style.height = "auto";
textarea.style.height = `${textarea.scrollHeight}px`;
}, [noteText]);
textarea.focus();
syncTextareaRows(textarea);
}, [syncTextareaRows]);

const handleSubmit = () => {
const text = textareaRef.current?.value ?? noteText;
const text = textareaRef.current?.value ?? "";
if (!text.trim()) return;

const [start, end] = [selection.startIndex, selection.endIndex].sort((a, b) => a - b);
Expand Down Expand Up @@ -844,13 +866,13 @@ const ReviewNoteInput: React.FC<ReviewNoteInputProps> = React.memo(
/>
<textarea
ref={textareaRef}
className="text-primary placeholder:text-muted/70 min-w-0 flex-1 resize-none overflow-y-hidden bg-transparent px-2 py-1.5 text-[12px] leading-[1.5] transition-colors focus:outline-none"
style={{
minHeight: "calc(12px * 1.5 * 2 + 12px)",
}}
className="text-primary placeholder:text-muted/70 min-w-0 flex-1 resize-none overflow-y-auto bg-transparent px-2 py-1.5 text-[12px] leading-[1.5] transition-colors focus:outline-none"
rows={REVIEW_NOTE_MIN_ROWS}
placeholder="Add a review note… (Enter to submit, Shift+Enter for newline, Esc to cancel)"
value={noteText}
onChange={(e) => setNoteText(e.target.value)}
defaultValue={initialNoteText ?? ""}
onInput={(e) => {
syncTextareaRows(e.currentTarget);
}}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => {
stopKeyboardPropagation(e);
Expand Down Expand Up @@ -984,12 +1006,60 @@ export const SelectableDiffRenderer = React.memo<SelectableDiffRendererProps>(
onComposerCancel,
}) => {
const dragAnchorRef = React.useRef<number | null>(null);
const dragUpdateFrameRef = React.useRef<number | null>(null);
const pendingDragLineIndexRef = React.useRef<number | null>(null);
const [isDragging, setIsDragging] = React.useState(false);
const [selection, setSelection] = React.useState<LineSelection | null>(null);
const [selectionInitialNoteText, setSelectionInitialNoteText] = React.useState("");

const flushPendingDragSelection = React.useCallback(() => {
const anchorIndex = dragAnchorRef.current;
const pendingLineIndex = pendingDragLineIndexRef.current;
if (anchorIndex === null || pendingLineIndex === null) {
return;
}

pendingDragLineIndexRef.current = null;
onLineIndexSelect?.(pendingLineIndex, true);
setSelection((previousSelection) => {
if (
previousSelection?.startIndex === anchorIndex &&
previousSelection?.endIndex === pendingLineIndex
) {
return previousSelection;
}

return { startIndex: anchorIndex, endIndex: pendingLineIndex };
});
}, [onLineIndexSelect]);

const scheduleDragSelectionUpdate = React.useCallback(
(lineIndex: number) => {
pendingDragLineIndexRef.current = lineIndex;

if (dragUpdateFrameRef.current !== null) {
return;
}

dragUpdateFrameRef.current = window.requestAnimationFrame(() => {
dragUpdateFrameRef.current = null;
flushPendingDragSelection();
});
},
[flushPendingDragSelection]
);

React.useEffect(() => {
const stopDragging = () => {
if (dragUpdateFrameRef.current !== null) {
cancelAnimationFrame(dragUpdateFrameRef.current);
dragUpdateFrameRef.current = null;
}

flushPendingDragSelection();
setIsDragging(false);
dragAnchorRef.current = null;
pendingDragLineIndexRef.current = null;
};

window.addEventListener("mouseup", stopDragging);
Expand All @@ -999,10 +1069,17 @@ export const SelectableDiffRenderer = React.memo<SelectableDiffRendererProps>(
window.removeEventListener("mouseup", stopDragging);
window.removeEventListener("blur", stopDragging);
};
}, [flushPendingDragSelection]);

React.useEffect(() => {
return () => {
if (dragUpdateFrameRef.current !== null) {
cancelAnimationFrame(dragUpdateFrameRef.current);
}
};
}, []);

const { theme } = useTheme();
const [selection, setSelection] = React.useState<LineSelection | null>(null);
const [selectionInitialNoteText, setSelectionInitialNoteText] = React.useState("");

const lastExternalSelectionRequestIdRef = React.useRef<number | null>(null);
const dismissedExternalSelectionRequestIdRef = React.useRef<number | null>(null);
Expand Down Expand Up @@ -1207,12 +1284,27 @@ export const SelectableDiffRenderer = React.memo<SelectableDiffRendererProps>(
onLineClick?.();
onLineIndexSelect?.(lineIndex, shiftKey);

if (dragUpdateFrameRef.current !== null) {
cancelAnimationFrame(dragUpdateFrameRef.current);
dragUpdateFrameRef.current = null;
}
pendingDragLineIndexRef.current = null;

const anchor =
shiftKey && renderSelectionStartIndex !== null ? renderSelectionStartIndex : lineIndex;
dragAnchorRef.current = anchor;
setIsDragging(true);
setSelectionInitialNoteText("");
setSelection({ startIndex: anchor, endIndex: lineIndex });
setSelection((previousSelection) => {
if (
previousSelection?.startIndex === anchor &&
previousSelection?.endIndex === lineIndex
) {
return previousSelection;
}

return { startIndex: anchor, endIndex: lineIndex };
});
},
[onLineClick, onLineIndexSelect, onReviewNote, renderSelectionStartIndex]
);
Expand All @@ -1223,10 +1315,11 @@ export const SelectableDiffRenderer = React.memo<SelectableDiffRendererProps>(
return;
}

onLineIndexSelect?.(lineIndex, true);
setSelection({ startIndex: dragAnchorRef.current, endIndex: lineIndex });
// Dragging can emit dozens of mouseenter events per second; coalesce updates
// to one per animation frame so immersive line-range selection stays responsive.
scheduleDragSelectionUpdate(lineIndex);
},
[isDragging, onLineIndexSelect]
[isDragging, scheduleDragSelectionUpdate]
);

const handleCommentButtonClick = (lineIndex: number, shiftKey: boolean) => {
Expand Down Expand Up @@ -1285,6 +1378,14 @@ export const SelectableDiffRenderer = React.memo<SelectableDiffRendererProps>(
const firstLineType = highlightedLineData[0]?.type;
const lastLineType = highlightedLineData[highlightedLineData.length - 1]?.type;

// Keep selectable diff rows free of layout containment styles.
//
// Why: each row is a `grid-cols-subgrid` participant and shares a 3-column
// contract with inline composer/review-note rows. Applying `content-visibility`
// (which implies layout containment) on these rows can break subgrid sizing and
// misalign pending review note layout. By construction we only apply outline
// visuals to row containers; no row-level culling styles are allowed here.

const cursorLikeOutlineColor = "hsl(from var(--color-review-accent) h s l / 0.45)";
const normalizedSelectedLineRange = selectedLineRange
? {
Expand All @@ -1298,7 +1399,9 @@ export const SelectableDiffRenderer = React.memo<SelectableDiffRendererProps>(
isLineInSelection(index, renderSelection) ||
isLineInSelection(index, normalizedSelectedLineRange);

const getCursorLikeOutlineStyle = (index: number): React.CSSProperties | undefined => {
type DiffRowRenderStyle = Pick<React.CSSProperties, "boxShadow">;

const getCursorLikeOutlineStyle = (index: number): DiffRowRenderStyle | undefined => {
if (!isCursorHighlightedLine(index)) {
return undefined;
}
Expand Down Expand Up @@ -1328,7 +1431,7 @@ export const SelectableDiffRenderer = React.memo<SelectableDiffRendererProps>(
{highlightedLineData.map((lineInfo, displayIndex) => {
const isComposerSelected = isLineInSelection(displayIndex, renderSelection);
const isRangeSelected = isLineInSelection(displayIndex, normalizedSelectedLineRange);
const lineOutlineStyle = getCursorLikeOutlineStyle(displayIndex);
const lineRenderStyle = getCursorLikeOutlineStyle(displayIndex);
const isInReviewRange = reviewRangeByLineIndex[displayIndex] ?? false;
const baseCodeBg = getDiffLineBackground(lineInfo.type);
const codeBg = applyReviewRangeOverlay(baseCodeBg, isInReviewRange);
Expand All @@ -1348,7 +1451,7 @@ export const SelectableDiffRenderer = React.memo<SelectableDiffRendererProps>(
"group relative col-span-3 grid grid-cols-subgrid",
onLineIndexSelect ? "cursor-pointer" : "cursor-text"
)}
style={lineOutlineStyle}
style={lineRenderStyle}
data-line-index={displayIndex}
data-selected={isComposerSelected || isRangeSelected ? "true" : "false"}
onClick={(e) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,9 @@ describe("SelectableDiffRenderer drag selection", () => {
fireEvent.mouseEnter(indicators[2]);
fireEvent.mouseUp(window);

const textarea = (await waitFor(() =>
getByPlaceholderText(/Add a review note/i)
)) as HTMLTextAreaElement;
await waitFor(() => {
expect(getByPlaceholderText(/Add a review note/i)).toBeTruthy();
});

await waitFor(() => {
const selectedLines = Array.from(
Expand All @@ -68,7 +68,12 @@ describe("SelectableDiffRenderer drag selection", () => {
// Input should render *after* the last selected line (line 2).
const inputWrapper = allLines[2]?.nextElementSibling;
expect(inputWrapper).toBeTruthy();
expect(inputWrapper?.querySelector("textarea")).toBe(textarea);

// Inline composer can re-mount once while drag selection settles. Assert placement and
// semantics instead of strict DOM node identity.
const textareaInWrapper = inputWrapper?.querySelector("textarea");
expect(textareaInWrapper).toBeTruthy();
expect(textareaInWrapper?.getAttribute("placeholder")).toContain("Add a review note");
});
});
});