Skip to content

Commit 91e04f7

Browse files
committed
🤖 Add read-more feature to code review hunks
- Add HunkReadMoreState type to track expansion state (up/down lines) - Add getReviewReadMoreStateKey storage helper for persistence - Create readFileLines utility using sed over existing executeBash IPC - Add expand up/down buttons in HunkViewer with ~30 line increments - Load and display expanded context with proper diff formatting - Store user expansion preferences per hunk in localStorage - Support expanding until beginning of file (disable button when reached) - Display loading states during line fetching
1 parent f64d2ea commit 91e04f7

File tree

4 files changed

+298
-19
lines changed

4 files changed

+298
-19
lines changed

src/components/RightSidebar/CodeReview/HunkViewer.tsx

Lines changed: 176 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,23 @@
33
*/
44

55
import React, { useState, useMemo } from "react";
6-
import type { DiffHunk } from "@/types/review";
6+
import type { DiffHunk, HunkReadMoreState } from "@/types/review";
77
import { SelectableDiffRenderer } from "../../shared/DiffRenderer";
88
import {
99
type SearchHighlightConfig,
1010
highlightSearchInText,
1111
} from "@/utils/highlighting/highlightSearchTerms";
1212
import { Tooltip, TooltipWrapper } from "../../Tooltip";
1313
import { usePersistedState } from "@/hooks/usePersistedState";
14-
import { getReviewExpandStateKey } from "@/constants/storage";
14+
import { getReviewExpandStateKey, getReviewReadMoreStateKey } from "@/constants/storage";
1515
import { KEYBINDS, formatKeybind } from "@/utils/ui/keybinds";
1616
import { cn } from "@/lib/utils";
17+
import {
18+
readFileLines,
19+
calculateUpwardExpansion,
20+
calculateDownwardExpansion,
21+
formatAsContextLines,
22+
} from "@/utils/review/readFileLines";
1723

1824
interface HunkViewerProps {
1925
hunk: DiffHunk;
@@ -105,6 +111,61 @@ export const HunkViewer = React.memo<HunkViewerProps>(
105111
}
106112
}, [hasManualState, manualExpandState]);
107113

114+
// Read-more state: tracks expanded lines up/down per hunk
115+
const [readMoreStateMap, setReadMoreStateMap] = usePersistedState<
116+
Record<string, HunkReadMoreState>
117+
>(getReviewReadMoreStateKey(workspaceId), {}, { listener: true });
118+
119+
const readMoreState = useMemo(
120+
() => readMoreStateMap[hunkId] || { up: 0, down: 0 },
121+
[readMoreStateMap, hunkId]
122+
);
123+
124+
// State for expanded content
125+
const [expandedContentUp, setExpandedContentUp] = useState<string>("");
126+
const [expandedContentDown, setExpandedContentDown] = useState<string>("");
127+
const [isLoadingUp, setIsLoadingUp] = useState(false);
128+
const [isLoadingDown, setIsLoadingDown] = useState(false);
129+
130+
// Load expanded content when read-more state changes
131+
React.useEffect(() => {
132+
if (readMoreState.up > 0) {
133+
const expansion = calculateUpwardExpansion(hunk.oldStart, readMoreState.up);
134+
if (expansion.numLines > 0) {
135+
setIsLoadingUp(true);
136+
void readFileLines(workspaceId, hunk.filePath, expansion.startLine, expansion.endLine)
137+
.then((lines) => {
138+
if (lines) {
139+
setExpandedContentUp(formatAsContextLines(lines));
140+
}
141+
})
142+
.finally(() => setIsLoadingUp(false));
143+
}
144+
} else {
145+
setExpandedContentUp("");
146+
}
147+
}, [readMoreState.up, hunk.oldStart, hunk.filePath, workspaceId]);
148+
149+
React.useEffect(() => {
150+
if (readMoreState.down > 0) {
151+
const expansion = calculateDownwardExpansion(
152+
hunk.oldStart,
153+
hunk.oldLines,
154+
readMoreState.down
155+
);
156+
setIsLoadingDown(true);
157+
void readFileLines(workspaceId, hunk.filePath, expansion.startLine, expansion.endLine)
158+
.then((lines) => {
159+
if (lines) {
160+
setExpandedContentDown(formatAsContextLines(lines));
161+
}
162+
})
163+
.finally(() => setIsLoadingDown(false));
164+
} else {
165+
setExpandedContentDown("");
166+
}
167+
}, [readMoreState.down, hunk.oldStart, hunk.oldLines, hunk.filePath, workspaceId]);
168+
108169
const handleToggleExpand = React.useCallback(
109170
(e?: React.MouseEvent) => {
110171
e?.stopPropagation();
@@ -131,6 +192,39 @@ export const HunkViewer = React.memo<HunkViewerProps>(
131192
onToggleRead?.(e);
132193
};
133194

195+
const handleExpandUp = React.useCallback(
196+
(e: React.MouseEvent) => {
197+
e.stopPropagation();
198+
const expansion = calculateUpwardExpansion(hunk.oldStart, readMoreState.up);
199+
if (expansion.startLine < 1 || expansion.numLines <= 0) {
200+
// Already at beginning of file
201+
return;
202+
}
203+
setReadMoreStateMap((prev) => ({
204+
...prev,
205+
[hunkId]: {
206+
...readMoreState,
207+
up: readMoreState.up + 30,
208+
},
209+
}));
210+
},
211+
[hunkId, hunk.oldStart, readMoreState, setReadMoreStateMap]
212+
);
213+
214+
const handleExpandDown = React.useCallback(
215+
(e: React.MouseEvent) => {
216+
e.stopPropagation();
217+
setReadMoreStateMap((prev) => ({
218+
...prev,
219+
[hunkId]: {
220+
...readMoreState,
221+
down: readMoreState.down + 30,
222+
},
223+
}));
224+
},
225+
[hunkId, readMoreState, setReadMoreStateMap]
226+
);
227+
134228
// Detect pure rename: if renamed and content hasn't changed (zero additions and deletions)
135229
const isPureRename =
136230
hunk.changeType === "renamed" && hunk.oldPath && additions === 0 && deletions === 0;
@@ -199,23 +293,86 @@ export const HunkViewer = React.memo<HunkViewerProps>(
199293
Renamed from <code>{hunk.oldPath}</code>
200294
</div>
201295
) : isExpanded ? (
202-
<div className="font-monospace bg-code-bg grid grid-cols-[minmax(min-content,1fr)] overflow-x-auto px-2 py-1.5 text-[11px] leading-[1.4]">
203-
<SelectableDiffRenderer
204-
content={hunk.content}
205-
filePath={hunk.filePath}
206-
oldStart={hunk.oldStart}
207-
newStart={hunk.newStart}
208-
maxHeight="none"
209-
onReviewNote={onReviewNote}
210-
onLineClick={() => {
211-
// Create synthetic event with data-hunk-id for parent handler
212-
const syntheticEvent = {
213-
currentTarget: { dataset: { hunkId } },
214-
} as unknown as React.MouseEvent<HTMLElement>;
215-
onClick?.(syntheticEvent);
216-
}}
217-
searchConfig={searchConfig}
218-
/>
296+
<div className="font-monospace bg-code-bg grid grid-cols-[minmax(min-content,1fr)] overflow-x-auto text-[11px] leading-[1.4]">
297+
{/* Read more upward button */}
298+
{(() => {
299+
const expansion = calculateUpwardExpansion(hunk.oldStart, readMoreState.up);
300+
const canExpandUp = expansion.startLine >= 1 && expansion.numLines > 0;
301+
return (
302+
canExpandUp && (
303+
<div className="border-border-light border-b px-2 py-1.5">
304+
<button
305+
onClick={handleExpandUp}
306+
disabled={isLoadingUp}
307+
className="text-muted hover:text-foreground disabled:text-muted w-full text-center text-[11px] italic disabled:cursor-not-allowed"
308+
>
309+
{isLoadingUp ? "Loading..." : `Read ${expansion.numLines} more lines ↑`}
310+
</button>
311+
</div>
312+
)
313+
);
314+
})()}
315+
{/* Expanded content upward */}
316+
{expandedContentUp && (
317+
<div className="px-2 py-1.5">
318+
<SelectableDiffRenderer
319+
content={expandedContentUp}
320+
filePath={hunk.filePath}
321+
oldStart={calculateUpwardExpansion(hunk.oldStart, readMoreState.up).startLine}
322+
newStart={calculateUpwardExpansion(hunk.oldStart, readMoreState.up).startLine}
323+
maxHeight="none"
324+
searchConfig={searchConfig}
325+
/>
326+
</div>
327+
)}
328+
{/* Original hunk content */}
329+
<div className="px-2 py-1.5">
330+
<SelectableDiffRenderer
331+
content={hunk.content}
332+
filePath={hunk.filePath}
333+
oldStart={hunk.oldStart}
334+
newStart={hunk.newStart}
335+
maxHeight="none"
336+
onReviewNote={onReviewNote}
337+
onLineClick={() => {
338+
// Create synthetic event with data-hunk-id for parent handler
339+
const syntheticEvent = {
340+
currentTarget: { dataset: { hunkId } },
341+
} as unknown as React.MouseEvent<HTMLElement>;
342+
onClick?.(syntheticEvent);
343+
}}
344+
searchConfig={searchConfig}
345+
/>
346+
</div>
347+
{/* Expanded content downward */}
348+
{expandedContentDown && (
349+
<div className="px-2 py-1.5">
350+
<SelectableDiffRenderer
351+
content={expandedContentDown}
352+
filePath={hunk.filePath}
353+
oldStart={
354+
calculateDownwardExpansion(hunk.oldStart, hunk.oldLines, readMoreState.down)
355+
.startLine
356+
}
357+
newStart={
358+
calculateDownwardExpansion(hunk.oldStart, hunk.oldLines, readMoreState.down)
359+
.startLine
360+
}
361+
maxHeight="none"
362+
searchConfig={searchConfig}
363+
/>
364+
</div>
365+
)}
366+
{/* Read more downward button */}
367+
<div className="border-border-light border-t px-2 py-1.5">
368+
<button
369+
onClick={handleExpandDown}
370+
disabled={isLoadingDown}
371+
className="text-muted hover:text-foreground disabled:text-muted w-full text-center text-[11px] italic disabled:cursor-not-allowed"
372+
>
373+
{isLoadingDown ? "Loading..." : "Read 30 more lines ↓"}
374+
</button>
375+
</div>
219376
</div>
220377
) : (
221378
<div

src/constants/storage.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,15 @@ export function getReviewSearchStateKey(workspaceId: string): string {
111111
return `reviewSearchState:${workspaceId}`;
112112
}
113113

114+
/**
115+
* Get the localStorage key for read-more expansion state in Review tab
116+
* Stores user's expanded context preferences per hunk
117+
* Format: "reviewReadMoreState:{workspaceId}"
118+
*/
119+
export function getReviewReadMoreStateKey(workspaceId: string): string {
120+
return `reviewReadMoreState:${workspaceId}`;
121+
}
122+
114123
/**
115124
* List of workspace-scoped key functions that should be copied on fork and deleted on removal
116125
* Note: Excludes ephemeral keys like getCompactContinueMessageKey
@@ -125,6 +134,7 @@ const PERSISTENT_WORKSPACE_KEY_FUNCTIONS: Array<(workspaceId: string) => string>
125134
getReviewExpandStateKey,
126135
getFileTreeExpandStateKey,
127136
getReviewSearchStateKey,
137+
getReviewReadMoreStateKey,
128138
];
129139

130140
/**

src/types/review.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,3 +93,14 @@ export interface ReviewStats {
9393
/** Number of unread hunks */
9494
unread: number;
9595
}
96+
97+
/**
98+
* Read-more expansion state for a hunk
99+
* Tracks how many lines have been expanded in each direction
100+
*/
101+
export interface HunkReadMoreState {
102+
/** Lines expanded upward from the hunk start */
103+
up: number;
104+
/** Lines expanded downward from the hunk end */
105+
down: number;
106+
}

src/utils/review/readFileLines.ts

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
/**
2+
* Utility functions for reading file lines using sed
3+
* Used by the read-more feature in code review
4+
*/
5+
6+
const LINES_PER_EXPANSION = 30;
7+
8+
/**
9+
* Read lines from a file using sed
10+
* @param workspaceId - The workspace ID
11+
* @param filePath - Path to the file relative to workspace root
12+
* @param startLine - Starting line number (1-indexed)
13+
* @param endLine - Ending line number (inclusive)
14+
* @returns Array of lines or null if error
15+
*/
16+
export async function readFileLines(
17+
workspaceId: string,
18+
filePath: string,
19+
startLine: number,
20+
endLine: number
21+
): Promise<string[] | null> {
22+
// Ensure valid line range
23+
if (startLine < 1 || endLine < startLine) {
24+
return null;
25+
}
26+
27+
// Use sed to read lines from the file
28+
// sed -n 'START,ENDp' FILE reads lines from START to END (inclusive)
29+
const script = `sed -n '${startLine},${endLine}p' "${filePath.replace(/"/g, '\\"')}"`;
30+
31+
const result = await window.api.workspace.executeBash(workspaceId, script, {
32+
timeout_secs: 3,
33+
});
34+
35+
if (!result.success) {
36+
console.error("Failed to read file lines:", result.error);
37+
return null;
38+
}
39+
40+
// When success is true, output is always a string (type narrowing)
41+
const bashResult = result.data;
42+
if (!bashResult.output) {
43+
console.error("No output from bash command");
44+
return null;
45+
}
46+
47+
// Split output into lines
48+
const lines = bashResult.output.split("\n");
49+
// Remove trailing empty line if present
50+
if (lines.length > 0 && lines[lines.length - 1] === "") {
51+
lines.pop();
52+
}
53+
54+
return lines;
55+
}
56+
57+
/**
58+
* Calculate line range for expanding context upward
59+
* @param oldStart - Starting line number of the hunk in the old file
60+
* @param currentExpansion - Current number of lines expanded upward
61+
* @returns Object with startLine and endLine for the expansion
62+
*/
63+
export function calculateUpwardExpansion(
64+
oldStart: number,
65+
currentExpansion: number
66+
): { startLine: number; endLine: number; numLines: number } {
67+
const newExpansion = currentExpansion + LINES_PER_EXPANSION;
68+
const startLine = Math.max(1, oldStart - newExpansion);
69+
const endLine = oldStart - currentExpansion - 1;
70+
const numLines = endLine - startLine + 1;
71+
72+
return { startLine, endLine, numLines };
73+
}
74+
75+
/**
76+
* Calculate line range for expanding context downward
77+
* @param oldStart - Starting line number of the hunk in the old file
78+
* @param oldLines - Number of lines in the hunk
79+
* @param currentExpansion - Current number of lines expanded downward
80+
* @returns Object with startLine and endLine for the expansion
81+
*/
82+
export function calculateDownwardExpansion(
83+
oldStart: number,
84+
oldLines: number,
85+
currentExpansion: number
86+
): { startLine: number; endLine: number; numLines: number } {
87+
const newExpansion = currentExpansion + LINES_PER_EXPANSION;
88+
const hunkEnd = oldStart + oldLines - 1;
89+
const startLine = hunkEnd + currentExpansion + 1;
90+
const endLine = hunkEnd + newExpansion;
91+
const numLines = endLine - startLine + 1;
92+
93+
return { startLine, endLine, numLines };
94+
}
95+
96+
/**
97+
* Format expanded lines as diff context lines (prefix with space)
98+
*/
99+
export function formatAsContextLines(lines: string[]): string {
100+
return lines.map((line) => ` ${line}`).join("\n");
101+
}

0 commit comments

Comments
 (0)