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
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
getFileHunks,
} from "@/browser/utils/review/navigation";
import { isEditableElement, KEYBINDS, matchesKeybind } from "@/browser/utils/ui/keybinds";
import { stopKeyboardPropagation } from "@/browser/utils/events";
import { buildReadFileScript, processFileContents } from "@/browser/utils/fileExplorer";
import {
parseReviewLineRange,
Expand Down Expand Up @@ -352,6 +353,7 @@ export const ImmersiveReviewView: React.FC<ImmersiveReviewViewProps> = (props) =
const containerRef = useRef<HTMLDivElement>(null);
const notesSidebarRef = useRef<HTMLDivElement>(null);
const hunkJumpRef = useRef(false);
const pendingJumpSelectAllHunkIdRef = useRef<string | null>(null);
const { api } = useAPI();

const {
Expand Down Expand Up @@ -429,6 +431,7 @@ export const ImmersiveReviewView: React.FC<ImmersiveReviewViewProps> = (props) =
}

if (!selectedHunkId || !currentFileHunks.some((hunk) => hunk.id === selectedHunkId)) {
pendingJumpSelectAllHunkIdRef.current = null;
onSelectHunk(currentFileHunks[0].id);
}
}, [currentFileHunks, selectedHunkId, onSelectHunk]);
Expand Down Expand Up @@ -734,12 +737,27 @@ export const ImmersiveReviewView: React.FC<ImmersiveReviewViewProps> = (props) =

// Keep cursor and selection aligned to the selected hunk when hunk navigation changes.
useEffect(() => {
if (!selectedHunkRange) {
const resolvedSelectedHunkId = selectedHunk?.id ?? null;

if (!selectedHunkRange || !resolvedSelectedHunkId) {
pendingJumpSelectAllHunkIdRef.current = null;
setActiveLineIndex(null);
setSelectedLineRange(null);
return;
}

const shouldSelectEntireHunk = pendingJumpSelectAllHunkIdRef.current === resolvedSelectedHunkId;
if (shouldSelectEntireHunk) {
pendingJumpSelectAllHunkIdRef.current = null;
const hunkAnchorLine = selectedHunkRange.firstModifiedIndex ?? selectedHunkRange.startIndex;
setActiveLineIndex(hunkAnchorLine);
setSelectedLineRange({
startIndex: selectedHunkRange.startIndex,
endIndex: selectedHunkRange.endIndex,
});
return;
}

setActiveLineIndex((previousLineIndex) => {
if (
previousLineIndex !== null &&
Expand Down Expand Up @@ -793,6 +811,7 @@ export const ImmersiveReviewView: React.FC<ImmersiveReviewViewProps> = (props) =
candidatePath = nextPath;
const fileHunks = sortHunksInFileOrder(getFileHunks(hunks, candidatePath));
if (fileHunks.length > 0) {
pendingJumpSelectAllHunkIdRef.current = null;
hunkJumpRef.current = true;
onSelectHunk(fileHunks[0].id);
return;
Expand Down Expand Up @@ -821,8 +840,10 @@ export const ImmersiveReviewView: React.FC<ImmersiveReviewViewProps> = (props) =
}
}

const targetHunkId = currentFileHunks[nextIdx].id;
pendingJumpSelectAllHunkIdRef.current = targetHunkId;
hunkJumpRef.current = true;
onSelectHunk(currentFileHunks[nextIdx].id);
onSelectHunk(targetHunkId);
},
[currentFileHunks, selectedHunkId, onSelectHunk]
);
Expand All @@ -835,6 +856,7 @@ export const ImmersiveReviewView: React.FC<ImmersiveReviewViewProps> = (props) =
}

const targetHunkId = findReviewHunkId(review, fileHunks) ?? fileHunks[0].id;
pendingJumpSelectAllHunkIdRef.current = null;
hunkJumpRef.current = true;
onSelectHunk(targetHunkId);
// Force scroll effect to re-fire even when activeLineIndex is unchanged
Expand Down Expand Up @@ -916,6 +938,7 @@ export const ImmersiveReviewView: React.FC<ImmersiveReviewViewProps> = (props) =
if (resolved.hunk.id !== currentSelectedHunkId) {
// Record the in-flight hunk switch so mismatch guards do not clear
// this composer request before onSelectHunk propagates.
pendingJumpSelectAllHunkIdRef.current = null;
pendingComposerHunkSwitchRef.current = {
fromHunkId: currentSelectedHunkId,
toHunkId: resolved.hunk.id,
Expand Down Expand Up @@ -1001,6 +1024,7 @@ export const ImmersiveReviewView: React.FC<ImmersiveReviewViewProps> = (props) =

const lineHunkId = overlayData.lineHunkIds[nextIndex];
if (lineHunkId && lineHunkId !== selectedHunkIdRef.current) {
pendingJumpSelectAllHunkIdRef.current = null;
onSelectHunk(lineHunkId);
}
},
Expand All @@ -1011,6 +1035,7 @@ export const ImmersiveReviewView: React.FC<ImmersiveReviewViewProps> = (props) =
(lineIndex: number, shiftKey: boolean) => {
const resolvedHunk = findHunkAtLine(lineIndex, overlayData, currentFileHunks);
if (resolvedHunk && selectedHunkIdRef.current !== resolvedHunk.hunk.id) {
pendingJumpSelectAllHunkIdRef.current = null;
onSelectHunk(resolvedHunk.hunk.id);
}

Expand Down Expand Up @@ -1060,6 +1085,7 @@ export const ImmersiveReviewView: React.FC<ImmersiveReviewViewProps> = (props) =
// Esc: return to diff panel (not exit immersive).
if (matchesKeybind(e, KEYBINDS.CANCEL)) {
e.preventDefault();
stopKeyboardPropagation(e);
setFocusedPanel("diff");
containerRef.current?.focus();
return;
Expand Down Expand Up @@ -1126,6 +1152,7 @@ export const ImmersiveReviewView: React.FC<ImmersiveReviewViewProps> = (props) =
// Esc: exit immersive
if (matchesKeybind(e, KEYBINDS.CANCEL)) {
e.preventDefault();
stopKeyboardPropagation(e);
onExit();
return;
}
Expand Down Expand Up @@ -1204,8 +1231,10 @@ export const ImmersiveReviewView: React.FC<ImmersiveReviewViewProps> = (props) =
}
};

window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
// Run in capture phase so immersive Escape handling can swallow the event before
// bubble-phase global stream-interrupt listeners see it.
window.addEventListener("keydown", handleKeyDown, { capture: true });
return () => window.removeEventListener("keydown", handleKeyDown, { capture: true });
}, [
focusedPanel,
allReviews,
Expand Down Expand Up @@ -1278,7 +1307,30 @@ export const ImmersiveReviewView: React.FC<ImmersiveReviewViewProps> = (props) =
return;
}

if (activeLineIndex !== null && lineIndexForScroll === activeLineIndex) {
const normalizedSelectedLineRange = selectedLineRange
? {
startIndex: Math.min(selectedLineRange.startIndex, selectedLineRange.endIndex),
endIndex: Math.max(selectedLineRange.startIndex, selectedLineRange.endIndex),
}
: null;
const hasMultiLineSelection = Boolean(
normalizedSelectedLineRange &&
normalizedSelectedLineRange.endIndex > normalizedSelectedLineRange.startIndex
);
const isActiveLineInsideSelection = Boolean(
normalizedSelectedLineRange &&
activeLineIndex !== null &&
activeLineIndex >= normalizedSelectedLineRange.startIndex &&
activeLineIndex <= normalizedSelectedLineRange.endIndex
);
const shouldRenderActiveLineOutline =
activeLineIndex !== null &&
lineIndexForScroll === activeLineIndex &&
!(hasMultiLineSelection && isActiveLineInsideSelection);

// For full-hunk keyboard selections (J/K), suppress the separate active-line
// ring so the range highlight reads as one unified selection.
if (shouldRenderActiveLineOutline) {
lineElement.style.outline = ACTIVE_LINE_OUTLINE;
lineElement.style.outlineOffset = "-1px";
highlightedLineElementRef.current = lineElement;
Expand Down Expand Up @@ -1311,6 +1363,7 @@ export const ImmersiveReviewView: React.FC<ImmersiveReviewViewProps> = (props) =
overlayData.content,
revealTargetLineIndex,
scrollNonce,
selectedLineRange,
]);

useEffect(() => {
Expand Down
18 changes: 16 additions & 2 deletions src/browser/components/shared/DiffRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1283,6 +1283,16 @@ export const SelectableDiffRenderer = React.memo<SelectableDiffRendererProps>(
const rangeSelectionHighlight =
"inset 0 0 0 100vmax hsl(from var(--color-review-accent) h s l / 0.12)";
const activeLineHighlight = "inset 0 0 0 1px hsl(from var(--color-review-accent) h s l / 0.45)";
const normalizedSelectedLineRange = selectedLineRange
? {
startIndex: Math.min(selectedLineRange.startIndex, selectedLineRange.endIndex),
endIndex: Math.max(selectedLineRange.startIndex, selectedLineRange.endIndex),
}
: null;
const hasMultiLineExternalSelection = Boolean(
normalizedSelectedLineRange &&
normalizedSelectedLineRange.endIndex > normalizedSelectedLineRange.startIndex
);

return (
<DiffContainer
Expand All @@ -1294,8 +1304,12 @@ export const SelectableDiffRenderer = React.memo<SelectableDiffRendererProps>(
>
{highlightedLineData.map((lineInfo, displayIndex) => {
const isComposerSelected = isLineInSelection(displayIndex, renderSelection);
const isRangeSelected = isLineInSelection(displayIndex, selectedLineRange);
const isRangeSelected = isLineInSelection(displayIndex, normalizedSelectedLineRange);
const isActiveLine = activeLineIndex === displayIndex;
// When a multi-line selection is active (e.g. immersive J/K full-hunk selection),
// let the range highlight own the visual state so the cursor doesn't appear detached.
const shouldRenderActiveLineHighlight =
isActiveLine && !(hasMultiLineExternalSelection && isRangeSelected);
const isInReviewRange = reviewRangeByLineIndex[displayIndex] ?? false;
const baseCodeBg = getDiffLineBackground(lineInfo.type);
const codeBg = applyReviewRangeOverlay(baseCodeBg, isInReviewRange);
Expand All @@ -1311,7 +1325,7 @@ export const SelectableDiffRenderer = React.memo<SelectableDiffRendererProps>(
} else if (isRangeSelected) {
lineShadows.push(rangeSelectionHighlight);
}
if (isActiveLine) {
if (shouldRenderActiveLineHighlight) {
lineShadows.push(activeLineHighlight);
}

Expand Down
Loading