diff --git a/src/browser/components/ChatPane.tsx b/src/browser/components/ChatPane.tsx index d174a172c3..adf4e09ea3 100644 --- a/src/browser/components/ChatPane.tsx +++ b/src/browser/components/ChatPane.tsx @@ -128,6 +128,8 @@ interface ChatPaneProps { onToggleLeftSidebarCollapsed: () => void; runtimeConfig?: RuntimeConfig; onOpenTerminal: (options?: TerminalSessionCreateOptions) => void; + /** Hide + inactivate chat pane while immersive review overlay is active. */ + immersiveHidden?: boolean; } type ReviewsState = ReturnType; @@ -144,11 +146,29 @@ export const ChatPane: React.FC = (props) => { runtimeConfig, onOpenTerminal, workspaceState, + immersiveHidden = false, } = props; const { api } = useAPI(); const { workspaceMetadata } = useWorkspaceContext(); const chatAreaRef = useRef(null); + useEffect(() => { + const chatPaneElement = chatAreaRef.current; + if (!chatPaneElement) { + return; + } + + if (immersiveHidden) { + chatPaneElement.setAttribute("inert", ""); + } else { + chatPaneElement.removeAttribute("inert"); + } + + return () => { + chatPaneElement.removeAttribute("inert"); + }; + }, [immersiveHidden, workspaceId]); + const storeRaw = useWorkspaceStoreRaw(); const aggregator = useWorkspaceAggregator(workspaceId); const workspaceUsage = useWorkspaceUsage(workspaceId); @@ -671,6 +691,7 @@ export const ChatPane: React.FC = (props) => {
diff --git a/src/browser/components/RightSidebar.tsx b/src/browser/components/RightSidebar.tsx index e09394ffc5..a0da816153 100644 --- a/src/browser/components/RightSidebar.tsx +++ b/src/browser/components/RightSidebar.tsx @@ -2,6 +2,7 @@ import React from "react"; import { RIGHT_SIDEBAR_COLLAPSED_KEY, RIGHT_SIDEBAR_TAB_KEY, + getReviewImmersiveKey, getRightSidebarLayoutKey, getTerminalTitlesKey, } from "@/common/constants/storage"; @@ -20,7 +21,13 @@ import { ErrorBoundary } from "./ErrorBoundary"; import { StatsTab } from "./RightSidebar/StatsTab"; import { OutputTab } from "./OutputTab"; -import { matchesKeybind, KEYBINDS, formatKeybind, isDialogOpen } from "@/browser/utils/ui/keybinds"; +import { + matchesKeybind, + KEYBINDS, + formatKeybind, + isDialogOpen, + isEditableElement, +} from "@/browser/utils/ui/keybinds"; import { SidebarCollapseButton } from "./ui/SidebarCollapseButton"; import { cn } from "@/common/lib/utils"; import type { ReviewNoteData } from "@/common/types/review"; @@ -51,6 +58,7 @@ import { removeTabEverywhere, reorderTabInTabset, selectTabByIndex, + selectOrAddTab, selectTabInTabset, setFocusedTabset, updateSplitSizes, @@ -103,6 +111,8 @@ interface SidebarContainerProps { isResizing?: boolean; /** Whether running in Electron desktop mode (hides border when collapsed) */ isDesktop?: boolean; + /** Hide + inactivate sidebar while immersive review overlay is active. */ + immersiveHidden?: boolean; children: React.ReactNode; role: string; "aria-label": string; @@ -121,14 +131,35 @@ const SidebarContainer: React.FC = ({ customWidth, isResizing, isDesktop, + immersiveHidden = false, children, role, "aria-label": ariaLabel, }) => { + const containerRef = React.useRef(null); const width = collapsed ? "20px" : customWidth ? `${customWidth}px` : "400px"; + React.useEffect(() => { + const container = containerRef.current; + if (!container) { + return; + } + + if (immersiveHidden) { + container.setAttribute("inert", ""); + } else { + container.removeAttribute("inert"); + } + + return () => { + container.removeAttribute("inert"); + }; + }, [immersiveHidden]); + return (
void; /** Workspace is still being created (git operations in progress) */ isCreating?: boolean; + /** Hide + inactivate sidebar while immersive review overlay is active. */ + immersiveHidden?: boolean; /** Ref callback to expose addTerminal function to parent */ addTerminalRef?: React.MutableRefObject< ((options?: TerminalSessionCreateOptions) => void) | null @@ -189,6 +222,14 @@ const DragAwarePanelResizeHandle: React.FC<{ return ; }; +function hasMountedReviewPanel(node: RightSidebarLayoutNode): boolean { + if (node.type === "tabset") { + return node.activeTab === "review"; + } + + return node.children.some((child) => hasMountedReviewPanel(child)); +} + type TabsetNode = Extract; interface RightSidebarTabsetNodeProps { @@ -594,6 +635,7 @@ const RightSidebarComponent: React.FC = ({ isResizing = false, onReviewNote, isCreating = false, + immersiveHidden = false, addTerminalRef, }) => { // Trigger for focusing Review panel (preserves hunk selection) @@ -611,6 +653,11 @@ const RightSidebarComponent: React.FC = ({ const [collapsed, setCollapsed] = usePersistedState(RIGHT_SIDEBAR_COLLAPSED_KEY, false, { listener: true, }); + const [isReviewImmersive, setIsReviewImmersive] = usePersistedState( + getReviewImmersiveKey(workspaceId), + false, + { listener: true } + ); // Stats tab feature flag const { statsTabState } = useFeatureFlags(); @@ -671,6 +718,21 @@ const RightSidebarComponent: React.FC = ({ [layoutDraft, layoutRaw, initialActiveTab] ); + const hasReviewPanelMounted = React.useMemo( + () => !collapsed && hasMountedReviewPanel(layout.root), + [collapsed, layout.root] + ); + + // If immersive mode is active but no ReviewPanel is mounted (e.g., user switched tabs), + // clear the persisted immersive flag to avoid leaving a blank overlay mounted. + React.useEffect(() => { + if (!isReviewImmersive || hasReviewPanelMounted) { + return; + } + + setIsReviewImmersive(false); + }, [hasReviewPanelMounted, isReviewImmersive, setIsReviewImmersive]); + // If the Stats tab feature is enabled, ensure it exists in the layout. // If disabled, ensure it doesn't linger in persisted layouts. React.useEffect(() => { @@ -735,6 +797,11 @@ const RightSidebarComponent: React.FC = ({ [initialActiveTab, setLayoutRaw] ); + const selectOrOpenReviewTab = React.useCallback(() => { + setLayout((prev) => selectOrAddTab(prev, "review")); + _setFocusTrigger((prev) => prev + 1); + }, [setLayout]); + // Keyboard shortcuts for tab switching by position (Cmd/Ctrl+1-9) // Auto-expands sidebar if collapsed React.useEffect(() => { @@ -783,6 +850,26 @@ const RightSidebarComponent: React.FC = ({ return () => window.removeEventListener("keydown", handleKeyDown); }, [initialActiveTab, setAutoFocusTerminalSession, setCollapsed, setLayout, _setFocusTrigger]); + React.useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (!matchesKeybind(e, KEYBINDS.TOGGLE_REVIEW_IMMERSIVE)) { + return; + } + + if (isEditableElement(e.target)) { + return; + } + + e.preventDefault(); + setCollapsed(false); + selectOrOpenReviewTab(); + setIsReviewImmersive((prev) => !prev); + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [isReviewImmersive, selectOrOpenReviewTab, setCollapsed, setIsReviewImmersive]); + const baseId = `right-sidebar-${workspaceId}`; // Build map of tab → position for keybind tooltips @@ -1218,6 +1305,7 @@ const RightSidebarComponent: React.FC = ({ collapsed={collapsed} isResizing={isResizing} isDesktop={isDesktopMode()} + immersiveHidden={immersiveHidden} customWidth={width} // Unified width from AIView (applies to all tabs) role="complementary" aria-label="Workspace insights" diff --git a/src/browser/components/RightSidebar/CodeReview/ImmersiveReviewView.tsx b/src/browser/components/RightSidebar/CodeReview/ImmersiveReviewView.tsx new file mode 100644 index 0000000000..3b7b164379 --- /dev/null +++ b/src/browser/components/RightSidebar/CodeReview/ImmersiveReviewView.tsx @@ -0,0 +1,1638 @@ +/** + * ImmersiveReviewView — Full-screen, keyboard-first code review mode. + * Rendered via portal into #review-immersive-root overlay. + * Shows one file at a time with keyboard navigation for files, hunks, and lines. + */ + +import React, { useState, useCallback, useEffect, useMemo, useRef } from "react"; +import { + ArrowLeft, + Check, + ChevronLeft, + ChevronRight, + Circle, + MessageSquare, + ThumbsDown, + ThumbsUp, + Trash2, +} from "lucide-react"; +import { cn } from "@/common/lib/utils"; +import { SelectableDiffRenderer } from "../../shared/DiffRenderer"; +import { KeycapGroup } from "@/browser/components/ui/Keycap"; +import { useAPI } from "@/browser/contexts/API"; +import { formatLineRangeCompact } from "@/browser/utils/review/lineRange"; +import { + flattenFileTreeLeaves, + getAdjacentFilePath, + getFileHunks, +} from "@/browser/utils/review/navigation"; +import { isEditableElement, KEYBINDS, matchesKeybind } from "@/browser/utils/ui/keybinds"; +import { buildReadFileScript, processFileContents } from "@/browser/utils/fileExplorer"; +import { + parseReviewLineRange, + type DiffHunk, + type Review, + type ReviewNoteData, +} from "@/common/types/review"; +import type { FileTreeNode } from "@/common/utils/git/numstatParser"; +import type { ReviewActionCallbacks } from "../../shared/InlineReviewNote"; + +interface ImmersiveReviewViewProps { + workspaceId: string; + fileTree: FileTreeNode | null; + /** Filtered hunks (respects current filters) */ + hunks: DiffHunk[]; + /** All hunks (unfiltered, for context) */ + allHunks: DiffHunk[]; + /** True while diff/tree payload for this workspace is still loading. */ + isLoading?: boolean; + isRead: (hunkId: string) => boolean; + onToggleRead: (hunkId: string) => void; + selectedHunkId: string | null; + onSelectHunk: (hunkId: string | null) => void; + onExit: () => void; + onReviewNote?: (data: ReviewNoteData) => void; + reviewActions?: ReviewActionCallbacks; + reviewsByFilePath: Map; + /** Map of hunkId -> first-seen timestamp */ + firstSeenMap: Record; +} + +interface InlineComposerRequest { + requestId: number; + prefill: string; + hunkId: string; + startOffset: number; + endOffset: number; +} + +interface InlineReviewEditRequest { + requestId: number; + reviewId: string; +} + +interface SelectedLineRange { + startIndex: number; + endIndex: number; +} + +interface PendingComposerHunkSwitch { + fromHunkId: string | null; + toHunkId: string; +} + +interface HunkLineRange { + startIndex: number; + endIndex: number; + firstModifiedIndex: number | null; +} + +interface ImmersiveOverlayData { + content: string; + lineHunkIds: Array; + hunkLineRanges: Map; +} + +const LINE_JUMP_SIZE = 10; +// Keep syntax highlighting on for larger review files now that per-line tooltip overhead is gone, +// but still cap it to avoid pathological DOM costs on extremely large diffs. +const MAX_HIGHLIGHTED_DIFF_LINES = 4000; +const ACTIVE_LINE_OUTLINE = "1px solid hsl(from var(--color-review-accent) h s l / 0.45)"; +const LIKE_NOTE_PREFIX = "I like this change"; +const DISLIKE_NOTE_PREFIX = "I don't like this change"; + +function getFileBaseName(filePath: string): string { + const segments = filePath.split(/[\\/]/); + return segments[segments.length - 1] || filePath; +} + +function getReviewStatusSidebarClasses(status: Review["status"]): { + accent: string; + badge: string; + icon: string; +} { + if (status === "checked") { + return { + accent: "bg-success", + badge: "bg-success/20 text-success", + icon: "text-success", + }; + } + + if (status === "attached") { + return { + accent: "bg-warning", + badge: "bg-warning/20 text-warning", + icon: "text-warning", + }; + } + + return { + accent: "bg-muted", + badge: "bg-muted/25 text-muted", + icon: "text-muted", + }; +} + +function splitDiffLines(content: string): string[] { + const lines = content.split(/\r?\n/); + if (lines.length > 0 && lines[lines.length - 1] === "") { + lines.pop(); + } + return lines; +} + +function normalizeFileLines(content: string): string[] { + // Normalize Windows CRLF to LF-equivalent lines so rows stay single-height in + // whitespace-preserving diff cells (embedded "\r" can render as extra breaks). + const lines = content + .split(/\r?\n/) + .map((line) => (line.endsWith("\r") ? line.slice(0, Math.max(0, line.length - 1)) : line)); + return lines.filter((line, idx) => idx < lines.length - 1 || line !== ""); +} + +function sortHunksInFileOrder(hunks: DiffHunk[]): DiffHunk[] { + return [...hunks].sort((a, b) => { + const newStartDelta = a.newStart - b.newStart; + if (newStartDelta !== 0) { + return newStartDelta; + } + + const oldStartDelta = a.oldStart - b.oldStart; + if (oldStartDelta !== 0) { + return oldStartDelta; + } + + return a.id.localeCompare(b.id); + }); +} + +function buildOverlayFromFileContent( + fileContent: string, + sortedHunks: DiffHunk[] +): ImmersiveOverlayData { + const fileLines = normalizeFileLines(fileContent); + const contentLines: string[] = []; + const lineHunkIds: Array = []; + const hunkLineRanges = new Map(); + + let newLineIdx = 0; + + const pushDisplayLine = (line: string, hunkId: string | null) => { + contentLines.push(line); + lineHunkIds.push(hunkId); + }; + + for (const hunk of sortedHunks) { + const hunkStartInNew = Math.max(0, hunk.newStart - 1); + + while (newLineIdx < hunkStartInNew && newLineIdx < fileLines.length) { + pushDisplayLine(` ${fileLines[newLineIdx]}`, null); + newLineIdx += 1; + } + + const hunkStartIndex = lineHunkIds.length; + let firstModifiedIndex: number | null = null; + + for (const line of splitDiffLines(hunk.content)) { + const prefix = line[0] ?? " "; + if (prefix !== "+" && prefix !== "-" && prefix !== " ") { + continue; + } + + if (firstModifiedIndex === null && (prefix === "+" || prefix === "-")) { + firstModifiedIndex = lineHunkIds.length; + } + + pushDisplayLine(`${prefix}${line.slice(1)}`, hunk.id); + if (prefix !== "-") { + newLineIdx += 1; + } + } + + if (lineHunkIds.length > hunkStartIndex) { + hunkLineRanges.set(hunk.id, { + startIndex: hunkStartIndex, + endIndex: lineHunkIds.length - 1, + firstModifiedIndex, + }); + } + } + + while (newLineIdx < fileLines.length) { + pushDisplayLine(` ${fileLines[newLineIdx]}`, null); + newLineIdx += 1; + } + + return { + content: contentLines.join("\n"), + lineHunkIds, + hunkLineRanges, + }; +} + +function buildOverlayFromHunks(sortedHunks: DiffHunk[]): ImmersiveOverlayData { + const contentLines: string[] = []; + const lineHunkIds: Array = []; + const hunkLineRanges = new Map(); + + const pushDisplayLine = (line: string, hunkId: string | null) => { + contentLines.push(line); + lineHunkIds.push(hunkId); + }; + + const pushHeaderLine = (line: string) => { + // Header rows are intentionally excluded from lineHunkIds because DiffRenderer + // does not render @@ header lines in selectable output. + contentLines.push(line); + }; + + sortedHunks.forEach((hunk, index) => { + if (index > 0) { + pushDisplayLine(" ", null); + } + + pushHeaderLine(`@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@`); + + const hunkStartIndex = lineHunkIds.length; + let firstModifiedIndex: number | null = null; + + for (const line of splitDiffLines(hunk.content)) { + const prefix = line[0] ?? " "; + if (prefix !== "+" && prefix !== "-" && prefix !== " ") { + continue; + } + + if (firstModifiedIndex === null && (prefix === "+" || prefix === "-")) { + firstModifiedIndex = lineHunkIds.length; + } + + pushDisplayLine(`${prefix}${line.slice(1)}`, hunk.id); + } + + if (lineHunkIds.length > hunkStartIndex) { + hunkLineRanges.set(hunk.id, { + startIndex: hunkStartIndex, + endIndex: lineHunkIds.length - 1, + firstModifiedIndex, + }); + } + }); + + return { + content: contentLines.join("\n"), + lineHunkIds, + hunkLineRanges, + }; +} + +function isSelectionInsideRange(selection: SelectedLineRange, range: HunkLineRange): boolean { + const start = Math.min(selection.startIndex, selection.endIndex); + const end = Math.max(selection.startIndex, selection.endIndex); + return start >= range.startIndex && end <= range.endIndex; +} + +/** Resolve the hunk that contains a given overlay line index using the lineHunkIds lookup. */ +function findHunkAtLine( + lineIndex: number, + overlayData: ImmersiveOverlayData, + fileHunks: DiffHunk[] +): { hunk: DiffHunk; range: HunkLineRange } | null { + const hunkId = overlayData.lineHunkIds[lineIndex]; + if (!hunkId) { + return null; + } + const hunk = fileHunks.find((h) => h.id === hunkId); + const range = overlayData.hunkLineRanges.get(hunkId); + if (!hunk || !range) { + return null; + } + return { hunk, range }; +} + +function getLineSpan(start: number, lineCount: number): { start: number; end: number } | null { + if (lineCount <= 0) { + return null; + } + + return { + start, + end: start + lineCount - 1, + }; +} + +function rangesOverlap( + lhs: { start: number; end: number } | undefined, + rhs: { start: number; end: number } | null +): boolean { + if (!lhs || !rhs) { + return false; + } + + return lhs.start <= rhs.end && rhs.start <= lhs.end; +} + +function findReviewHunkId(review: Review, fileHunks: DiffHunk[]): string | null { + const parsedRange = parseReviewLineRange(review.data.lineRange); + if (!parsedRange) { + return null; + } + + const matchingHunk = fileHunks.find((hunk) => { + const oldSpan = getLineSpan(hunk.oldStart, hunk.oldLines); + const newSpan = getLineSpan(hunk.newStart, hunk.newLines); + + return rangesOverlap(parsedRange.old, oldSpan) || rangesOverlap(parsedRange.new, newSpan); + }); + + return matchingHunk?.id ?? null; +} + +export const ImmersiveReviewView: React.FC = (props) => { + const containerRef = useRef(null); + const notesSidebarRef = useRef(null); + const hunkJumpRef = useRef(false); + const { api } = useAPI(); + + const { + fileTree, + hunks, + allHunks, + selectedHunkId, + onSelectHunk, + onToggleRead, + onExit, + onReviewNote, + } = props; + + // Flatten file tree into ordered file list + const fileList = useMemo(() => flattenFileTreeLeaves(fileTree), [fileTree]); + + // Determine active file from selected hunk or first file + const activeFilePath = useMemo(() => { + if (selectedHunkId) { + const selectedHunk = + hunks.find((item) => item.id === selectedHunkId) ?? + allHunks.find((item) => item.id === selectedHunkId); + if (selectedHunk) { + return selectedHunk.filePath; + } + } + + // Fallback: first file that has currently visible hunks. + if (hunks.length > 0) { + return hunks[0].filePath; + } + + if (fileList.length > 0) { + return fileList[0]; + } + + return null; + }, [selectedHunkId, hunks, allHunks, fileList]); + + const selectedHunkFromAll = useMemo( + () => (selectedHunkId ? (allHunks.find((item) => item.id === selectedHunkId) ?? null) : null), + [selectedHunkId, allHunks] + ); + + const selectedHunkIsFilteredOut = Boolean( + selectedHunkFromAll && !hunks.some((item) => item.id === selectedHunkFromAll.id) + ); + + const activeFileHunks = selectedHunkIsFilteredOut ? allHunks : hunks; + + // Hunks for the active file only, always sorted in file order. + // When the selected hunk is filtered out, keep using unfiltered hunks so + // note-driven navigation can still land on the review context. + const currentFileHunks = useMemo( + () => + activeFilePath ? sortHunksInFileOrder(getFileHunks(activeFileHunks, activeFilePath)) : [], + [activeFileHunks, activeFilePath] + ); + + const selectedHunk = useMemo(() => { + if (selectedHunkId) { + const matchingHunk = currentFileHunks.find((hunk) => hunk.id === selectedHunkId); + if (matchingHunk) { + return matchingHunk; + } + } + + return currentFileHunks[0] ?? null; + }, [selectedHunkId, currentFileHunks]); + + // Ensure we always have a selected hunk when the active file has hunks. + useEffect(() => { + if (currentFileHunks.length === 0) { + return; + } + + if (!selectedHunkId || !currentFileHunks.some((hunk) => hunk.id === selectedHunkId)) { + onSelectHunk(currentFileHunks[0].id); + } + }, [currentFileHunks, selectedHunkId, onSelectHunk]); + + const [activeFileContentState, setActiveFileContentState] = useState<{ + filePath: string | null; + content: string | null; + isSettled: boolean; + }>({ + filePath: null, + content: null, + isSettled: true, + }); + + // Hold diff reveal during file switches until loading + initial scroll are complete. + const [pendingRevealFilePath, setPendingRevealFilePath] = useState(null); + const revealAnimationFrameRef = useRef(null); + + // Load full file content so immersive mode can render one coherent file with hunk overlays. + // Keep a per-file loading state so switches can show a splash until loading settles, + // which avoids a visible fallback-overlay -> full-content jump. + useEffect(() => { + const apiClient = api; + const filePath = activeFilePath; + + if (!filePath || !apiClient) { + setActiveFileContentState({ + filePath: filePath ?? null, + content: null, + isSettled: true, + }); + return; + } + + const resolvedApi: NonNullable = apiClient; + const resolvedFilePath: string = filePath; + + let cancelled = false; + setActiveFileContentState({ + filePath: resolvedFilePath, + content: null, + isSettled: false, + }); + + async function loadActiveFileContent() { + try { + const fileResult = await resolvedApi.workspace.executeBash({ + workspaceId: props.workspaceId, + script: buildReadFileScript(resolvedFilePath), + }); + + if (cancelled) { + return; + } + + if (!fileResult.success) { + setActiveFileContentState({ + filePath: resolvedFilePath, + content: null, + isSettled: true, + }); + return; + } + + const bashResult = fileResult.data; + + if (!bashResult.success && !bashResult.output) { + setActiveFileContentState({ + filePath: resolvedFilePath, + content: null, + isSettled: true, + }); + return; + } + + const data = processFileContents(bashResult.output ?? "", bashResult.exitCode); + setActiveFileContentState({ + filePath: resolvedFilePath, + content: data.type === "text" ? data.content : null, + isSettled: true, + }); + } catch { + if (!cancelled) { + setActiveFileContentState({ + filePath: resolvedFilePath, + content: null, + isSettled: true, + }); + } + } + } + + void loadActiveFileContent(); + + return () => { + cancelled = true; + }; + }, [api, props.workspaceId, activeFilePath]); + + const isActiveFileContentSettled = + !activeFilePath || + (activeFileContentState.filePath === activeFilePath && activeFileContentState.isSettled); + + const resolvedActiveFileContent = isActiveFileContentSettled + ? activeFileContentState.content + : null; + + const isActiveFileContentLoading = Boolean( + activeFilePath && currentFileHunks.length > 0 && !isActiveFileContentSettled + ); + + const overlayData = useMemo(() => { + if (currentFileHunks.length === 0) { + return { + content: "", + lineHunkIds: [], + hunkLineRanges: new Map(), + }; + } + + if (resolvedActiveFileContent != null) { + return buildOverlayFromFileContent(resolvedActiveFileContent, currentFileHunks); + } + + return buildOverlayFromHunks(currentFileHunks); + }, [resolvedActiveFileContent, currentFileHunks]); + + const selectedHunkRange = useMemo( + () => (selectedHunk ? (overlayData.hunkLineRanges.get(selectedHunk.id) ?? null) : null), + [selectedHunk, overlayData] + ); + + const selectedHunkLineCount = selectedHunkRange + ? selectedHunkRange.endIndex - selectedHunkRange.startIndex + 1 + : 0; + + const allReviews = useMemo( + () => + Array.from(props.reviewsByFilePath.values()) + .flat() + .sort((a, b) => { + const createdAtDelta = b.createdAt - a.createdAt; + if (createdAtDelta !== 0) { + return createdAtDelta; + } + + return a.id.localeCompare(b.id); + }), + [props.reviewsByFilePath] + ); + + const [inlineComposerRequest, setInlineComposerRequest] = useState( + null + ); + const [inlineReviewEditRequest, setInlineReviewEditRequest] = + useState(null); + const nextComposerRequestIdRef = useRef(0); + const nextInlineReviewEditRequestIdRef = useRef(0); + const pendingComposerHunkSwitchRef = useRef(null); + + // Keyboard line cursor state within the whole rendered file. + const [activeLineIndex, setActiveLineIndex] = useState(null); + const [selectedLineRange, setSelectedLineRange] = useState(null); + const [scrollNonce, setScrollNonce] = useState(0); + const [boundaryToast, setBoundaryToast] = useState(null); + + // Which panel has keyboard focus while in immersive mode. + const [focusedPanel, setFocusedPanel] = useState<"diff" | "notes">("diff"); + const [focusedNoteIndex, setFocusedNoteIndex] = useState(0); + + useEffect(() => { + if (revealAnimationFrameRef.current !== null) { + cancelAnimationFrame(revealAnimationFrameRef.current); + revealAnimationFrameRef.current = null; + } + + if (!activeFilePath) { + setPendingRevealFilePath(null); + return; + } + + // Keep the splash visible for each file switch until we have scrolled to the target hunk. + setPendingRevealFilePath(activeFilePath); + hunkJumpRef.current = true; + }, [activeFilePath]); + + useEffect(() => { + return () => { + if (revealAnimationFrameRef.current !== null) { + cancelAnimationFrame(revealAnimationFrameRef.current); + } + }; + }, []); + + const selectedHunkRevealTargetLineIndex = + selectedHunkRange?.firstModifiedIndex ?? selectedHunkRange?.startIndex ?? null; + const isActiveFileRevealPending = pendingRevealFilePath === activeFilePath; + const revealTargetLineIndex = isActiveFileRevealPending + ? selectedHunkRevealTargetLineIndex + : (activeLineIndex ?? selectedHunkRevealTargetLineIndex); + const hasResolvedSelectedHunkForReveal = + selectedHunkId !== null && currentFileHunks.some((hunk) => hunk.id === selectedHunkId); + + useEffect(() => { + if (!isActiveFileRevealPending || !isActiveFileContentSettled) { + return; + } + + // Fail open so the UI cannot get stuck if a file has no hunks. + if (currentFileHunks.length === 0) { + setPendingRevealFilePath(null); + return; + } + + // Avoid dropping the reveal gate while selected hunk state is still settling. + if (!hasResolvedSelectedHunkForReveal) { + return; + } + + // Fail open once selection is stable if we still cannot resolve a reveal target. + if (selectedHunkRevealTargetLineIndex === null) { + setPendingRevealFilePath(null); + } + }, [ + currentFileHunks.length, + hasResolvedSelectedHunkForReveal, + isActiveFileRevealPending, + isActiveFileContentSettled, + selectedHunkRevealTargetLineIndex, + ]); + + useEffect(() => { + if (!boundaryToast) return; + const timer = setTimeout(() => setBoundaryToast(null), 2500); + return () => clearTimeout(timer); + }, [boundaryToast]); + + useEffect(() => { + if (focusedNoteIndex < allReviews.length) { + return; + } + + setFocusedNoteIndex(Math.max(0, allReviews.length - 1)); + }, [allReviews.length, focusedNoteIndex]); + + useEffect(() => { + if (focusedPanel !== "notes") { + return; + } + + const noteEl = notesSidebarRef.current?.querySelector( + `[data-note-index="${focusedNoteIndex}"]` + ); + noteEl?.scrollIntoView({ block: "nearest", behavior: "auto" }); + }, [focusedPanel, focusedNoteIndex]); + + useEffect(() => { + if (!inlineComposerRequest) { + pendingComposerHunkSwitchRef.current = null; + return; + } + + if (selectedHunk?.id === inlineComposerRequest.hunkId) { + pendingComposerHunkSwitchRef.current = null; + return; + } + + const pendingSwitch = pendingComposerHunkSwitchRef.current; + const isAwaitingRequestedHunk = + pendingSwitch?.toHunkId === inlineComposerRequest.hunkId && + (selectedHunkId === pendingSwitch.fromHunkId || selectedHunkId === null); + + if (isAwaitingRequestedHunk) { + const requestedHunkStillExists = currentFileHunks.some( + (hunk) => hunk.id === inlineComposerRequest.hunkId + ); + if (requestedHunkStillExists) { + return; + } + } + + pendingComposerHunkSwitchRef.current = null; + setInlineComposerRequest(null); + }, [currentFileHunks, inlineComposerRequest, selectedHunk, selectedHunkId]); + + // Refs keep hot-path callbacks stable so cursor movement doesn't trigger expensive re-renders. + const activeLineIndexRef = useRef(null); + const selectedLineRangeRef = useRef(null); + const selectedHunkIdRef = useRef(selectedHunkId); + const highlightedLineElementRef = useRef(null); + + useEffect(() => { + activeLineIndexRef.current = activeLineIndex; + }, [activeLineIndex]); + + useEffect(() => { + selectedLineRangeRef.current = selectedLineRange; + }, [selectedLineRange]); + + useEffect(() => { + selectedHunkIdRef.current = selectedHunkId; + }, [selectedHunkId]); + + // Keep cursor and selection aligned to the selected hunk when hunk navigation changes. + useEffect(() => { + if (!selectedHunkRange) { + setActiveLineIndex(null); + setSelectedLineRange(null); + return; + } + + setActiveLineIndex((previousLineIndex) => { + if ( + previousLineIndex !== null && + previousLineIndex >= selectedHunkRange.startIndex && + previousLineIndex <= selectedHunkRange.endIndex + ) { + return previousLineIndex; + } + return selectedHunkRange.firstModifiedIndex ?? selectedHunkRange.startIndex; + }); + + setSelectedLineRange((previousSelection) => { + if (!previousSelection) { + return null; + } + + if (isSelectionInsideRange(previousSelection, selectedHunkRange)) { + return previousSelection; + } + + return null; + }); + }, [ + selectedHunk?.id, + selectedHunkRange?.startIndex, + selectedHunkRange?.endIndex, + selectedHunkRange, + ]); + + // File index for display + const fileIndex = activeFilePath ? fileList.indexOf(activeFilePath) : -1; + const fileCount = fileList.length; + + // --- Navigation callbacks --- + + const navigateFile = useCallback( + (direction: 1 | -1) => { + if (!activeFilePath || fileList.length <= 1) { + return; + } + + // Skip files with no currently visible hunks (e.g. filtered out by read/search filters). + // This keeps file navigation moving forward instead of getting stuck on empty files. + let candidatePath = activeFilePath; + for (let step = 0; step < fileList.length - 1; step += 1) { + const nextPath = getAdjacentFilePath(fileList, candidatePath, direction); + if (!nextPath) { + return; + } + + candidatePath = nextPath; + const fileHunks = sortHunksInFileOrder(getFileHunks(hunks, candidatePath)); + if (fileHunks.length > 0) { + hunkJumpRef.current = true; + onSelectHunk(fileHunks[0].id); + return; + } + } + }, + [activeFilePath, fileList, hunks, onSelectHunk] + ); + + const navigateHunk = useCallback( + (direction: 1 | -1) => { + if (currentFileHunks.length === 0) return; + + const currentIdx = selectedHunkId + ? currentFileHunks.findIndex((hunk) => hunk.id === selectedHunkId) + : -1; + + let nextIdx: number; + if (currentIdx === -1) { + nextIdx = direction === 1 ? 0 : currentFileHunks.length - 1; + } else { + nextIdx = currentIdx + direction; + if (nextIdx < 0 || nextIdx >= currentFileHunks.length) { + setBoundaryToast("No more hunks in this file — use H / L to move between files"); + return; + } + } + + hunkJumpRef.current = true; + onSelectHunk(currentFileHunks[nextIdx].id); + }, + [currentFileHunks, selectedHunkId, onSelectHunk] + ); + + const navigateToReview = useCallback( + (review: Review, options?: { startEditing?: boolean }) => { + const fileHunks = sortHunksInFileOrder(getFileHunks(allHunks, review.data.filePath)); + if (fileHunks.length === 0) { + return; + } + + const targetHunkId = findReviewHunkId(review, fileHunks) ?? fileHunks[0].id; + hunkJumpRef.current = true; + onSelectHunk(targetHunkId); + // Force scroll effect to re-fire even when activeLineIndex is unchanged + // (for example when the cursor is already inside the selected hunk). + setScrollNonce((previousNonce) => previousNonce + 1); + + if (options?.startEditing && props.reviewActions?.onEditComment) { + nextInlineReviewEditRequestIdRef.current += 1; + setInlineReviewEditRequest({ + requestId: nextInlineReviewEditRequestIdRef.current, + reviewId: review.id, + }); + } + }, + [allHunks, onSelectHunk, props.reviewActions?.onEditComment] + ); + + const diffReviewActions = useMemo(() => { + if (!props.reviewActions) { + return undefined; + } + + return { + ...props.reviewActions, + onEditingChange: (reviewId: string, isEditing: boolean) => { + props.reviewActions?.onEditingChange?.(reviewId, isEditing); + if (isEditing) { + setInlineReviewEditRequest((currentRequest) => + currentRequest?.reviewId === reviewId ? null : currentRequest + ); + } + }, + }; + }, [props.reviewActions]); + + const getCurrentLineSelection = useCallback((): SelectedLineRange | null => { + if (activeLineIndex === null) { + return null; + } + + if (!selectedLineRange) { + return { startIndex: activeLineIndex, endIndex: activeLineIndex }; + } + + return { + startIndex: Math.min(selectedLineRange.startIndex, selectedLineRange.endIndex), + endIndex: Math.max(selectedLineRange.startIndex, selectedLineRange.endIndex), + }; + }, [activeLineIndex, selectedLineRange]); + + const selectedLineSummary = getCurrentLineSelection(); + + const openComposer = useCallback( + (prefill: string) => { + const selection = getCurrentLineSelection(); + pendingComposerHunkSwitchRef.current = null; + + // Resolve which hunk to attach the composer to. If the cursor has moved + // into a different hunk (e.g. via arrow-key line navigation), look it up + // from the overlay's lineHunkIds rather than using the stale selectedHunk. + let targetHunk = selectedHunk; + let targetRange = selectedHunkRange; + let targetSelectionEnd = selection?.endIndex ?? null; + + if (selection && (!targetRange || !isSelectionInsideRange(selection, targetRange))) { + const resolved = findHunkAtLine(selection.endIndex, overlayData, currentFileHunks); + if (resolved) { + targetHunk = resolved.hunk; + targetRange = resolved.range; + targetSelectionEnd = Math.max( + resolved.range.startIndex, + Math.min(resolved.range.endIndex, selection.endIndex) + ); + // Keep cursor state anchored to the intended target line before + // hunk-selection effects run; this avoids snapping to hunk start. + setActiveLineIndex(targetSelectionEnd); + + const currentSelectedHunkId = selectedHunkIdRef.current; + if (resolved.hunk.id !== currentSelectedHunkId) { + // Record the in-flight hunk switch so mismatch guards do not clear + // this composer request before onSelectHunk propagates. + pendingComposerHunkSwitchRef.current = { + fromHunkId: currentSelectedHunkId, + toHunkId: resolved.hunk.id, + }; + onSelectHunk(resolved.hunk.id); + } + } + } + + if (!targetHunk || !targetRange) { + return; + } + + const fallbackLineIndex = + targetSelectionEnd === null + ? targetRange.startIndex + : Math.max(targetRange.startIndex, Math.min(targetRange.endIndex, targetSelectionEnd)); + + const effectiveSelection = + selection && isSelectionInsideRange(selection, targetRange) + ? selection + : { + startIndex: fallbackLineIndex, + endIndex: fallbackLineIndex, + }; + + nextComposerRequestIdRef.current += 1; + setInlineComposerRequest({ + requestId: nextComposerRequestIdRef.current, + prefill, + hunkId: targetHunk.id, + startOffset: effectiveSelection.startIndex - targetRange.startIndex, + endOffset: effectiveSelection.endIndex - targetRange.startIndex, + }); + }, + [ + getCurrentLineSelection, + selectedHunk, + selectedHunkRange, + overlayData, + currentFileHunks, + onSelectHunk, + ] + ); + + const handleReviewNoteSubmit = useCallback( + (data: ReviewNoteData) => { + onReviewNote?.(data); + // DiffRenderer clears its internal selection after submit, but immersive mode may + // still keep an external selection request active. Clear it to close the composer + // and prevent accidental duplicate submissions on repeated Enter presses. + setInlineComposerRequest(null); + containerRef.current?.focus(); + }, + [onReviewNote] + ); + + const handleInlineComposerCancel = useCallback(() => { + // Keep immersive parent state aligned with child composer teardown so canceled + // keyboard-initiated requests do not linger or steal focus. + setInlineComposerRequest(null); + containerRef.current?.focus(); + }, []); + + const moveLineCursor = useCallback( + (delta: number, extendRange: boolean) => { + const lineCount = overlayData.lineHunkIds.length; + if (lineCount === 0) { + return; + } + + const currentIndex = activeLineIndexRef.current ?? selectedHunkRange?.startIndex ?? 0; + const nextIndex = Math.max(0, Math.min(lineCount - 1, currentIndex + delta)); + + setActiveLineIndex(nextIndex); + + if (extendRange) { + const anchorIndex = selectedLineRangeRef.current?.startIndex ?? currentIndex; + setSelectedLineRange({ startIndex: anchorIndex, endIndex: nextIndex }); + } else { + setSelectedLineRange(null); + } + + const lineHunkId = overlayData.lineHunkIds[nextIndex]; + if (lineHunkId && lineHunkId !== selectedHunkIdRef.current) { + onSelectHunk(lineHunkId); + } + }, + [overlayData.lineHunkIds, selectedHunkRange?.startIndex, onSelectHunk] + ); + + const handleLineIndexSelect = useCallback( + (lineIndex: number, shiftKey: boolean) => { + const resolvedHunk = findHunkAtLine(lineIndex, overlayData, currentFileHunks); + if (resolvedHunk && selectedHunkIdRef.current !== resolvedHunk.hunk.id) { + onSelectHunk(resolvedHunk.hunk.id); + } + + const anchorIndex = shiftKey + ? (selectedLineRangeRef.current?.startIndex ?? activeLineIndexRef.current ?? lineIndex) + : lineIndex; + setActiveLineIndex(lineIndex); + + if (shiftKey) { + setSelectedLineRange({ startIndex: anchorIndex, endIndex: lineIndex }); + } else { + setSelectedLineRange(null); + } + }, + [overlayData, currentFileHunks, onSelectHunk] + ); + + // Auto-focus container on mount + useEffect(() => { + containerRef.current?.focus(); + }, []); + + // --- Keyboard handler --- + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + // Tab: toggle between diff and notes panels. + if (matchesKeybind(e, KEYBINDS.REVIEW_FOCUS_NOTES)) { + // Keep normal tab behavior when typing in inline note editors. + if (isEditableElement(e.target)) return; + e.preventDefault(); + if (focusedPanel === "diff") { + if (allReviews.length > 0) { + setFocusedPanel("notes"); + } + } else { + setFocusedPanel("diff"); + containerRef.current?.focus(); + } + return; + } + + // --- Notes sidebar keyboard mode --- + if (focusedPanel === "notes") { + // Don't intercept when typing in editable elements. + if (isEditableElement(e.target)) return; + + // Esc: return to diff panel (not exit immersive). + if (matchesKeybind(e, KEYBINDS.CANCEL)) { + e.preventDefault(); + setFocusedPanel("diff"); + containerRef.current?.focus(); + return; + } + + // J / ArrowDown: next note. + if (e.key === "j" || e.key === "ArrowDown") { + e.preventDefault(); + const maxNoteIndex = Math.max(0, allReviews.length - 1); + setFocusedNoteIndex((previousIndex) => Math.min(maxNoteIndex, previousIndex + 1)); + return; + } + + // K / ArrowUp: previous note. + if (e.key === "k" || e.key === "ArrowUp") { + e.preventDefault(); + setFocusedNoteIndex((previousIndex) => Math.max(0, previousIndex - 1)); + return; + } + + // Enter: navigate to focused note in diff and return to diff panel. + if (e.key === "Enter") { + e.preventDefault(); + const note = allReviews[focusedNoteIndex]; + if (note) { + navigateToReview(note); + setFocusedPanel("diff"); + containerRef.current?.focus(); + } + return; + } + + if (e.key === "e" || e.key === "E") { + e.preventDefault(); + const note = allReviews[focusedNoteIndex]; + if (note) { + // Keep note triage keyboard-first: jump directly from notes list into + // editing the exact inline note comment in the diff pane. + navigateToReview(note, { startEditing: true }); + setFocusedPanel("diff"); + containerRef.current?.focus(); + } + return; + } + + // Backspace/Delete: delete focused note. + if (e.key === "Backspace" || e.key === "Delete") { + e.preventDefault(); + const note = allReviews[focusedNoteIndex]; + if (note && props.reviewActions?.onDelete) { + props.reviewActions.onDelete(note.id); + } + return; + } + + // Swallow all other keys in notes mode so diff shortcuts do not fire. + return; + } + + // --- Diff panel keyboard mode --- + // Don't intercept when typing in editable elements + if (isEditableElement(e.target)) return; + + // Esc: exit immersive + if (matchesKeybind(e, KEYBINDS.CANCEL)) { + e.preventDefault(); + onExit(); + return; + } + + // L/H: next/prev file + if (matchesKeybind(e, KEYBINDS.REVIEW_NEXT_FILE)) { + e.preventDefault(); + navigateFile(1); + return; + } + if (matchesKeybind(e, KEYBINDS.REVIEW_PREV_FILE)) { + e.preventDefault(); + navigateFile(-1); + return; + } + + // J/K: next/prev hunk + if (matchesKeybind(e, KEYBINDS.REVIEW_NEXT_HUNK)) { + e.preventDefault(); + navigateHunk(1); + return; + } + if (matchesKeybind(e, KEYBINDS.REVIEW_PREV_HUNK)) { + e.preventDefault(); + navigateHunk(-1); + return; + } + + // Arrow line cursor controls + if (matchesKeybind(e, KEYBINDS.REVIEW_CURSOR_JUMP_DOWN)) { + e.preventDefault(); + moveLineCursor(LINE_JUMP_SIZE, e.shiftKey); + return; + } + if (matchesKeybind(e, KEYBINDS.REVIEW_CURSOR_JUMP_UP)) { + e.preventDefault(); + moveLineCursor(-LINE_JUMP_SIZE, e.shiftKey); + return; + } + if (matchesKeybind(e, KEYBINDS.REVIEW_CURSOR_DOWN)) { + e.preventDefault(); + moveLineCursor(1, e.shiftKey); + return; + } + if (matchesKeybind(e, KEYBINDS.REVIEW_CURSOR_UP)) { + e.preventDefault(); + moveLineCursor(-1, e.shiftKey); + return; + } + + // Shift+C: add comment + if (matchesKeybind(e, KEYBINDS.REVIEW_COMMENT)) { + e.preventDefault(); + openComposer(""); + return; + } + + // Shift+L: quick like + if (matchesKeybind(e, KEYBINDS.REVIEW_QUICK_LIKE)) { + e.preventDefault(); + openComposer(LIKE_NOTE_PREFIX); + return; + } + + // Shift+D: quick dislike + if (matchesKeybind(e, KEYBINDS.REVIEW_QUICK_DISLIKE)) { + e.preventDefault(); + openComposer(DISLIKE_NOTE_PREFIX); + return; + } + + // Toggle hunk read + if (matchesKeybind(e, KEYBINDS.TOGGLE_HUNK_READ)) { + e.preventDefault(); + if (selectedHunkId) onToggleRead(selectedHunkId); + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [ + focusedPanel, + allReviews, + focusedNoteIndex, + navigateToReview, + props.reviewActions, + onExit, + navigateFile, + navigateHunk, + moveLineCursor, + openComposer, + selectedHunkId, + onToggleRead, + ]); + + const previousContentRef = useRef(overlayData.content); + + // Keep the active line visible while moving with keyboard shortcuts, without + // forcing the full diff tree to re-render on every cursor move. + useEffect(() => { + const contentChanged = previousContentRef.current !== overlayData.content; + previousContentRef.current = overlayData.content; + + const previousLineElement = highlightedLineElementRef.current; + if (previousLineElement) { + previousLineElement.style.outline = ""; + previousLineElement.style.outlineOffset = ""; + highlightedLineElementRef.current = null; + } + + // When overlay content structure changes (fallback hunks -> full-file view), + // defer regular scrolling until the selected-hunk effect has recalculated + // activeLineIndex. During a file-switch reveal gate we still need one initial + // scroll so the diff appears already positioned at the selected hunk. + if (contentChanged) { + hunkJumpRef.current = true; + if (!isActiveFileRevealPending) { + return; + } + } + + if (isActiveFileRevealPending && !isActiveFileContentSettled) { + return; + } + + const lineIndexForScroll = isActiveFileRevealPending ? revealTargetLineIndex : activeLineIndex; + if (lineIndexForScroll === null) { + return; + } + + const lineElement = containerRef.current?.querySelector( + `[data-line-index="${lineIndexForScroll}"]` + ); + if (!lineElement) { + if (!isActiveFileRevealPending || !activeFilePath || contentChanged) { + return; + } + + if (revealAnimationFrameRef.current !== null) { + cancelAnimationFrame(revealAnimationFrameRef.current); + } + + const revealFilePath = activeFilePath; + revealAnimationFrameRef.current = window.requestAnimationFrame(() => { + setPendingRevealFilePath((pendingFilePath) => + pendingFilePath === revealFilePath ? null : pendingFilePath + ); + revealAnimationFrameRef.current = null; + }); + return; + } + + if (activeLineIndex !== null && lineIndexForScroll === activeLineIndex) { + lineElement.style.outline = ACTIVE_LINE_OUTLINE; + lineElement.style.outlineOffset = "-1px"; + highlightedLineElementRef.current = lineElement; + } + + const block = hunkJumpRef.current ? "center" : "nearest"; + hunkJumpRef.current = false; + lineElement.scrollIntoView({ behavior: "auto", block }); + + if (!isActiveFileRevealPending || !activeFilePath) { + return; + } + + if (revealAnimationFrameRef.current !== null) { + cancelAnimationFrame(revealAnimationFrameRef.current); + } + + const revealFilePath = activeFilePath; + revealAnimationFrameRef.current = window.requestAnimationFrame(() => { + setPendingRevealFilePath((pendingFilePath) => + pendingFilePath === revealFilePath ? null : pendingFilePath + ); + revealAnimationFrameRef.current = null; + }); + }, [ + activeFilePath, + activeLineIndex, + isActiveFileContentSettled, + isActiveFileRevealPending, + overlayData.content, + revealTargetLineIndex, + scrollNonce, + ]); + + useEffect(() => { + return () => { + const previousLineElement = highlightedLineElementRef.current; + if (!previousLineElement) { + return; + } + + previousLineElement.style.outline = ""; + previousLineElement.style.outlineOffset = ""; + highlightedLineElementRef.current = null; + }; + }, []); + + const currentHunkIdx = selectedHunkId + ? currentFileHunks.findIndex((hunk) => hunk.id === selectedHunkId) + : -1; + + const selectedLineSummaryLabel = useMemo(() => { + if (!selectedLineSummary) { + return "–"; + } + + if (!selectedHunkRange || !isSelectionInsideRange(selectedLineSummary, selectedHunkRange)) { + return `${selectedLineSummary.startIndex + 1}-${selectedLineSummary.endIndex + 1}`; + } + + const relativeStart = selectedLineSummary.startIndex - selectedHunkRange.startIndex + 1; + const relativeEnd = selectedLineSummary.endIndex - selectedHunkRange.startIndex + 1; + return `${relativeStart}-${relativeEnd}`; + }, [selectedLineSummary, selectedHunkRange]); + + const externalComposerSelectionRequest = useMemo(() => { + if (!inlineComposerRequest || !selectedHunk || !selectedHunkRange) { + return null; + } + + if (inlineComposerRequest.hunkId !== selectedHunk.id) { + return null; + } + + const clampToHunk = (lineIndex: number) => + Math.max(selectedHunkRange.startIndex, Math.min(selectedHunkRange.endIndex, lineIndex)); + + return { + requestId: inlineComposerRequest.requestId, + selection: { + startIndex: clampToHunk(selectedHunkRange.startIndex + inlineComposerRequest.startOffset), + endIndex: clampToHunk(selectedHunkRange.startIndex + inlineComposerRequest.endOffset), + }, + initialNoteText: inlineComposerRequest.prefill, + }; + }, [inlineComposerRequest, selectedHunk, selectedHunkRange]); + + const shouldEnableHighlighting = overlayData.lineHunkIds.length <= MAX_HIGHLIGHTED_DIFF_LINES; + + const shouldShowFileTransitionSplash = + currentFileHunks.length > 0 && (isActiveFileContentLoading || isActiveFileRevealPending); + + return ( +
+ {/* Header */} +
+ {/* Back button */} + + +
+ + {/* File navigation */} +
+ + + {activeFilePath ?? "No files"} + + + {fileIndex >= 0 ? `${fileIndex + 1}/${fileCount}` : ""} + + +
+ +
+ + {/* Hunk and line selection summary */} +
+ {selectedHunk && ( + + )} + + Hunk {currentHunkIdx >= 0 ? currentHunkIdx + 1 : "–"}/{currentFileHunks.length} + + · + Lines {selectedLineSummaryLabel} + {selectedHunkLineCount > 0 && ( + <> + · + {selectedHunkLineCount} lines + + )} +
+
+ + {/* Unified whole-file diff with hunk overlays + notes sidebar */} +
+
+ {props.isLoading && currentFileHunks.length === 0 ? ( +
+ Loading diff... +
+ ) : currentFileHunks.length === 0 ? ( +
+ {activeFilePath ? "No hunks for this file" : "No files to review"} +
+ ) : ( +
+ {shouldShowFileTransitionSplash && ( +
+ Loading file... +
+ )} +
+ +
+
+ )} +
+ + +
+ + {/* Boundary toast */} + {boundaryToast && ( +
+
+ {boundaryToast} +
+
+ )} + + {/* Shortcut bar */} +
+ + + + + + + + + + +
+
+ ); +}; diff --git a/src/browser/components/RightSidebar/CodeReview/ReviewControls.tsx b/src/browser/components/RightSidebar/CodeReview/ReviewControls.tsx index f3f2420995..445521496e 100644 --- a/src/browser/components/RightSidebar/CodeReview/ReviewControls.tsx +++ b/src/browser/components/RightSidebar/CodeReview/ReviewControls.tsx @@ -3,7 +3,11 @@ */ import React from "react"; +import { ArrowLeft, Maximize2 } from "lucide-react"; import { usePersistedState } from "@/browser/hooks/usePersistedState"; +import { useTutorial } from "@/browser/contexts/TutorialContext"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/browser/components/ui/tooltip"; +import { KEYBINDS, formatKeybind } from "@/browser/utils/ui/keybinds"; import { STORAGE_KEYS, WORKSPACE_DEFAULTS } from "@/constants/workspaceDefaults"; import type { ReviewFilters, ReviewStats, ReviewSortOrder } from "@/common/types/review"; import type { LastRefreshInfo } from "@/browser/utils/RefreshController"; @@ -26,6 +30,10 @@ interface ReviewControlsProps { projectPath: string; /** Debug info about last refresh */ lastRefreshInfo?: LastRefreshInfo | null; + /** Whether immersive review mode is active */ + isImmersive?: boolean; + /** Toggle immersive review mode */ + onToggleImmersive?: () => void; } export const ReviewControls: React.FC = ({ @@ -37,6 +45,8 @@ export const ReviewControls: React.FC = ({ isRefreshBlocked = false, projectPath, lastRefreshInfo, + isImmersive = false, + onToggleImmersive, }) => { // Per-project default base (used for new workspaces in this project) const [defaultBase, setDefaultBase] = usePersistedState( @@ -44,6 +54,14 @@ export const ReviewControls: React.FC = ({ WORKSPACE_DEFAULTS.reviewBase, { listener: true } ); + const { startSequence } = useTutorial(); + + // Show the immersive review tutorial the first time the review panel is visible + React.useEffect(() => { + // Small delay to ensure the button is rendered and measurable + const timer = setTimeout(() => startSequence("review"), 500); + return () => clearTimeout(timer); + }, [startSequence]); // Use callback form to avoid stale closure issues with filters prop const handleBaseChange = (value: string) => { @@ -146,7 +164,33 @@ export const ReviewControls: React.FC = ({ - + {onToggleImmersive && ( + <> +
+ + + + + + {isImmersive ? "Exit" : "Enter"} immersive review ( + {formatKeybind(KEYBINDS.TOGGLE_REVIEW_IMMERSIVE)}) + + + + )} + + {stats.read}/{stats.total}
diff --git a/src/browser/components/RightSidebar/CodeReview/ReviewPanel.tsx b/src/browser/components/RightSidebar/CodeReview/ReviewPanel.tsx index 295575a6e4..9bc0dc9d09 100644 --- a/src/browser/components/RightSidebar/CodeReview/ReviewPanel.tsx +++ b/src/browser/components/RightSidebar/CodeReview/ReviewPanel.tsx @@ -25,9 +25,11 @@ import { LRUCache } from "lru-cache"; import { AlertTriangle, Lightbulb, Loader2 } from "lucide-react"; import React, { useState, useEffect, useMemo, useCallback, useRef } from "react"; +import { createPortal } from "react-dom"; import { HunkViewer } from "./HunkViewer"; import type { ReviewActionCallbacks } from "../../shared/InlineReviewNote"; import { ReviewControls } from "./ReviewControls"; +import { ImmersiveReviewView } from "./ImmersiveReviewView"; import { FileTree } from "./FileTree"; import { UntrackedStatus } from "./UntrackedStatus"; import { shellQuote } from "@/common/utils/shell"; @@ -38,7 +40,11 @@ import { useReviews } from "@/browser/hooks/useReviews"; import { useHunkFirstSeen } from "@/browser/hooks/useHunkFirstSeen"; import { RefreshController, type LastRefreshInfo } from "@/browser/utils/RefreshController"; import { parseDiff, extractAllHunks, buildGitDiffCommand } from "@/common/utils/git/diffParser"; -import { getReviewSearchStateKey, REVIEW_SORT_ORDER_KEY } from "@/common/constants/storage"; +import { + getReviewImmersiveKey, + getReviewSearchStateKey, + REVIEW_SORT_ORDER_KEY, +} from "@/common/constants/storage"; import { Tooltip, TooltipTrigger, TooltipContent } from "@/browser/components/ui/tooltip"; import { parseNumstat, buildFileTree, extractNewPath } from "@/common/utils/git/numstatParser"; import { parseNameStatus } from "@/common/utils/git/nameStatusParser"; @@ -364,6 +370,17 @@ export const ReviewPanel: React.FC = ({ removeReview, } = useReviews(workspaceId); + // Immersive review mode - persisted so WorkspaceShell overlay can react + const [isImmersive, setIsImmersive] = usePersistedState( + getReviewImmersiveKey(workspaceId), + false, + { listener: true } + ); + + const toggleImmersive = useCallback(() => { + setIsImmersive((prev) => !prev); + }, [setIsImmersive]); + const reviewsByFilePath = useMemo(() => { const grouped = new Map(); @@ -1081,6 +1098,9 @@ export const ReviewPanel: React.FC = ({ } } + // Immersive mode has its own keyboard handler; don't double-handle + if (isImmersive) return; + if (!selectedHunkId) return; const currentIndex = filteredHunks.findIndex((h) => h.id === selectedHunkId); @@ -1134,9 +1154,10 @@ export const ReviewPanel: React.FC = ({ handleMarkAsRead, handleMarkAsUnread, handleMarkFileAsRead, + isImmersive, ]); - // Global keyboard shortcuts (Ctrl+R / Cmd+R for refresh, Ctrl+F / Cmd+F for search) + // Global keyboard shortcuts (refresh/search) useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (matchesKeybind(e, KEYBINDS.REFRESH_REVIEW)) { @@ -1190,6 +1211,8 @@ export const ReviewPanel: React.FC = ({ diffState.status === "loading" || diffState.status === "refreshing" || isLoadingTree } isRefreshBlocked={isRefreshBlocked} + isImmersive={isImmersive} + onToggleImmersive={toggleImmersive} projectPath={projectPath} lastRefreshInfo={lastRefreshInfo} /> @@ -1392,6 +1415,35 @@ export const ReviewPanel: React.FC = ({
)} + + {/* Immersive review mode: render into workspace overlay */} + {isImmersive && + (() => { + const root = + typeof document !== "undefined" + ? document.getElementById("review-immersive-root") + : null; + if (!root) return null; + return createPortal( + , + root + ); + })()}
); }; diff --git a/src/browser/components/Settings/sections/KeybindsSection.tsx b/src/browser/components/Settings/sections/KeybindsSection.tsx index c990f3f2a7..31ad2df69f 100644 --- a/src/browser/components/Settings/sections/KeybindsSection.tsx +++ b/src/browser/components/Settings/sections/KeybindsSection.tsx @@ -59,6 +59,19 @@ const KEYBIND_LABELS: Record = { // Modal-only keybinds; intentionally omitted from KEYBIND_GROUPS. CONFIRM_DIALOG_YES: "Confirm dialog action", CONFIRM_DIALOG_NO: "Cancel dialog action", + TOGGLE_REVIEW_IMMERSIVE: "Toggle immersive review", + REVIEW_NEXT_FILE: "Next file (immersive)", + REVIEW_PREV_FILE: "Previous file (immersive)", + REVIEW_NEXT_HUNK: "Next hunk (immersive)", + REVIEW_PREV_HUNK: "Previous hunk (immersive)", + REVIEW_CURSOR_DOWN: "Line cursor down (immersive)", + REVIEW_CURSOR_UP: "Line cursor up (immersive)", + REVIEW_CURSOR_JUMP_DOWN: "Jump 10 lines down (immersive)", + REVIEW_CURSOR_JUMP_UP: "Jump 10 lines up (immersive)", + REVIEW_QUICK_LIKE: "Quick like (immersive)", + REVIEW_QUICK_DISLIKE: "Quick dislike (immersive)", + REVIEW_COMMENT: "Add comment (immersive)", + REVIEW_FOCUS_NOTES: "Focus notes sidebar (immersive)", // Easter egg keybind; intentionally omitted from KEYBIND_GROUPS. TOGGLE_POWER_MODE: "", }; @@ -141,6 +154,20 @@ const KEYBIND_GROUPS: Array<{ label: string; keys: Array "TOGGLE_HUNK_COLLAPSE", ], }, + { + label: "Immersive Review", + keys: [ + "TOGGLE_REVIEW_IMMERSIVE", + "REVIEW_NEXT_FILE", + "REVIEW_PREV_FILE", + "REVIEW_NEXT_HUNK", + "REVIEW_PREV_HUNK", + "REVIEW_QUICK_LIKE", + "REVIEW_QUICK_DISLIKE", + "REVIEW_COMMENT", + "REVIEW_FOCUS_NOTES", + ], + }, { label: "External", keys: ["OPEN_TERMINAL", "OPEN_IN_EDITOR"], diff --git a/src/browser/components/WorkspaceShell.tsx b/src/browser/components/WorkspaceShell.tsx index 18ef023be2..b33635aae7 100644 --- a/src/browser/components/WorkspaceShell.tsx +++ b/src/browser/components/WorkspaceShell.tsx @@ -1,10 +1,11 @@ import type { TerminalSessionCreateOptions } from "@/browser/utils/terminal"; import React, { useCallback, useRef } from "react"; import { cn } from "@/common/lib/utils"; -import { RIGHT_SIDEBAR_WIDTH_KEY } from "@/common/constants/storage"; +import { RIGHT_SIDEBAR_WIDTH_KEY, getReviewImmersiveKey } from "@/common/constants/storage"; import { useResizableSidebar } from "@/browser/hooks/useResizableSidebar"; import { useResizeObserver } from "@/browser/hooks/useResizeObserver"; import { useOpenTerminal } from "@/browser/hooks/useOpenTerminal"; +import { usePersistedState } from "@/browser/hooks/usePersistedState"; import { RightSidebar } from "./RightSidebar"; import { PopoverError } from "./PopoverError"; import type { RuntimeConfig } from "@/common/types/runtime"; @@ -127,6 +128,9 @@ export const WorkspaceShell: React.FC = (props) => { ); const workspaceState = useWorkspaceState(props.workspaceId); + const [isReviewImmersive] = usePersistedState(getReviewImmersiveKey(props.workspaceId), false, { + listener: true, + }); const backgroundBashError = useBackgroundBashError(); if (!workspaceState || workspaceState.loading) { @@ -147,7 +151,7 @@ export const WorkspaceShell: React.FC = (props) => {
= (props) => { onToggleLeftSidebarCollapsed={props.onToggleLeftSidebarCollapsed} runtimeConfig={props.runtimeConfig} onOpenTerminal={handleOpenTerminal} + immersiveHidden={isReviewImmersive} /> = (props) => { isResizing={isResizing} onReviewNote={handleReviewNote} isCreating={props.isInitializing === true} + immersiveHidden={isReviewImmersive} addTerminalRef={addTerminalRef} /> + {/* Portal target for immersive review mode overlay */} + ); @@ -931,6 +970,12 @@ export const SelectableDiffRenderer = React.memo( enableHighlighting = true, onComposingChange, reviewActions, + activeLineIndex, + selectedLineRange, + onLineIndexSelect, + externalSelectionRequest, + externalEditRequest, + onComposerCancel, }) => { const dragAnchorRef = React.useRef(null); const [isDragging, setIsDragging] = React.useState(false); @@ -951,11 +996,62 @@ export const SelectableDiffRenderer = React.memo( }, []); const { theme } = useTheme(); const [selection, setSelection] = React.useState(null); + const [selectionInitialNoteText, setSelectionInitialNoteText] = React.useState(""); + + const lastExternalSelectionRequestIdRef = React.useRef(null); + const dismissedExternalSelectionRequestIdRef = React.useRef(null); + + React.useEffect(() => { + if (!externalSelectionRequest) { + if (lastExternalSelectionRequestIdRef.current !== null) { + lastExternalSelectionRequestIdRef.current = null; + setSelection(null); + setSelectionInitialNoteText(""); + } + return; + } + + // If the composer was closed for this request ID, keep it dismissed even + // if the parent prop lingers and re-renders before clearing. + if (dismissedExternalSelectionRequestIdRef.current === externalSelectionRequest.requestId) { + return; + } + + // Reset only when a new request arrives; selection churn from the parent + // for the same request should not wipe an in-progress composer draft. + if (lastExternalSelectionRequestIdRef.current === externalSelectionRequest.requestId) { + return; + } + + lastExternalSelectionRequestIdRef.current = externalSelectionRequest.requestId; + setSelection({ + startIndex: externalSelectionRequest.selection.startIndex, + endIndex: externalSelectionRequest.selection.endIndex, + }); + setSelectionInitialNoteText(externalSelectionRequest.initialNoteText ?? ""); + }, [externalSelectionRequest]); + + // Render newly-issued external composer requests immediately so immersive actions + // don't show a one-frame delay while local state catches up in the effect above. + const pendingExternalSelectionRequest = + externalSelectionRequest && + dismissedExternalSelectionRequestIdRef.current !== externalSelectionRequest.requestId && + lastExternalSelectionRequestIdRef.current !== externalSelectionRequest.requestId + ? externalSelectionRequest + : null; + + const renderSelection: LineSelection | null = + pendingExternalSelectionRequest?.selection ?? selection; + const renderNoteText = pendingExternalSelectionRequest + ? (pendingExternalSelectionRequest.initialNoteText ?? "") + : selectionInitialNoteText; + const renderSelectionStartIndex = renderSelection?.startIndex ?? null; // Notify parent when composition state changes + const isComposing = renderSelection !== null; React.useEffect(() => { - onComposingChange?.(selection !== null); - }, [selection, onComposingChange]); + onComposingChange?.(isComposing); + }, [isComposing, onComposingChange]); // On unmount, ensure we release the pause if we were composing // (separate effect with empty deps so cleanup only runs on unmount) @@ -1097,15 +1193,18 @@ export const SelectableDiffRenderer = React.memo( return; } - // Notify parent that this hunk should become active + // Notify parent that this hunk should become active. onLineClick?.(); + onLineIndexSelect?.(lineIndex, shiftKey); - const anchor = shiftKey && selection ? selection.startIndex : lineIndex; + const anchor = + shiftKey && renderSelectionStartIndex !== null ? renderSelectionStartIndex : lineIndex; dragAnchorRef.current = anchor; setIsDragging(true); + setSelectionInitialNoteText(""); setSelection({ startIndex: anchor, endIndex: lineIndex }); }, - [onLineClick, onReviewNote, selection] + [onLineClick, onLineIndexSelect, onReviewNote, renderSelectionStartIndex] ); const updateDragSelection = React.useCallback( @@ -1114,18 +1213,21 @@ export const SelectableDiffRenderer = React.memo( return; } + onLineIndexSelect?.(lineIndex, true); setSelection({ startIndex: dragAnchorRef.current, endIndex: lineIndex }); }, - [isDragging] + [isDragging, onLineIndexSelect] ); const handleCommentButtonClick = (lineIndex: number, shiftKey: boolean) => { - // Notify parent that this hunk should become active + // Keep immersive cursor/hunk selection in sync with inline comment actions. onLineClick?.(); + onLineIndexSelect?.(lineIndex, shiftKey); // Shift-click: extend existing selection - if (shiftKey && selection) { - const start = selection.startIndex; + if (shiftKey && renderSelection) { + const start = renderSelection.startIndex; + setSelectionInitialNoteText(""); setSelection({ startIndex: start, endIndex: lineIndex, @@ -1134,6 +1236,7 @@ export const SelectableDiffRenderer = React.memo( } // Regular click: start new selection + setSelectionInitialNoteText(""); setSelection({ startIndex: lineIndex, endIndex: lineIndex, @@ -1142,17 +1245,29 @@ export const SelectableDiffRenderer = React.memo( const handleSubmitNote = (data: ReviewNoteData) => { if (!onReviewNote) return; + if (externalSelectionRequest) { + dismissedExternalSelectionRequestIdRef.current = externalSelectionRequest.requestId; + } onReviewNote(data); setSelection(null); + setSelectionInitialNoteText(""); }; const handleCancelNote = () => { + if (externalSelectionRequest) { + dismissedExternalSelectionRequestIdRef.current = externalSelectionRequest.requestId; + } setSelection(null); + setSelectionInitialNoteText(""); + onComposerCancel?.(); }; - const isLineSelected = (index: number) => { - if (!selection) return false; - const [start, end] = [selection.startIndex, selection.endIndex].sort((a, b) => a - b); + const isLineInSelection = (index: number, lineSelection: LineSelection | null | undefined) => { + if (!lineSelection) { + return false; + } + + const [start, end] = [lineSelection.startIndex, lineSelection.endIndex].sort((a, b) => a - b); return index >= start && index <= end; }; @@ -1160,9 +1275,12 @@ export const SelectableDiffRenderer = React.memo( const firstLineType = highlightedLineData[0]?.type; const lastLineType = highlightedLineData[highlightedLineData.length - 1]?.type; - // Selection highlight overlay - applied via box-shadow to avoid affecting grid layout - const selectionHighlight = + // Selection highlights are applied via box-shadow to avoid affecting grid layout. + const reviewSelectionHighlight = "inset 0 0 0 100vmax hsl(from var(--color-review-accent) h s l / 0.16)"; + 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)"; return ( ( lastLineType={lastLineType} > {highlightedLineData.map((lineInfo, displayIndex) => { - const isSelected = isLineSelected(displayIndex); + const isComposerSelected = isLineInSelection(displayIndex, renderSelection); + const isRangeSelected = isLineInSelection(displayIndex, selectedLineRange); + const isActiveLine = activeLineIndex === displayIndex; const isInReviewRange = reviewRangeByLineIndex[displayIndex] ?? false; const baseCodeBg = getDiffLineBackground(lineInfo.type); const codeBg = applyReviewRangeOverlay(baseCodeBg, isInReviewRange); @@ -1183,6 +1303,16 @@ export const SelectableDiffRenderer = React.memo( ); const anchoredReviews = inlineReviewsByAnchor.get(displayIndex); + const lineShadows: string[] = []; + if (isComposerSelected) { + lineShadows.push(reviewSelectionHighlight); + } else if (isRangeSelected) { + lineShadows.push(rangeSelectionHighlight); + } + if (isActiveLine) { + lineShadows.push(activeLineHighlight); + } + // Each line renders as 3 CSS Grid cells: gutter | indicator | code // Use display:contents wrapper for selection state + group hover behavior return ( @@ -1190,9 +1320,18 @@ export const SelectableDiffRenderer = React.memo(
{ + if (!onLineIndexSelect) { + return; + } + onLineClick?.(); + onLineIndexSelect(displayIndex, e.shiftKey); + }} > ( type={lineInfo.type} background={codeBg} lineIndex={displayIndex} - isInteractive={Boolean(onReviewNote)} + isInteractive={Boolean(onReviewNote ?? onLineIndexSelect)} onMouseDown={(e) => { if (!onReviewNote) return; if (e.button !== 0) return; @@ -1221,25 +1360,19 @@ export const SelectableDiffRenderer = React.memo( }} reviewButton={ onReviewNote && ( - - - - - - Add review comment -
- (Shift-click or drag to select range) -
-
+ ) } /> @@ -1248,18 +1381,18 @@ export const SelectableDiffRenderer = React.memo( style={{ background: codeBg, color: getLineContentColor(lineInfo.type), - boxShadow: isSelected ? selectionHighlight : undefined, + boxShadow: lineShadows.length > 0 ? lineShadows.join(", ") : undefined, }} dangerouslySetInnerHTML={{ __html: lineInfo.html }} />
{/* Show textarea after the last selected line */} - {isSelected && - selection && - displayIndex === Math.max(selection.startIndex, selection.endIndex) && ( + {isComposerSelected && + renderSelection && + displayIndex === Math.max(renderSelection.startIndex, renderSelection.endIndex) && ( ( lineNumberWidths={lineNumberWidths} onSubmit={handleSubmitNote} onCancel={handleCancelNote} + initialNoteText={renderNoteText} /> )} @@ -1279,6 +1413,11 @@ export const SelectableDiffRenderer = React.memo( lineNumberMode={lineNumberMode} lineNumberWidths={lineNumberWidths} reviewActions={reviewActions} + editRequestId={ + externalEditRequest?.reviewId === review.id + ? externalEditRequest.requestId + : null + } /> ))} diff --git a/src/browser/components/shared/InlineReviewNote.tsx b/src/browser/components/shared/InlineReviewNote.tsx index b9c3f2d7d2..5d15030627 100644 --- a/src/browser/components/shared/InlineReviewNote.tsx +++ b/src/browser/components/shared/InlineReviewNote.tsx @@ -11,6 +11,7 @@ import { Button } from "../ui/button"; import { Tooltip, TooltipTrigger, TooltipContent } from "../ui/tooltip"; import { formatLineRangeCompact } from "@/browser/utils/review/lineRange"; import { matchesKeybind, formatKeybind, KEYBINDS } from "@/browser/utils/ui/keybinds"; +import { stopKeyboardPropagation } from "@/browser/utils/events"; import { cn } from "@/common/lib/utils"; import type { Review } from "@/common/types/review"; @@ -43,6 +44,8 @@ export interface InlineReviewNoteProps { actions?: ReviewActionCallbacks; /** Additional className for the container */ className?: string; + /** Request id that should put this note into edit mode */ + editRequestId?: number | null; } // ═══════════════════════════════════════════════════════════════════════════════ @@ -58,12 +61,14 @@ export const InlineReviewNote: React.FC = ({ showFilePath = false, actions, className, + editRequestId, }) => { const [isEditing, setIsEditing] = useState(false); const [editValue, setEditValue] = useState(review.data.userNote); const textareaRef = useRef(null); const isEditingRef = useRef(false); const actionsRef = useRef(actions); + const handledEditRequestIdRef = useRef(null); useEffect(() => { actionsRef.current = actions; @@ -85,6 +90,19 @@ export const InlineReviewNote: React.FC = ({ setTimeout(() => textareaRef.current?.focus(), 0); }, [review.data.userNote, review.id, actions]); + useEffect(() => { + if (editRequestId == null || !actions?.onEditComment) { + return; + } + + if (handledEditRequestIdRef.current === editRequestId) { + return; + } + + handledEditRequestIdRef.current = editRequestId; + handleStartEdit(); + }, [actions?.onEditComment, editRequestId, handleStartEdit]); + const handleSaveEdit = useCallback(() => { if (actions?.onEditComment && editValue.trim() !== review.data.userNote) { actions.onEditComment(review.id, editValue.trim()); @@ -104,9 +122,11 @@ export const InlineReviewNote: React.FC = ({ const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { if (matchesKeybind(e, KEYBINDS.SAVE_EDIT)) { + stopKeyboardPropagation(e); e.preventDefault(); handleSaveEdit(); } else if (matchesKeybind(e, KEYBINDS.CANCEL_EDIT)) { + stopKeyboardPropagation(e); e.preventDefault(); handleCancelEdit(); } diff --git a/src/browser/components/ui/Keycap.tsx b/src/browser/components/ui/Keycap.tsx new file mode 100644 index 0000000000..9e7cafcca9 --- /dev/null +++ b/src/browser/components/ui/Keycap.tsx @@ -0,0 +1,99 @@ +/** + * Keycap - OS-aware keyboard key display component. + * Renders keyboard shortcuts as styled elements. + * Inspired by pulldash keycap patterns. + */ + +import React from "react"; +import { cn } from "@/common/lib/utils"; +import { isMac } from "@/browser/utils/ui/keybinds"; + +interface KeycapProps { + /** The key label to display. Special values: "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Escape", "Enter", "Shift", "Ctrl", "Cmd", "Alt" */ + children: string; + className?: string; +} + +const KEY_DISPLAY_MAP: Record = { + ArrowUp: "↑", + ArrowDown: "↓", + ArrowLeft: "←", + ArrowRight: "→", + Escape: "Esc", + Enter: "↵", + Shift: "⇧", + " ": "Space", +}; + +const MAC_KEY_MAP: Record = { + Ctrl: "⌘", + Alt: "⌥", + Cmd: "⌘", +}; + +const WIN_KEY_MAP: Record = { + Cmd: "Ctrl", +}; + +/** + * Single key cap (styled ) + */ +export const Keycap: React.FC = (props) => { + const onMac = isMac(); + let label = props.children; + + // Apply platform-specific mappings first + if (onMac && label in MAC_KEY_MAP) { + label = MAC_KEY_MAP[label]; + } else if (!onMac && label in WIN_KEY_MAP) { + label = WIN_KEY_MAP[label]; + } + + // Apply universal display mappings + if (label in KEY_DISPLAY_MAP) { + label = KEY_DISPLAY_MAP[label]; + } + + // Single letters: uppercase + if (label.length === 1 && /[a-z]/.test(label)) { + label = label.toUpperCase(); + } + + return ( + + {label} + + ); +}; + +interface KeycapGroupProps { + /** Array of key labels, rendered as a sequence of keycaps */ + keys: string[]; + /** Label to show after the keycaps */ + label?: string; + className?: string; +} + +/** + * Group of keycaps with an optional label. + * Renders keys as a tight group with a description. + * Example: + */ +export const KeycapGroup: React.FC = (props) => { + return ( + + + {props.keys.map((key, i) => ( + {key} + ))} + + {props.label && {props.label}} + + ); +}; diff --git a/src/browser/contexts/TutorialContext.tsx b/src/browser/contexts/TutorialContext.tsx index 32af3080a4..436bed0662 100644 --- a/src/browser/contexts/TutorialContext.tsx +++ b/src/browser/contexts/TutorialContext.tsx @@ -48,6 +48,14 @@ const TUTORIAL_SEQUENCES: Record = { position: "bottom", }, ], + review: [ + { + target: "immersive-review", + title: "Immersive Review", + content: "Try our new immersive keyboard-driven code review experience.", + position: "bottom", + }, + ], }; interface TutorialContextValue { diff --git a/src/browser/stories/App.rightSidebar.immersiveReview.stories.tsx b/src/browser/stories/App.rightSidebar.immersiveReview.stories.tsx new file mode 100644 index 0000000000..79322b20e3 --- /dev/null +++ b/src/browser/stories/App.rightSidebar.immersiveReview.stories.tsx @@ -0,0 +1,201 @@ +import { within, waitFor } from "@storybook/test"; +import type { ComponentType } from "react"; + +import { updatePersistedState } from "@/browser/hooks/usePersistedState"; +import { + RIGHT_SIDEBAR_TAB_KEY, + RIGHT_SIDEBAR_WIDTH_KEY, + getReviewImmersiveKey, + getRightSidebarLayoutKey, +} from "@/common/constants/storage"; + +import { appMeta, AppWithMocks, type AppStory } from "./meta.js"; +import { createAssistantMessage, createUserMessage } from "./mockFactory"; +import { expandRightSidebar, setupSimpleChatStory } from "./storyHelpers"; + +const LINE_HEIGHT_DEBUG_WORKSPACE_ID = "ws-review-immersive-line-height"; + +// Includes highlighted TypeScript lines and neutral/context lines so row-height +// differences are easy to compare while debugging immersive review rendering. +const IMMERSIVE_LINE_HEIGHT_DIFF = `diff --git a/src/utils/formatPrice.ts b/src/utils/formatPrice.ts +index 1111111..2222222 100644 +--- a/src/utils/formatPrice.ts ++++ b/src/utils/formatPrice.ts +@@ -1,10 +1,15 @@ + export function formatPrice(amount: number, currency = "USD"): string { ++ const formatter = new Intl.NumberFormat("en-US", { ++ style: "currency", ++ currency, ++ }); ++ + if (!Number.isFinite(amount)) { +- return "$0.00"; ++ return formatter.format(0); + } + +- return amount.toFixed(2); ++ return formatter.format(amount); + } + + // Keep this context line unchanged for neutral-row comparison. + export const DEFAULT_LOCALE = "en-US"; +`; + +const IMMERSIVE_LINE_HEIGHT_NUMSTAT = "7\t2\tsrc/utils/formatPrice.ts"; + +const HIGHLIGHT_VS_PLAIN_WORKSPACE_ID = "ws-review-immersive-highlight-vs-plain"; +const HIGHLIGHT_FALLBACK_THRESHOLD_BYTES = 32 * 1024; +const HIGHLIGHT_FALLBACK_BUFFER_BYTES = 1024; +const HIGHLIGHT_VS_PLAIN_NUMSTAT = "3\t2\tsrc/review/lineHeightProbe.ts"; + +function buildHighlightVsPlainDiffOutput(): string { + const oversizedContextLines: string[] = []; + let contextBytes = 0; + let lineIndex = 0; + + // Keep adding context lines until this single context chunk exceeds the + // 32kb highlight limit, forcing plain/fallback rendering for those rows. + while (contextBytes <= HIGHLIGHT_FALLBACK_THRESHOLD_BYTES + HIGHLIGHT_FALLBACK_BUFFER_BYTES) { + const contextLine = + `const fallbackProbe${lineIndex.toString().padStart(4, "0")} = createProbeEntry(` + + `"fallback-${lineIndex}", { index: ${lineIndex}, mode: "plain-context-chunk" });`; + oversizedContextLines.push(` ${contextLine}`); + contextBytes += contextLine.length + 1; + lineIndex += 1; + } + + return [ + "diff --git a/src/review/lineHeightProbe.ts b/src/review/lineHeightProbe.ts", + "index abcdef1..1234567 100644", + "--- a/src/review/lineHeightProbe.ts", + "+++ b/src/review/lineHeightProbe.ts", + "@@ -1,8 +1,9 @@", + "-export const BASE_ROW_HEIGHT = 20;", + "+export const BASE_ROW_HEIGHT = 22;", + '+export const ROW_HEIGHT_MODE = "immersive";', + " export function resolveRowHeight(scale = 1): number {", + " return BASE_ROW_HEIGHT * scale;", + " }", + ...oversizedContextLines, + '-export const ROW_HEIGHT_LABEL = "compact";', + '+export const ROW_HEIGHT_LABEL = "immersive";', + "", + ].join("\n"); +} + +export default { + ...appMeta, + title: "App/RightSidebar", + decorators: [ + (Story: ComponentType) => ( +
+ +
+ ), + ], + parameters: { + ...appMeta.parameters, + chromatic: { + ...(appMeta.parameters?.chromatic ?? {}), + modes: { + dark: { theme: "dark", viewport: 1600 }, + light: { theme: "light", viewport: 1600 }, + }, + }, + }, +}; + +export const ReviewTabImmersiveLineHeightDebug: AppStory = { + render: () => ( + { + localStorage.setItem(RIGHT_SIDEBAR_TAB_KEY, JSON.stringify("review")); + localStorage.setItem(RIGHT_SIDEBAR_WIDTH_KEY, "760"); + localStorage.removeItem(getRightSidebarLayoutKey(LINE_HEIGHT_DEBUG_WORKSPACE_ID)); + updatePersistedState(getReviewImmersiveKey(LINE_HEIGHT_DEBUG_WORKSPACE_ID), true); + + const client = setupSimpleChatStory({ + workspaceId: LINE_HEIGHT_DEBUG_WORKSPACE_ID, + workspaceName: "feature/immersive-line-height", + projectName: "my-app", + messages: [ + createUserMessage("msg-1", "Please review this formatter cleanup.", { + historySequence: 1, + }), + createAssistantMessage("msg-2", "Added Intl formatter and cleanup.", { + historySequence: 2, + }), + ], + gitDiff: { + diffOutput: IMMERSIVE_LINE_HEIGHT_DIFF, + numstatOutput: IMMERSIVE_LINE_HEIGHT_NUMSTAT, + }, + }); + + expandRightSidebar(); + return client; + }} + /> + ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await waitFor( + () => { + canvas.getByTestId("immersive-review-view"); + canvas.getByRole("button", { name: /exit immersive review/i }); + }, + { timeout: 10_000 } + ); + }, +}; + +export const ReviewTabImmersiveHighlightVsPlainHeight: AppStory = { + render: () => ( + { + localStorage.setItem(RIGHT_SIDEBAR_TAB_KEY, JSON.stringify("review")); + localStorage.setItem(RIGHT_SIDEBAR_WIDTH_KEY, "760"); + localStorage.removeItem(getRightSidebarLayoutKey(HIGHLIGHT_VS_PLAIN_WORKSPACE_ID)); + updatePersistedState(getReviewImmersiveKey(HIGHLIGHT_VS_PLAIN_WORKSPACE_ID), true); + + const diffOutput = buildHighlightVsPlainDiffOutput(); + const client = setupSimpleChatStory({ + workspaceId: HIGHLIGHT_VS_PLAIN_WORKSPACE_ID, + workspaceName: "feature/immersive-highlight-vs-plain", + projectName: "my-app", + messages: [ + createUserMessage("msg-1", "Can you compare highlight and plain line heights?", { + historySequence: 1, + }), + createAssistantMessage( + "msg-2", + "I generated a mixed diff where one oversized context chunk falls back to plain text.", + { + historySequence: 2, + } + ), + ], + gitDiff: { + diffOutput, + numstatOutput: HIGHLIGHT_VS_PLAIN_NUMSTAT, + }, + }); + + expandRightSidebar(); + return client; + }} + /> + ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await waitFor( + () => { + canvas.getByTestId("immersive-review-view"); + canvas.getByRole("button", { name: /exit immersive review/i }); + }, + { timeout: 10_000 } + ); + }, +}; diff --git a/src/browser/utils/review/navigation.test.ts b/src/browser/utils/review/navigation.test.ts index d93cf05ebf..f5e011d62f 100644 --- a/src/browser/utils/review/navigation.test.ts +++ b/src/browser/utils/review/navigation.test.ts @@ -6,8 +6,15 @@ */ import { describe, test, expect } from "bun:test"; -import { findNextHunkId, findNextHunkIdAfterFileRemoval } from "./navigation"; +import { + findNextHunkId, + findNextHunkIdAfterFileRemoval, + flattenFileTreeLeaves, + getAdjacentFilePath, + getFileHunks, +} from "./navigation"; import type { DiffHunk } from "@/common/types/review"; +import type { FileTreeNode } from "@/common/utils/git/numstatParser"; // Helper to create minimal DiffHunk for testing function makeHunk(id: string, filePath: string): DiffHunk { @@ -128,3 +135,210 @@ describe("findNextHunkIdAfterFileRemoval", () => { expect(findNextHunkIdAfterFileRemoval(hunks, "B1", "b.ts")).toBe("A2"); }); }); + +describe("flattenFileTreeLeaves", () => { + test("returns empty array for null input", () => { + expect(flattenFileTreeLeaves(null)).toEqual([]); + }); + + test("returns single file for a flat tree", () => { + const root: FileTreeNode = { + name: "", + path: "", + isDirectory: true, + children: [ + { + name: "index.ts", + path: "index.ts", + isDirectory: false, + children: [], + }, + ], + }; + + expect(flattenFileTreeLeaves(root)).toEqual(["index.ts"]); + }); + + test("returns multiple files in depth-first order for a nested tree", () => { + const root: FileTreeNode = { + name: "", + path: "", + isDirectory: true, + children: [ + { + name: "src", + path: "src", + isDirectory: true, + children: [ + { + name: "components", + path: "src/components", + isDirectory: true, + children: [ + { + name: "Button.tsx", + path: "src/components/Button.tsx", + isDirectory: false, + children: [], + }, + { + name: "Card.tsx", + path: "src/components/Card.tsx", + isDirectory: false, + children: [], + }, + ], + }, + { + name: "index.ts", + path: "src/index.ts", + isDirectory: false, + children: [], + }, + ], + }, + { + name: "README.md", + path: "README.md", + isDirectory: false, + children: [], + }, + ], + }; + + expect(flattenFileTreeLeaves(root)).toEqual([ + "src/components/Button.tsx", + "src/components/Card.tsx", + "src/index.ts", + "README.md", + ]); + }); + + test("normalizes rename syntax to renamed leaf paths", () => { + const root: FileTreeNode = { + name: "", + path: "", + isDirectory: true, + children: [ + { + name: "src", + path: "src", + isDirectory: true, + children: [ + { + name: "{oldName.ts => newName.ts}", + path: "src/{oldName.ts => newName.ts}", + isDirectory: false, + children: [], + }, + ], + }, + { + name: "README.old.md => README.md", + path: "README.old.md => README.md", + isDirectory: false, + children: [], + }, + ], + }; + + expect(flattenFileTreeLeaves(root)).toEqual(["src/newName.ts", "README.md"]); + }); + + test("skips directory nodes and only returns leaf file paths", () => { + const root: FileTreeNode = { + name: "", + path: "", + isDirectory: true, + children: [ + { + name: "packages", + path: "packages", + isDirectory: true, + children: [ + { + name: "core", + path: "packages/core", + isDirectory: true, + children: [ + { + name: "index.ts", + path: "packages/core/index.ts", + isDirectory: false, + children: [], + }, + ], + }, + ], + }, + ], + }; + + const result = flattenFileTreeLeaves(root); + + expect(result).toEqual(["packages/core/index.ts"]); + expect(result).not.toContain("packages"); + expect(result).not.toContain("packages/core"); + }); +}); + +describe("getAdjacentFilePath", () => { + test("returns null for empty file list", () => { + expect(getAdjacentFilePath([], "src/a.ts", 1)).toBeNull(); + }); + + test("returns first file if current is not in list", () => { + const files = ["src/a.ts", "src/b.ts", "src/c.ts"]; + + expect(getAdjacentFilePath(files, "src/missing.ts", 1)).toBe("src/a.ts"); + }); + + test("returns next file for direction 1", () => { + const files = ["src/a.ts", "src/b.ts", "src/c.ts"]; + + expect(getAdjacentFilePath(files, "src/b.ts", 1)).toBe("src/c.ts"); + }); + + test("returns previous file for direction -1", () => { + const files = ["src/a.ts", "src/b.ts", "src/c.ts"]; + + expect(getAdjacentFilePath(files, "src/b.ts", -1)).toBe("src/a.ts"); + }); + + test("wraps around from last to first for direction 1", () => { + const files = ["src/a.ts", "src/b.ts", "src/c.ts"]; + + expect(getAdjacentFilePath(files, "src/c.ts", 1)).toBe("src/a.ts"); + }); + + test("wraps around from first to last for direction -1", () => { + const files = ["src/a.ts", "src/b.ts", "src/c.ts"]; + + expect(getAdjacentFilePath(files, "src/a.ts", -1)).toBe("src/c.ts"); + }); +}); + +describe("getFileHunks", () => { + test("returns empty array if no hunks match", () => { + const hunks = [makeHunk("a", "src/a.ts"), makeHunk("b", "src/b.ts")]; + + expect(getFileHunks(hunks, "src/c.ts")).toEqual([]); + }); + + test("returns only hunks matching the file path", () => { + const hunkA = makeHunk("a", "src/a.ts"); + const hunkB = makeHunk("b", "src/b.ts"); + const hunks = [hunkA, hunkB]; + + expect(getFileHunks(hunks, "src/b.ts")).toEqual([hunkB]); + }); + + test("returns multiple hunks for the same file", () => { + const hunkA1 = makeHunk("a1", "src/a.ts"); + const hunkB = makeHunk("b", "src/b.ts"); + const hunkA2 = makeHunk("a2", "src/a.ts"); + const hunks = [hunkA1, hunkB, hunkA2]; + + expect(getFileHunks(hunks, "src/a.ts")).toEqual([hunkA1, hunkA2]); + }); +}); diff --git a/src/browser/utils/review/navigation.ts b/src/browser/utils/review/navigation.ts index 96f733b09f..d7be4869db 100644 --- a/src/browser/utils/review/navigation.ts +++ b/src/browser/utils/review/navigation.ts @@ -3,6 +3,7 @@ * These pure functions compute the next/previous hunk to navigate to. */ +import { extractNewPath, type FileTreeNode } from "@/common/utils/git/numstatParser"; import type { DiffHunk } from "@/common/types/review"; /** @@ -65,3 +66,52 @@ export function findNextHunkIdAfterFileRemoval( return null; } + +/** + * Flatten a FileTreeNode into a sorted list of leaf file paths. + * Traverses the tree depth-first, collecting only leaf nodes (files, not dirs). + */ +export function flattenFileTreeLeaves(root: FileTreeNode | null): string[] { + if (!root) return []; + const result: string[] = []; + + function walk(node: FileTreeNode, prefix: string) { + const path = prefix ? `${prefix}/${node.name}` : node.name; + if (node.children && node.children.length > 0) { + for (const child of node.children) { + walk(child, path); + } + } else { + // Leaf node = file; normalize rename syntax (e.g., "src/{old.ts => new.ts}" → "src/new.ts") + result.push(extractNewPath(path)); + } + } + + // Root is virtual (""), walk children directly + for (const child of root.children ?? []) { + walk(child, ""); + } + return result; +} + +/** + * Get the next or previous file path in a list, wrapping around. + */ +export function getAdjacentFilePath( + files: string[], + current: string, + direction: 1 | -1 +): string | null { + if (files.length === 0) return null; + const idx = files.indexOf(current); + if (idx === -1) return files[0]; + const next = (idx + direction + files.length) % files.length; + return files[next]; +} + +/** + * Filter hunks to only those matching a specific file path. + */ +export function getFileHunks(hunks: DiffHunk[], filePath: string): DiffHunk[] { + return hunks.filter((h) => h.filePath === filePath); +} diff --git a/src/browser/utils/review/quickReviewNotes.test.ts b/src/browser/utils/review/quickReviewNotes.test.ts new file mode 100644 index 0000000000..cc0809edb1 --- /dev/null +++ b/src/browser/utils/review/quickReviewNotes.test.ts @@ -0,0 +1,246 @@ +import { describe, test, expect } from "bun:test"; +import type { DiffHunk } from "@/common/types/review"; +import { buildQuickHunkReviewNote, buildQuickLineReviewNote } from "./quickReviewNotes"; + +function makeHunk(overrides: Partial = {}): DiffHunk { + return { + id: "hunk-1", + filePath: "src/example.ts", + oldStart: 10, + oldLines: 3, + newStart: 10, + newLines: 3, + content: "-const a = 1;\n+const a = 2;\n console.log(a);", + header: "@@ -10,3 +10,3 @@", + ...overrides, + }; +} + +describe("buildQuickHunkReviewNote", () => { + test("returns correct filePath and userNote", () => { + const hunk = makeHunk(); + + const note = buildQuickHunkReviewNote({ + hunk, + userNote: "Looks good", + }); + + expect(note.filePath).toBe("src/example.ts"); + expect(note.userNote).toBe("Looks good"); + }); + + test("builds correct lineRange from hunk coordinates", () => { + const hunk = makeHunk({ + oldStart: 12, + oldLines: 4, + newStart: 20, + newLines: 5, + header: "@@ -12,4 +20,5 @@", + }); + + const note = buildQuickHunkReviewNote({ + hunk, + userNote: "Coordinate check", + }); + + expect(note.lineRange).toBe("-12-15 +20-24"); + }); + + test("includes selectedDiff matching hunk.content", () => { + const hunk = makeHunk({ + content: "-old line\n+new line\n unchanged", + }); + + const note = buildQuickHunkReviewNote({ + hunk, + userNote: "Diff included", + }); + + expect(note.selectedDiff).toBe(hunk.content); + }); + + test("handles small hunks by including all lines in selectedCode", () => { + const hunk = makeHunk({ + oldStart: 40, + oldLines: 5, + newStart: 40, + newLines: 5, + header: "@@ -40,5 +40,5 @@", + content: [ + "-const a = 1;", + "+const a = 2;", + " const b = 3;", + "-console.log(a);", + "+console.log(a, b);", + ].join("\n"), + }); + + const note = buildQuickHunkReviewNote({ + hunk, + userNote: "Small hunk", + }); + + const selectedLines = note.selectedCode.split("\n"); + + expect(selectedLines).toHaveLength(5); + expect(note.selectedCode).toContain("const a = 1;"); + expect(note.selectedCode).toContain("const a = 2;"); + expect(note.selectedCode).toContain("const b = 3;"); + expect(note.selectedCode).toContain("console.log(a);"); + expect(note.selectedCode).toContain("console.log(a, b);"); + expect(note.selectedCode).not.toContain("lines omitted"); + }); + + test("handles large hunks by eliding middle lines when over 20 lines", () => { + const content = Array.from( + { length: 25 }, + (_, index) => `+const line${index + 1} = ${index + 1};` + ).join("\n"); + + const hunk = makeHunk({ + oldStart: 100, + oldLines: 25, + newStart: 200, + newLines: 25, + header: "@@ -100,25 +200,25 @@", + content, + }); + + const note = buildQuickHunkReviewNote({ + hunk, + userNote: "Large hunk", + }); + + const selectedLines = note.selectedCode.split("\n"); + + expect(selectedLines).toHaveLength(21); + expect(note.selectedCode).toContain("(5 lines omitted)"); + expect(note.selectedCode).toContain("const line1 = 1;"); + expect(note.selectedCode).toContain("const line10 = 10;"); + expect(note.selectedCode).toContain("const line16 = 16;"); + expect(note.selectedCode).toContain("const line25 = 25;"); + expect(note.selectedCode).not.toContain("const line11 = 11;"); + expect(note.selectedCode).not.toContain("const line15 = 15;"); + }); +}); + +describe("buildQuickLineReviewNote", () => { + test("builds note data for a single selected line", () => { + const hunk = makeHunk({ + content: "-const a = 1;\n+const a = 2;\n const b = a;", + }); + + const note = buildQuickLineReviewNote({ + hunk, + startIndex: 1, + endIndex: 1, + userNote: "Use a constant here", + }); + + expect(note.lineRange).toBe("+10"); + expect(note.selectedDiff).toBe("+const a = 2;"); + expect(note.selectedCode).toContain("+ const a = 2;"); + expect(note.oldStart).toBe(1); + expect(note.newStart).toBe(10); + expect(note.userNote).toBe("Use a constant here"); + }); + + test("builds ranges from selected line span", () => { + const hunk = makeHunk({ + oldStart: 50, + oldLines: 4, + newStart: 50, + newLines: 4, + content: "-const a = 1;\n+const a = 2;\n const b = 3;\n-console.log(a);\n+console.log(a, b);", + header: "@@ -50,4 +50,4 @@", + }); + + const note = buildQuickLineReviewNote({ + hunk, + startIndex: 0, + endIndex: 2, + userNote: "Please revisit this block", + }); + + expect(note.lineRange).toBe("-50-51 +50-51"); + expect(note.selectedDiff).toBe("-const a = 1;\n+const a = 2;\n const b = 3;"); + expect(note.oldStart).toBe(50); + expect(note.newStart).toBe(50); + }); + + test("keeps old/new coordinates for context-only selections", () => { + const hunk = makeHunk({ + oldStart: 30, + oldLines: 3, + newStart: 40, + newLines: 3, + content: + "-const removed = 1;\n+const added = 1;\n const keepOne = added;\n const keepTwo = keepOne;", + header: "@@ -30,3 +40,3 @@", + }); + + const note = buildQuickLineReviewNote({ + hunk, + startIndex: 2, + endIndex: 3, + userNote: "Context-only selection", + }); + + expect(note.lineRange).toBe("-31-32 +41-42"); + expect(note.selectedDiff).toBe(" const keepOne = added;\n const keepTwo = keepOne;"); + expect(note.oldStart).toBe(31); + expect(note.newStart).toBe(41); + }); + + test("clamps out-of-bounds selection indices", () => { + const hunk = makeHunk({ + content: "-old\n+new\n context", + oldStart: 7, + oldLines: 2, + newStart: 7, + newLines: 2, + }); + + const note = buildQuickLineReviewNote({ + hunk, + startIndex: -50, + endIndex: 99, + userNote: "Clamp selection", + }); + + expect(note.lineRange).toBe("-7-8 +7-8"); + expect(note.selectedDiff).toBe("-old\n+new\n context"); + }); + + test("elides selectedCode for ranges longer than 20 lines", () => { + const content = Array.from( + { length: 30 }, + (_, index) => `+const line${index + 1} = ${index + 1};` + ).join("\n"); + + const hunk = makeHunk({ + oldStart: 1, + oldLines: 30, + newStart: 100, + newLines: 30, + content, + header: "@@ -1,30 +100,30 @@", + }); + + const note = buildQuickLineReviewNote({ + hunk, + startIndex: 0, + endIndex: 29, + userNote: "Large range", + }); + + const selectedLines = note.selectedCode.split("\n"); + expect(selectedLines).toHaveLength(21); + expect(note.selectedCode).toContain("(10 lines omitted)"); + expect(note.selectedCode).toContain("const line1 = 1;"); + expect(note.selectedCode).toContain("const line10 = 10;"); + expect(note.selectedCode).toContain("const line21 = 21;"); + expect(note.selectedCode).toContain("const line30 = 30;"); + expect(note.selectedCode).not.toContain("const line11 = 11;"); + }); +}); diff --git a/src/browser/utils/review/quickReviewNotes.ts b/src/browser/utils/review/quickReviewNotes.ts new file mode 100644 index 0000000000..88f040f23a --- /dev/null +++ b/src/browser/utils/review/quickReviewNotes.ts @@ -0,0 +1,236 @@ +/** + * Utilities for building quick review notes from hunks in immersive mode. + * + * - buildQuickHunkReviewNote creates note data for an entire hunk. + * - buildQuickLineReviewNote creates note data for a selected line range in a hunk. + */ + +import type { DiffHunk, ReviewNoteData } from "@/common/types/review"; + +const CONTEXT_LINES = 10; +const MAX_FULL_LINES = CONTEXT_LINES * 2; + +interface QuickReviewLineData { + raw: string; + oldLineNum: number | null; + newLineNum: number | null; +} + +function splitDiffLines(content: string): string[] { + const lines = content.split(/\r?\n/); + if (lines.length > 0 && lines[lines.length - 1] === "") { + lines.pop(); + } + return lines; +} + +function formatRange(nums: number[]): string | null { + if (nums.length === 0) { + return null; + } + + const min = Math.min(...nums); + const max = Math.max(...nums); + return min === max ? `${min}` : `${min}-${max}`; +} + +function buildLineDataForHunk(hunk: DiffHunk): QuickReviewLineData[] { + const lines = splitDiffLines(hunk.content); + const lineData: QuickReviewLineData[] = []; + + let oldNum = hunk.oldStart; + let newNum = hunk.newStart; + + for (const line of lines) { + if (line.startsWith("@@")) { + const headerMatch = /^@@\s+-(\d+)(?:,\d+)?\s+\+(\d+)(?:,\d+)?\s+@@/.exec(line); + if (headerMatch) { + oldNum = Number.parseInt(headerMatch[1], 10); + newNum = Number.parseInt(headerMatch[2], 10); + } + continue; + } + + const indicator = line[0] ?? " "; + if (indicator === "+") { + lineData.push({ raw: line, oldLineNum: null, newLineNum: newNum }); + newNum += 1; + continue; + } + + if (indicator === "-") { + lineData.push({ raw: line, oldLineNum: oldNum, newLineNum: null }); + oldNum += 1; + continue; + } + + lineData.push({ raw: line, oldLineNum: oldNum, newLineNum: newNum }); + oldNum += 1; + newNum += 1; + } + + return lineData; +} + +function buildRangeForSelectedLines(selectedLineData: QuickReviewLineData[]): string { + const oldLineNumbers = selectedLineData + .map((lineInfo) => lineInfo.oldLineNum) + .filter((lineNum): lineNum is number => lineNum !== null); + const newLineNumbers = selectedLineData + .map((lineInfo) => lineInfo.newLineNum) + .filter((lineNum): lineNum is number => lineNum !== null); + + const oldRange = formatRange(oldLineNumbers); + const newRange = formatRange(newLineNumbers); + + return [oldRange ? `-${oldRange}` : null, newRange ? `+${newRange}` : null] + .filter((part): part is string => part !== null) + .join(" "); +} + +function formatSelectedCode(selectedLineData: QuickReviewLineData[]): string { + const oldLineNumbers = selectedLineData + .map((lineInfo) => lineInfo.oldLineNum) + .filter((lineNum): lineNum is number => lineNum !== null); + const newLineNumbers = selectedLineData + .map((lineInfo) => lineInfo.newLineNum) + .filter((lineNum): lineNum is number => lineNum !== null); + + const oldWidth = Math.max(1, ...oldLineNumbers.map((lineNum) => String(lineNum).length)); + const newWidth = Math.max(1, ...newLineNumbers.map((lineNum) => String(lineNum).length)); + + const allLines = selectedLineData.map((lineInfo) => { + const indicator = lineInfo.raw[0] ?? " "; + const content = lineInfo.raw.slice(1); + const oldStr = lineInfo.oldLineNum === null ? "" : String(lineInfo.oldLineNum); + const newStr = lineInfo.newLineNum === null ? "" : String(lineInfo.newLineNum); + + return `${oldStr.padStart(oldWidth)} ${newStr.padStart(newWidth)} ${indicator} ${content}`; + }); + + if (allLines.length <= MAX_FULL_LINES) { + return allLines.join("\n"); + } + + const omittedCount = allLines.length - MAX_FULL_LINES; + return [ + ...allLines.slice(0, CONTEXT_LINES), + ` (${omittedCount} lines omitted)`, + ...allLines.slice(-CONTEXT_LINES), + ].join("\n"); +} + +/** + * Build a ReviewNoteData from a selected line range in a hunk. + * Mirrors ReviewNoteInput formatting in DiffRenderer for consistent payloads. + */ +export function buildQuickLineReviewNote(params: { + hunk: DiffHunk; + startIndex: number; + endIndex: number; + userNote: string; +}): ReviewNoteData { + const { hunk, startIndex, endIndex, userNote } = params; + const lineData = buildLineDataForHunk(hunk); + + if (lineData.length === 0) { + return buildQuickHunkReviewNote({ hunk, userNote }); + } + + const requestedStart = Math.min(startIndex, endIndex); + const requestedEnd = Math.max(startIndex, endIndex); + const clampedStart = Math.max(0, Math.min(requestedStart, lineData.length - 1)); + const clampedEnd = Math.max(clampedStart, Math.min(requestedEnd, lineData.length - 1)); + const selectedLineData = lineData.slice(clampedStart, clampedEnd + 1); + + const oldLineNumbers = selectedLineData + .map((lineInfo) => lineInfo.oldLineNum) + .filter((lineNum): lineNum is number => lineNum !== null); + const newLineNumbers = selectedLineData + .map((lineInfo) => lineInfo.newLineNum) + .filter((lineNum): lineNum is number => lineNum !== null); + + return { + filePath: hunk.filePath, + lineRange: buildRangeForSelectedLines(selectedLineData), + selectedCode: formatSelectedCode(selectedLineData), + selectedDiff: selectedLineData.map((lineInfo) => lineInfo.raw).join("\n"), + oldStart: oldLineNumbers.length > 0 ? Math.min(...oldLineNumbers) : 1, + newStart: newLineNumbers.length > 0 ? Math.min(...newLineNumbers) : 1, + userNote, + }; +} + +/** + * Build a ReviewNoteData for the entire hunk with a prefilled user note. + * Used by the quick feedback actions in immersive review mode. + */ +export function buildQuickHunkReviewNote(params: { + hunk: DiffHunk; + userNote: string; +}): ReviewNoteData { + const { hunk, userNote } = params; + + const lines = hunk.content.split("\n").filter((line) => line.length > 0); + + // Compute line number ranges, omitting segments for pure additions/deletions + const oldRange = + hunk.oldLines > 0 ? `-${hunk.oldStart}-${hunk.oldStart + hunk.oldLines - 1}` : null; + const newRange = + hunk.newLines > 0 ? `+${hunk.newStart}-${hunk.newStart + hunk.newLines - 1}` : null; + const lineRange = [oldRange, newRange].filter(Boolean).join(" "); + + const oldEnd = hunk.oldLines > 0 ? hunk.oldStart + hunk.oldLines - 1 : hunk.oldStart; + const newEnd = hunk.newLines > 0 ? hunk.newStart + hunk.newLines - 1 : hunk.newStart; + + // Build selectedCode with line numbers (matching DiffRenderer format) + const oldWidth = Math.max(1, String(oldEnd).length); + const newWidth = Math.max(1, String(newEnd).length); + + let oldNum = hunk.oldStart; + let newNum = hunk.newStart; + const codeLines = lines.map((line) => { + const indicator = line[0] ?? " "; + const content = line.slice(1); + let oldStr = ""; + let newStr = ""; + + if (indicator === "+") { + newStr = String(newNum); + newNum++; + } else if (indicator === "-") { + oldStr = String(oldNum); + oldNum++; + } else { + oldStr = String(oldNum); + newStr = String(newNum); + oldNum++; + newNum++; + } + + return `${oldStr.padStart(oldWidth)} ${newStr.padStart(newWidth)} ${indicator} ${content}`; + }); + + // Elide middle lines if more than 20 + let selectedCode: string; + if (codeLines.length <= MAX_FULL_LINES) { + selectedCode = codeLines.join("\n"); + } else { + const omittedCount = codeLines.length - MAX_FULL_LINES; + selectedCode = [ + ...codeLines.slice(0, CONTEXT_LINES), + ` (${omittedCount} lines omitted)`, + ...codeLines.slice(-CONTEXT_LINES), + ].join("\n"); + } + + return { + filePath: hunk.filePath, + lineRange, + selectedCode, + selectedDiff: hunk.content, + oldStart: hunk.oldStart, + newStart: hunk.newStart, + userNote, + }; +} diff --git a/src/browser/utils/ui/keybinds.ts b/src/browser/utils/ui/keybinds.ts index 83c3bd94b1..07767a7119 100644 --- a/src/browser/utils/ui/keybinds.ts +++ b/src/browser/utils/ui/keybinds.ts @@ -382,10 +382,10 @@ export const KEYBINDS = { TOGGLE_HUNK_READ: { key: "m" }, /** Mark selected hunk as read in Code Review panel */ - MARK_HUNK_READ: { key: "l" }, + MARK_HUNK_READ: { key: "r" }, /** Mark selected hunk as unread in Code Review panel */ - MARK_HUNK_UNREAD: { key: "h" }, + MARK_HUNK_UNREAD: { key: "u" }, /** Mark entire file (all hunks) as read in Code Review panel */ MARK_FILE_READ: { key: "M", shift: true }, @@ -423,6 +423,45 @@ export const KEYBINDS = { /** Cancel/dismiss confirmation dialogs */ CONFIRM_DIALOG_NO: { key: "n", allowShift: true }, + /** Toggle immersive review mode */ + TOGGLE_REVIEW_IMMERSIVE: { key: "i", shift: true }, + + /** Navigate to next file in immersive review */ + REVIEW_NEXT_FILE: { key: "l" }, + + /** Navigate to previous file in immersive review */ + REVIEW_PREV_FILE: { key: "h" }, + + /** Navigate to next hunk in immersive review */ + REVIEW_NEXT_HUNK: { key: "j" }, + + /** Navigate to previous hunk in immersive review */ + REVIEW_PREV_HUNK: { key: "k" }, + + /** Move line cursor down in immersive review */ + REVIEW_CURSOR_DOWN: { key: "ArrowDown", allowShift: true }, + + /** Move line cursor up in immersive review */ + REVIEW_CURSOR_UP: { key: "ArrowUp", allowShift: true }, + + /** Jump line cursor 10 lines down in immersive review */ + REVIEW_CURSOR_JUMP_DOWN: { key: "ArrowDown", ctrl: true, allowShift: true }, + + /** Jump line cursor 10 lines up in immersive review */ + REVIEW_CURSOR_JUMP_UP: { key: "ArrowUp", ctrl: true, allowShift: true }, + + /** Quick "I like this" feedback in immersive review */ + REVIEW_QUICK_LIKE: { key: "l", shift: true }, + + /** Quick "I don't like this" feedback in immersive review */ + REVIEW_QUICK_DISLIKE: { key: "d", shift: true }, + + /** Add comment in immersive review */ + REVIEW_COMMENT: { key: "c", shift: true }, + + /** Toggle focus between diff and notes sidebar in immersive review */ + REVIEW_FOCUS_NOTES: { key: "Tab" }, + TOGGLE_POWER_MODE: { key: "F12", shift: true }, } as const; diff --git a/src/common/constants/storage.ts b/src/common/constants/storage.ts index c9dcbd39b9..7d794d4887 100644 --- a/src/common/constants/storage.ts +++ b/src/common/constants/storage.ts @@ -374,11 +374,11 @@ export const DEFAULT_TERMINAL_FONT_CONFIG: TerminalFontConfig = { /** * Tutorial state storage key (global) - * Stores: { disabled: boolean, completed: { creation?: true, workspace?: true } } + * Stores: { disabled: boolean, completed: { creation?: true, workspace?: true, review?: true } } */ export const TUTORIAL_STATE_KEY = "tutorialState"; -export type TutorialSequence = "creation" | "workspace"; +export type TutorialSequence = "creation" | "workspace" | "review"; export interface TutorialState { disabled: boolean; @@ -559,6 +559,15 @@ export function getReviewsKey(workspaceId: string): string { return `reviews:${workspaceId}`; } +/** + * Get the localStorage key for immersive review mode state per workspace + * Tracks whether immersive mode is active + * Format: "review-immersive:{workspaceId}" + */ +export function getReviewImmersiveKey(workspaceId: string): string { + return `review-immersive:${workspaceId}`; +} + /** * Get the localStorage key for auto-compaction enabled preference per workspace * Format: "autoCompaction:enabled:{workspaceId}" @@ -597,6 +606,7 @@ const PERSISTENT_WORKSPACE_KEY_FUNCTIONS: Array<(workspaceId: string) => string> getFileTreeExpandStateKey, getReviewSearchStateKey, getReviewsKey, + getReviewImmersiveKey, getAutoCompactionEnabledKey, getWorkspaceLastReadKey, getStatusStateKey,