Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
91e04f7
🤖 Add read-more feature to code review hunks
ammar-agent Oct 24, 2025
7fdcb5f
🤖 Decompose HunkViewer into smaller components
ammar-agent Oct 24, 2025
6cd258a
🤖 Fix read-more to read from correct git ref
ammar-agent Oct 24, 2025
3760d06
🤖 Fix syntax highlighting continuity and reduce duplication
ammar-agent Oct 24, 2025
c5536f5
🤖 Remove unused import
ammar-agent Oct 24, 2025
aeb84c9
🤖 Fix cumulative expansion and add unit tests
ammar-agent Oct 24, 2025
aa172c9
🤖 Export LINES_PER_EXPANSION constant
ammar-agent Oct 24, 2025
da0c39a
🤖 Add reversible collapse to read-more feature
ammar-agent Oct 24, 2025
588fb88
🤖 Add collapse behavior tests
ammar-agent Oct 24, 2025
a3b2b8e
🤖 Implement GitHub-style arrow UI for context expansion
ammar-agent Oct 25, 2025
0488c79
🤖 Fix arrow position and collapse visibility
ammar-agent Oct 25, 2025
3b38698
🤖 Integrate arrow into diff grid and add hunk combining logic
ammar-agent Oct 25, 2025
5716b7c
🤖 Integrate hunk combining into ReviewPanel
ammar-agent Oct 25, 2025
4b79604
🤖 Implement separate expand/collapse arrows with BOF/EOF markers
ammar-agent Oct 25, 2025
1158eec
🤖 Fix: Prevent combining hunks from different files
ammar-agent Oct 25, 2025
39597ac
🤖 Improve EOF detection to handle empty expansion
ammar-agent Oct 25, 2025
17daad1
🤖 Save vertical space: inline BOF/EOF markers with arrows
ammar-agent Oct 25, 2025
4735d5f
🤖 Improve marker text styling with muted grey and bullet
ammar-agent Oct 25, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions src/components/RightSidebar/CodeReview/ExpanderArrow.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
/**
* ExpanderArrow - GitHub-style blue arrow for expanding/collapsing context
*/

import React from "react";
import { cn } from "@/lib/utils";

interface ExpanderArrowProps {
/** Direction of the arrow */
direction: "up" | "down";
/** Mode: expand adds lines, collapse removes lines */
mode: "expand" | "collapse";
/** Is expansion/collapse in progress? */
isLoading: boolean;
/** Click handler */
onClick: (e: React.MouseEvent) => void;
/** Optional marker text to show (e.g., "Beginning of file") */
markerText?: string;
}

export const ExpanderArrow = React.memo<ExpanderArrowProps>(
({ direction, mode, isLoading, onClick, markerText }) => {
// Arrow symbol based on direction and mode
// Expand: always points toward direction (â–² for up, â–¼ for down)
// Collapse: always points away from direction (â–¼ for up, â–² for down)
const arrow =
mode === "expand" ? (direction === "up" ? "â–²" : "â–¼") : direction === "up" ? "â–¼" : "â–²";

// Collapse arrows are more muted
const opacity = mode === "collapse" ? 0.5 : 1;

return (
<div
className={cn(
"block w-full cursor-pointer transition-colors hover:bg-[rgba(0,122,204,0.08)]"
)}
onClick={onClick}
role="button"
aria-label={`${mode === "expand" ? "Expand" : "Collapse"} context ${direction}`}
>
<div
className="flex px-2 font-mono whitespace-pre"
style={{ color: "var(--color-accent)", opacity }}
>
{/* Indicator column - matches diff line structure */}
<span className="inline-block w-1 shrink-0 text-center opacity-40">·</span>

{/* Line number column - matches diff line structure */}
<span className="flex min-w-9 shrink-0 items-center justify-end pr-1 select-none">
{isLoading ? (
<span className="text-[9px] opacity-50">...</span>
) : (
<span className="text-sm leading-none">{arrow}</span>
)}
</span>

{/* Content area - matches diff line structure */}
{markerText ? (
<span className="text-muted flex items-center gap-1.5 pl-2 text-[11px] italic">
<span className="opacity-50">•</span>
<span>{markerText}</span>
</span>
) : (
<span className="pl-2 text-[11px] opacity-0">{isLoading ? "Loading..." : ""}</span>
)}
</div>
</div>
);
}
);

ExpanderArrow.displayName = "ExpanderArrow";
167 changes: 167 additions & 0 deletions src/components/RightSidebar/CodeReview/HunkContent.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
/**
* HunkContent - Main content area for a hunk with read-more functionality
*/

import React, { useMemo } from "react";
import type { DiffHunk, HunkReadMoreState } from "@/types/review";
import type { SearchHighlightConfig } from "@/utils/highlighting/highlightSearchTerms";
import { SelectableDiffRenderer } from "../../shared/DiffRenderer";
import { ExpanderArrow } from "./ExpanderArrow";
import { calculateUpwardExpansion } from "@/utils/review/readFileLines";

interface ExpansionState {
content: string;
isLoading: boolean;
onExpand: (e: React.MouseEvent) => void;
onCollapse: (e: React.MouseEvent) => void;
isExpanded: boolean;
canExpand: boolean;
}

interface HunkContentProps {
/** The hunk to display */
hunk: DiffHunk;
/** Hunk ID for event handling */
hunkId: string;
/** Read-more expansion state */
readMoreState: HunkReadMoreState;
/** Upward expansion state */
upExpansion: ExpansionState;
/** Downward expansion state */
downExpansion: ExpansionState;
/** Handler for line clicks (triggers parent onClick) */
onClick?: (e: React.MouseEvent<HTMLElement>) => void;
/** Handler for review notes */
onReviewNote?: (note: string) => void;
/** Search configuration for highlighting */
searchConfig?: SearchHighlightConfig;
}

export const HunkContent = React.memo<HunkContentProps>(
({
hunk,
hunkId,
readMoreState,
upExpansion,
downExpansion,
onClick,
onReviewNote,
searchConfig,
}) => {
// Calculate expansion metadata
const upwardExpansion = calculateUpwardExpansion(hunk.oldStart, readMoreState.up);
const canExpandUp = upwardExpansion.startLine >= 1 && upwardExpansion.numLines > 0;

// Check if we've reached beginning of file (line 1)
const atBeginningOfFile = upExpansion.isExpanded && upwardExpansion.startLine === 1;

// Detect EOF: multiple scenarios
// 1. Expanded down and got fewer lines than requested
// 2. Expanded down and got empty/no content (hunk was already at EOF)
const atEndOfFile = useMemo(() => {
// If we've never expanded, we don't know if we're at EOF yet
if (readMoreState.down === 0) return false;

// If we expanded but got no content, we're at EOF
if (!downExpansion.content?.trim().length) {
return true;
}

const lines = downExpansion.content.split("\n").filter((l) => l.length > 0);
// If we got fewer lines than requested, we're at EOF
return lines.length < readMoreState.down;
}, [downExpansion.content, readMoreState.down]);

// Combine all content into single unified diff for proper syntax highlighting
// This ensures grammar state (multi-line comments, strings, etc.) spans correctly
const combinedContent = useMemo(() => {
const parts: string[] = [];

if (upExpansion.content) {
parts.push(upExpansion.content);
}

parts.push(hunk.content);

if (downExpansion.content) {
parts.push(downExpansion.content);
}

return parts.join("\n");
}, [upExpansion.content, hunk.content, downExpansion.content]);

// Calculate starting line number for combined content
const combinedStartLine = upExpansion.content ? upwardExpansion.startLine : hunk.oldStart;

return (
<div className="px-2 py-1.5">
<SelectableDiffRenderer
content={combinedContent}
filePath={hunk.filePath}
oldStart={combinedStartLine}
newStart={combinedStartLine}
maxHeight="none"
onReviewNote={onReviewNote}
onLineClick={() => {
// Create synthetic event with data-hunk-id for parent handler
const syntheticEvent = {
currentTarget: { dataset: { hunkId } },
} as unknown as React.MouseEvent<HTMLElement>;
onClick?.(syntheticEvent);
}}
searchConfig={searchConfig}
expanderTop={
<>
{/* Collapse arrow - show if currently expanded, with BOF marker if at beginning */}
{upExpansion.isExpanded && (
<ExpanderArrow
direction="up"
mode="collapse"
isLoading={upExpansion.isLoading}
onClick={upExpansion.onCollapse}
markerText={atBeginningOfFile ? "Beginning of file" : undefined}
/>
)}

{/* Expand arrow - show if can expand more */}
{canExpandUp && !atBeginningOfFile && (
<ExpanderArrow
direction="up"
mode="expand"
isLoading={upExpansion.isLoading}
onClick={upExpansion.onExpand}
/>
)}
</>
}
expanderBottom={
<>
{/* Expand arrow - show if can expand more */}
{downExpansion.canExpand && !atEndOfFile && (
<ExpanderArrow
direction="down"
mode="expand"
isLoading={downExpansion.isLoading}
onClick={downExpansion.onExpand}
/>
)}

{/* Collapse arrow - show if currently expanded, with EOF marker if at end */}
{downExpansion.isExpanded && (
<ExpanderArrow
direction="down"
mode="collapse"
isLoading={downExpansion.isLoading}
onClick={downExpansion.onCollapse}
markerText={atEndOfFile ? "End of file" : undefined}
/>
)}
</>
}
/>
</div>
);
}
);

HunkContent.displayName = "HunkContent";
89 changes: 89 additions & 0 deletions src/components/RightSidebar/CodeReview/HunkHeader.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
/**
* HunkHeader - Header section of a diff hunk showing file path, stats, and controls
*/

import React from "react";
import { Tooltip, TooltipWrapper } from "../../Tooltip";
import { KEYBINDS, formatKeybind } from "@/utils/ui/keybinds";

interface HunkHeaderProps {
/** File path (may contain HTML from search highlighting) */
highlightedFilePath: string;
/** Whether the hunk is marked as read */
isRead: boolean;
/** Number of additions in the hunk */
additions: number;
/** Number of deletions in the hunk */
deletions: number;
/** Total line count */
lineCount: number;
/** Whether this is a pure rename (no content changes) */
isPureRename: boolean;
/** Hunk ID for event handling */
hunkId: string;
/** Callback when toggle read button is clicked */
onToggleRead?: (e: React.MouseEvent<HTMLButtonElement>) => void;
}

export const HunkHeader = React.memo<HunkHeaderProps>(
({
highlightedFilePath,
isRead,
additions,
deletions,
lineCount,
isPureRename,
hunkId,
onToggleRead,
}) => {
return (
<div className="bg-separator border-border-light font-monospace flex items-center justify-between gap-2 border-b px-3 py-2 text-xs">
{isRead && (
<TooltipWrapper inline>
<span
className="text-read mr-1 inline-flex items-center text-sm"
aria-label="Marked as read"
>
✓
</span>
<Tooltip align="center" position="top">
Marked as read
</Tooltip>
</TooltipWrapper>
)}
<div
className="text-foreground min-w-0 truncate font-medium"
dangerouslySetInnerHTML={{ __html: highlightedFilePath }}
/>
<div className="flex shrink-0 items-center gap-2 text-[11px] whitespace-nowrap">
{!isPureRename && (
<span className="flex gap-2 text-[11px]">
{additions > 0 && <span className="text-success-light">+{additions}</span>}
{deletions > 0 && <span className="text-warning-light">-{deletions}</span>}
</span>
)}
<span className="text-muted">
({lineCount} {lineCount === 1 ? "line" : "lines"})
</span>
{onToggleRead && (
<TooltipWrapper inline>
<button
className="border-border-light text-muted hover:border-read hover:text-read flex cursor-pointer items-center gap-1 rounded-[3px] border bg-transparent px-1.5 py-0.5 text-[11px] transition-all duration-200 hover:bg-white/5 active:scale-95"
data-hunk-id={hunkId}
onClick={onToggleRead}
aria-label={`Mark as read (${formatKeybind(KEYBINDS.TOGGLE_HUNK_READ)})`}
>
{isRead ? "â—‹" : "â—‰"}
</button>
<Tooltip align="right" position="top">
Mark as read ({formatKeybind(KEYBINDS.TOGGLE_HUNK_READ)})
</Tooltip>
</TooltipWrapper>
)}
</div>
</div>
);
}
);

HunkHeader.displayName = "HunkHeader";
Loading