Skip to content

Commit db2b84e

Browse files
committed
feat(review): toggle unchanged context inline
Let the review controller own gap state and source-load lifecycles so keyboard, mouse, reload invalidation, and visible loading/error rows stay in one review flow.
1 parent bee8669 commit db2b84e

15 files changed

Lines changed: 1134 additions & 120 deletions

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ All notable user-visible changes to Hunk are documented in this file.
66

77
### Added
88

9+
- Added inline expansion for collapsed unchanged file content. Click an unchanged-context row (`▾ N unchanged lines` when expandable, otherwise the static `··· N unchanged lines ···` form) or press `e` while a hunk is selected to reveal surrounding and trailing file lines without leaving the review. The affordance is shown only for input modes that have reachable source content (`hunk diff`, `show`, `stash show`, file-pair `diff` and `difftool`, untracked files); raw `hunk patch` input still renders as before. Failed and in-flight loads surface a one-line status ("Loading…", "Could not load N unchanged lines") on the gap row. Expanded context rows use the same syntax highlighting as the surrounding diff.
10+
911
### Changed
1012

1113
### Fixed

src/ui/App.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -562,6 +562,7 @@ export function App({
562562
switchMenu,
563563
toggleAgentNotes,
564564
toggleFocusArea,
565+
toggleGapForSelectedHunk: review.toggleSelectedHunkGap,
565566
toggleHelp,
566567
toggleHunkHeaders,
567568
toggleLineNumbers,
@@ -702,6 +703,7 @@ export function App({
702703
<DiffPane
703704
codeHorizontalOffset={codeHorizontalOffset}
704705
diffContentWidth={diffContentWidth}
706+
expandedGapsByFileId={review.expandedGapsByFileId}
705707
files={filteredFiles}
706708
pagerMode={pagerMode}
707709
headerLabelWidth={diffHeaderLabelWidth}
@@ -715,6 +717,7 @@ export function App({
715717
showAgentNotes={showAgentNotes}
716718
showLineNumbers={showLineNumbers}
717719
showHunkHeaders={showHunkHeaders}
720+
sourceStatusByFileId={review.sourceStatusByFileId}
718721
wrapLines={wrapLines}
719722
wrapToggleScrollTop={wrapToggleScrollTopRef.current}
720723
layoutToggleScrollTop={layoutToggleScrollTopRef.current}
@@ -728,6 +731,7 @@ export function App({
728731
scrollCodeHorizontally(delta * FAST_CODE_HORIZONTAL_SCROLL_COLUMNS);
729732
}}
730733
onSelectFile={jumpToFile}
734+
onToggleGap={review.toggleGap}
731735
onViewportCenteredHunkChange={(fileId, hunkIndex) =>
732736
review.selectHunk(fileId, hunkIndex, { preserveViewport: true })
733737
}

src/ui/components/chrome/HelpDialog.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ export function HelpDialog({
4444
["1 / 2 / 0", "split / stack / auto"],
4545
["s / t", "sidebar / theme"],
4646
["a", "toggle AI notes"],
47+
["e", "toggle unchanged context"],
4748
["l / w / m", "lines / wrap / metadata"],
4849
],
4950
},

src/ui/components/panes/DiffPane.tsx

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
type RefObject,
1111
} from "react";
1212
import type { DiffFile, LayoutMode } from "../../../core/types";
13+
import type { FileSourceStatus } from "../../diff/expandCollapsedRows";
1314
import type { VisibleAgentNote } from "../../lib/agentAnnotations";
1415
import { computeHunkRevealScrollTop } from "../../lib/hunkScroll";
1516
import {
@@ -125,10 +126,16 @@ function buildHighlightPrefetchFileIds({
125126
return next;
126127
}
127128

129+
const EMPTY_EXPANDED_GAP_KEYS: ReadonlySet<string> = new Set();
130+
const EMPTY_EXPANDED_GAPS_BY_FILE_ID: Record<string, ReadonlySet<string>> = {};
131+
const EMPTY_SOURCE_STATUS_BY_FILE_ID: Record<string, FileSourceStatus> = {};
132+
const NOOP_TOGGLE_GAP = () => {};
133+
128134
/** Render the main multi-file review stream. */
129135
export function DiffPane({
130136
codeHorizontalOffset = 0,
131137
diffContentWidth,
138+
expandedGapsByFileId = EMPTY_EXPANDED_GAPS_BY_FILE_ID,
132139
files,
133140
headerLabelWidth,
134141
headerStatsWidth,
@@ -142,6 +149,7 @@ export function DiffPane({
142149
showAgentNotes,
143150
showLineNumbers,
144151
showHunkHeaders,
152+
sourceStatusByFileId = EMPTY_SOURCE_STATUS_BY_FILE_ID,
145153
wrapLines,
146154
wrapToggleScrollTop,
147155
layoutToggleScrollTop = null,
@@ -153,10 +161,12 @@ export function DiffPane({
153161
onOpenAgentNotesAtHunk,
154162
onScrollCodeHorizontally = () => {},
155163
onSelectFile,
164+
onToggleGap = NOOP_TOGGLE_GAP,
156165
onViewportCenteredHunkChange,
157166
}: {
158167
codeHorizontalOffset?: number;
159168
diffContentWidth: number;
169+
expandedGapsByFileId?: Record<string, ReadonlySet<string>>;
160170
files: DiffFile[];
161171
headerLabelWidth: number;
162172
headerStatsWidth: number;
@@ -170,6 +180,7 @@ export function DiffPane({
170180
showAgentNotes: boolean;
171181
showLineNumbers: boolean;
172182
showHunkHeaders: boolean;
183+
sourceStatusByFileId?: Record<string, FileSourceStatus>;
173184
wrapLines: boolean;
174185
wrapToggleScrollTop: number | null;
175186
layoutToggleScrollTop?: number | null;
@@ -181,6 +192,7 @@ export function DiffPane({
181192
onOpenAgentNotesAtHunk: (fileId: string, hunkIndex: number) => void;
182193
onScrollCodeHorizontally?: (delta: number) => void;
183194
onSelectFile: (fileId: string) => void;
195+
onToggleGap?: (fileId: string, gapKey: string) => void;
184196
onViewportCenteredHunkChange?: (fileId: string, hunkIndex: number) => void;
185197
}) {
186198
const renderer = useRenderer();
@@ -398,9 +410,21 @@ export function DiffPane({
398410
diffContentWidth,
399411
showLineNumbers,
400412
wrapLines,
413+
expandedGapsByFileId[file.id] ?? EMPTY_EXPANDED_GAP_KEYS,
414+
sourceStatusByFileId[file.id],
401415
),
402416
),
403-
[diffContentWidth, files, layout, showHunkHeaders, showLineNumbers, theme, wrapLines],
417+
[
418+
diffContentWidth,
419+
expandedGapsByFileId,
420+
files,
421+
layout,
422+
showHunkHeaders,
423+
showLineNumbers,
424+
sourceStatusByFileId,
425+
theme,
426+
wrapLines,
427+
],
404428
);
405429
const baseEstimatedBodyHeights = useMemo(
406430
() => baseSectionGeometry.map((metrics) => metrics.bodyHeight),
@@ -466,16 +490,20 @@ export function DiffPane({
466490
diffContentWidth,
467491
showLineNumbers,
468492
wrapLines,
493+
expandedGapsByFileId[file.id] ?? EMPTY_EXPANDED_GAP_KEYS,
494+
sourceStatusByFileId[file.id],
469495
);
470496
}),
471497
[
472498
allAgentNotesByFile,
473499
baseSectionGeometry,
474500
diffContentWidth,
501+
expandedGapsByFileId,
475502
files,
476503
layout,
477504
showHunkHeaders,
478505
showLineNumbers,
506+
sourceStatusByFileId,
479507
theme,
480508
wrapLines,
481509
],
@@ -1163,6 +1191,7 @@ export function DiffPane({
11631191
<DiffSection
11641192
key={file.id}
11651193
codeHorizontalOffset={codeHorizontalOffset}
1194+
expandedGapKeys={expandedGapsByFileId[file.id] ?? EMPTY_EXPANDED_GAP_KEYS}
11661195
file={file}
11671196
headerLabelWidth={headerLabelWidth}
11681197
headerStatsWidth={headerStatsWidth}
@@ -1175,6 +1204,7 @@ export function DiffPane({
11751204
showSeparator={index > 0}
11761205
showLineNumbers={showLineNumbers}
11771206
showHunkHeaders={showHunkHeaders}
1207+
sourceStatus={sourceStatusByFileId[file.id]}
11781208
wrapLines={wrapLines}
11791209
theme={theme}
11801210
viewWidth={diffContentWidth}
@@ -1186,6 +1216,7 @@ export function DiffPane({
11861216
onOpenAgentNotesAtHunk(file.id, hunkIndex)
11871217
}
11881218
onSelect={() => onSelectFile(file.id)}
1219+
onToggleGap={(gapKey) => onToggleGap(file.id, gapKey)}
11891220
/>
11901221
);
11911222
})}

src/ui/components/panes/DiffSection.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { memo } from "react";
22
import type { DiffFile, LayoutMode } from "../../../core/types";
33
import { PierreDiffView } from "../../diff/PierreDiffView";
4+
import type { FileSourceStatus } from "../../diff/expandCollapsedRows";
45
import type { VisibleBodyBounds } from "../../diff/rowWindowing";
56
import type { DiffSectionGeometry } from "../../lib/diffSectionGeometry";
67
import { getAnnotatedHunkIndices, type VisibleAgentNote } from "../../lib/agentAnnotations";
@@ -11,6 +12,7 @@ import { DiffFileHeaderRow } from "./DiffFileHeaderRow";
1112

1213
interface DiffSectionProps {
1314
codeHorizontalOffset: number;
15+
expandedGapKeys: ReadonlySet<string>;
1416
file: DiffFile;
1517
headerLabelWidth: number;
1618
headerStatsWidth: number;
@@ -21,6 +23,7 @@ interface DiffSectionProps {
2123
separatorWidth: number;
2224
showLineNumbers: boolean;
2325
showHunkHeaders: boolean;
26+
sourceStatus: FileSourceStatus | undefined;
2427
wrapLines: boolean;
2528
showHeader: boolean;
2629
showSeparator: boolean;
@@ -30,11 +33,13 @@ interface DiffSectionProps {
3033
viewWidth: number;
3134
onOpenAgentNotesAtHunk: (hunkIndex: number) => void;
3235
onSelect: () => void;
36+
onToggleGap: (gapKey: string) => void;
3337
}
3438

3539
/** Render one file section in the main review stream. */
3640
function DiffSectionComponent({
3741
codeHorizontalOffset,
42+
expandedGapKeys,
3843
file,
3944
headerLabelWidth,
4045
headerStatsWidth,
@@ -45,6 +50,7 @@ function DiffSectionComponent({
4550
separatorWidth,
4651
showLineNumbers,
4752
showHunkHeaders,
53+
sourceStatus,
4854
wrapLines,
4955
showHeader,
5056
showSeparator,
@@ -54,6 +60,7 @@ function DiffSectionComponent({
5460
viewWidth,
5561
onOpenAgentNotesAtHunk,
5662
onSelect,
63+
onToggleGap,
5764
}: DiffSectionProps) {
5865
const annotatedHunkIndices = getAnnotatedHunkIndices(file);
5966

@@ -92,17 +99,20 @@ function DiffSectionComponent({
9299
) : null}
93100

94101
<PierreDiffView
102+
expandedGapKeys={expandedGapKeys}
95103
file={file}
96104
layout={layout}
97105
showLineNumbers={showLineNumbers}
98106
showHunkHeaders={showHunkHeaders}
107+
sourceStatus={sourceStatus}
99108
wrapLines={wrapLines}
100109
codeHorizontalOffset={codeHorizontalOffset}
101110
theme={theme}
102111
width={viewWidth}
103112
annotatedHunkIndices={annotatedHunkIndices}
104113
visibleAgentNotes={visibleAgentNotes}
105114
onOpenAgentNotesAtHunk={onOpenAgentNotesAtHunk}
115+
onToggleGap={onToggleGap}
106116
selectedHunkIndex={selectedHunkIndex}
107117
sectionGeometry={sectionGeometry}
108118
shouldLoadHighlight={shouldLoadHighlight}
@@ -119,6 +129,7 @@ export const DiffSection = memo(DiffSectionComponent, (previous, next) => {
119129
// This comparator relies on stable upstream object identity for files and visible-note arrays.
120130
return (
121131
previous.codeHorizontalOffset === next.codeHorizontalOffset &&
132+
previous.expandedGapKeys === next.expandedGapKeys &&
122133
previous.file === next.file &&
123134
previous.headerLabelWidth === next.headerLabelWidth &&
124135
previous.headerStatsWidth === next.headerStatsWidth &&
@@ -129,6 +140,7 @@ export const DiffSection = memo(DiffSectionComponent, (previous, next) => {
129140
previous.separatorWidth === next.separatorWidth &&
130141
previous.showLineNumbers === next.showLineNumbers &&
131142
previous.showHunkHeaders === next.showHunkHeaders &&
143+
previous.sourceStatus === next.sourceStatus &&
132144
previous.wrapLines === next.wrapLines &&
133145
previous.showHeader === next.showHeader &&
134146
previous.showSeparator === next.showSeparator &&

0 commit comments

Comments
 (0)