Skip to content

Commit 37a8fc2

Browse files
committed
feat: add ]c/[c change line jump navigation
Add Vim-style ]c (next change) and [c (previous change) keybindings to jump between add/delete lines in the PR detail diff view. Uses a pendingBracket state for 2-key sequence handling with visual feedback in the footer. https://claude.ai/code/session_015NbnHYsXMH45WpAakjZoki
1 parent 8e7cd66 commit 37a8fc2

3 files changed

Lines changed: 196 additions & 5 deletions

File tree

src/components/Help.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,8 @@ export function Help({ onClose }: Props) {
4949
<KeyRow keyName="G" description="Jump to end" />
5050
<KeyRow keyName="n" description="Next file" />
5151
<KeyRow keyName="N" description="Previous file" />
52+
<KeyRow keyName="]c" description="Next change (add/delete line)" />
53+
<KeyRow keyName="[c" description="Previous change (add/delete line)" />
5254
<KeyRow keyName="f" description="File list" />
5355
<KeyRow keyName="Enter" description="Select / confirm" />
5456
<KeyRow keyName="q / Esc" description="Back / quit" />

src/components/PullRequestDetail.test.tsx

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7986,4 +7986,159 @@ describe("PullRequestDetail", () => {
79867986
stdin.write("g");
79877987
expect(lastFrame()).not.toContain("React to comment:");
79887988
});
7989+
7990+
it("]c jumps to next add/delete line", async () => {
7991+
const { stdin, lastFrame } = render(
7992+
<PullRequestDetail
7993+
pullRequest={pullRequest as any}
7994+
differences={differences as any}
7995+
commentThreads={[] as any}
7996+
diffTexts={diffTexts}
7997+
onBack={vi.fn()}
7998+
onHelp={vi.fn()}
7999+
onShowActivity={vi.fn()}
8000+
comment={{ onPost: vi.fn(), isProcessing: false, error: null, onClearError: vi.fn() }}
8001+
inlineComment={defaultInlineCommentProps}
8002+
reply={defaultReplyProps}
8003+
approval={defaultApprovalProps}
8004+
merge={defaultMergeProps}
8005+
close={defaultCloseProps}
8006+
commitView={defaultCommitProps}
8007+
editComment={defaultEditCommentProps}
8008+
deleteComment={defaultDeleteCommentProps}
8009+
reaction={defaultReactionProps}
8010+
/>,
8011+
);
8012+
8013+
// Cursor starts at index 0 (header). Press ]c to jump to first change line.
8014+
stdin.write("]");
8015+
await vi.waitFor(() => {
8016+
expect(lastFrame()).toContain("]_");
8017+
});
8018+
stdin.write("c");
8019+
await vi.waitFor(() => {
8020+
const output = lastFrame()!;
8021+
// Should land on a delete or add line (contains - or +)
8022+
expect(output).toMatch(/>.*.*[-+]/);
8023+
});
8024+
});
8025+
8026+
it("[c jumps to previous add/delete line", async () => {
8027+
const { stdin, lastFrame } = render(
8028+
<PullRequestDetail
8029+
pullRequest={pullRequest as any}
8030+
differences={differences as any}
8031+
commentThreads={[] as any}
8032+
diffTexts={diffTexts}
8033+
onBack={vi.fn()}
8034+
onHelp={vi.fn()}
8035+
onShowActivity={vi.fn()}
8036+
comment={{ onPost: vi.fn(), isProcessing: false, error: null, onClearError: vi.fn() }}
8037+
inlineComment={defaultInlineCommentProps}
8038+
reply={defaultReplyProps}
8039+
approval={defaultApprovalProps}
8040+
merge={defaultMergeProps}
8041+
close={defaultCloseProps}
8042+
commitView={defaultCommitProps}
8043+
editComment={defaultEditCommentProps}
8044+
deleteComment={defaultDeleteCommentProps}
8045+
reaction={defaultReactionProps}
8046+
/>,
8047+
);
8048+
8049+
// Jump to end first, then [c to find previous change
8050+
stdin.write("G");
8051+
await vi.waitFor(() => {
8052+
expect(lastFrame()).toBeTruthy();
8053+
});
8054+
stdin.write("[");
8055+
// Wait for pendingBracket to be processed and shown in footer
8056+
await vi.waitFor(() => {
8057+
expect(lastFrame()).toContain("[_");
8058+
});
8059+
stdin.write("c");
8060+
await vi.waitFor(() => {
8061+
const output = lastFrame()!;
8062+
expect(output).toMatch(/>.*.*[-+]/);
8063+
});
8064+
});
8065+
8066+
it("]c does nothing when no change lines ahead", async () => {
8067+
// Use diffTexts with no changes (identical before/after)
8068+
const noDiffTexts = new Map([["b1:b2", { before: "line1\nline2", after: "line1\nline2" }]]);
8069+
const { stdin, lastFrame } = render(
8070+
<PullRequestDetail
8071+
pullRequest={pullRequest as any}
8072+
differences={differences as any}
8073+
commentThreads={[] as any}
8074+
diffTexts={noDiffTexts}
8075+
onBack={vi.fn()}
8076+
onHelp={vi.fn()}
8077+
onShowActivity={vi.fn()}
8078+
comment={{ onPost: vi.fn(), isProcessing: false, error: null, onClearError: vi.fn() }}
8079+
inlineComment={defaultInlineCommentProps}
8080+
reply={defaultReplyProps}
8081+
approval={defaultApprovalProps}
8082+
merge={defaultMergeProps}
8083+
close={defaultCloseProps}
8084+
commitView={defaultCommitProps}
8085+
editComment={defaultEditCommentProps}
8086+
deleteComment={defaultDeleteCommentProps}
8087+
reaction={defaultReactionProps}
8088+
/>,
8089+
);
8090+
8091+
stdin.write("]");
8092+
await vi.waitFor(() => {
8093+
expect(lastFrame()).toContain("]_");
8094+
});
8095+
stdin.write("c");
8096+
await vi.waitFor(() => {
8097+
// After ]c with no changes, footer returns to normal (no pending bracket)
8098+
expect(lastFrame()).not.toContain("]_");
8099+
});
8100+
// Cursor position should be unchanged (still on header line)
8101+
expect(lastFrame()).toContain("> ");
8102+
expect(lastFrame()).toContain("src/auth.ts");
8103+
});
8104+
8105+
it("[ + j resets pendingBracket and j moves cursor normally", async () => {
8106+
const { stdin, lastFrame } = render(
8107+
<PullRequestDetail
8108+
pullRequest={pullRequest as any}
8109+
differences={differences as any}
8110+
commentThreads={[] as any}
8111+
diffTexts={diffTexts}
8112+
onBack={vi.fn()}
8113+
onHelp={vi.fn()}
8114+
onShowActivity={vi.fn()}
8115+
comment={{ onPost: vi.fn(), isProcessing: false, error: null, onClearError: vi.fn() }}
8116+
inlineComment={defaultInlineCommentProps}
8117+
reply={defaultReplyProps}
8118+
approval={defaultApprovalProps}
8119+
merge={defaultMergeProps}
8120+
close={defaultCloseProps}
8121+
commitView={defaultCommitProps}
8122+
editComment={defaultEditCommentProps}
8123+
deleteComment={defaultDeleteCommentProps}
8124+
reaction={defaultReactionProps}
8125+
/>,
8126+
);
8127+
8128+
// Press [ (sets pendingBracket), then j (resets and does normal cursor down)
8129+
stdin.write("[");
8130+
await vi.waitFor(() => {
8131+
expect(lastFrame()).toContain("[_");
8132+
});
8133+
stdin.write("j");
8134+
await vi.waitFor(() => {
8135+
const output = lastFrame()!;
8136+
// Cursor should have moved down one line (to separator, the second line)
8137+
const lines = output.split("\n");
8138+
const cursorLine = lines.find((l) => l.startsWith(">") || l.includes("> "));
8139+
// The cursor should be on a line that is NOT the header (first line)
8140+
expect(cursorLine).toBeTruthy();
8141+
expect(cursorLine).not.toContain("src/auth.ts");
8142+
});
8143+
});
79898144
});

src/components/PullRequestDetail.tsx

Lines changed: 39 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,7 @@ export function PullRequestDetail({
255255
const [diffLineLimits, setDiffLineLimits] = useState<Map<string, number>>(new Map());
256256
const [showFileList, setShowFileList] = useState(false);
257257
const [fileListCursor, setFileListCursor] = useState(0);
258+
const [pendingBracket, setPendingBracket] = useState<"[" | "]" | null>(null);
258259
const diffCacheRef = useRef<Map<string, DisplayLine[]>>(new Map());
259260

260261
useEffect(() => {
@@ -415,6 +416,29 @@ export function PullRequestDetail({
415416
)
416417
return;
417418

419+
// === 2-key sequence: ]c / [c for change navigation ===
420+
if (pendingBracket && input === "c") {
421+
const isNext = pendingBracket === "]";
422+
setPendingBracket(null);
423+
if (isNext) {
424+
const idx = lines.findIndex(
425+
(l, i) => i > cursorIndex && (l.type === "add" || l.type === "delete"),
426+
);
427+
if (idx !== -1) setCursorIndex(idx);
428+
} else {
429+
for (let i = cursorIndex - 1; i >= 0; i--) {
430+
if (lines[i]!.type === "add" || lines[i]!.type === "delete") {
431+
setCursorIndex(i);
432+
break;
433+
}
434+
}
435+
}
436+
return;
437+
}
438+
if (pendingBracket) {
439+
setPendingBracket(null);
440+
}
441+
418442
if (key.tab && (commits.length > 0 || commitsAvailable)) {
419443
if (commits.length === 0) {
420444
// Lazy load: trigger first commit load
@@ -599,6 +623,14 @@ export function PullRequestDetail({
599623
setReactionTarget(currentLine.commentId);
600624
return;
601625
}
626+
if (input === "]") {
627+
setPendingBracket("]");
628+
return;
629+
}
630+
if (input === "[") {
631+
setPendingBracket("[");
632+
return;
633+
}
602634
});
603635

604636
const scrollOffset = useMemo(() => {
@@ -901,11 +933,13 @@ export function PullRequestDetail({
901933
reactionTarget ||
902934
showFileList
903935
? ""
904-
: viewIndex === -1 && (commits.length > 0 || commitsAvailable)
905-
? `Tab view ↑↓ n/N file f list c comment C inline R reply o fold e edit d del g react a/r approve m merge x close A activity q ? help${hasTruncation ? " t more" : ""}`
906-
: viewIndex >= 0
907-
? "Tab next Shift+Tab prev ↑↓ e edit d del a/r approve m merge x close q ? help"
908-
: `↑↓ n/N file f list c comment C inline R reply o fold e edit d del g react a/r approve m merge x close A activity q ? help${hasTruncation ? " t more" : ""}`}
936+
: pendingBracket
937+
? `${pendingBracket}_ Press c for ${pendingBracket === "]" ? "next" : "previous"} change, other key to cancel`
938+
: viewIndex === -1 && (commits.length > 0 || commitsAvailable)
939+
? `Tab view ↑↓ n/N file ]c/[c change f list c comment C inline R reply o fold e edit d del g react a/r approve m merge x close A activity q ? help${hasTruncation ? " t more" : ""}`
940+
: viewIndex >= 0
941+
? "Tab next Shift+Tab prev ↑↓ ]c/[c change e edit d del a/r approve m merge x close q ? help"
942+
: `↑↓ n/N file ]c/[c change f list c comment C inline R reply o fold e edit d del g react a/r approve m merge x close A activity q ? help${hasTruncation ? " t more" : ""}`}
909943
</Text>
910944
</Box>
911945
</Box>

0 commit comments

Comments
 (0)