Skip to content

Commit 08800c7

Browse files
feat(ui): open file in editor with e shortcut (#310)
Co-authored-by: Ben Vinegar <ben@benv.ca>
1 parent f8dddc6 commit 08800c7

9 files changed

Lines changed: 283 additions & 5 deletions

File tree

CHANGELOG.md

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

77
### Added
88

9+
- Added an `e` shortcut to open the selected diff file in `$EDITOR`.
910
- Added `g` and `G` keyboard aliases for jump-to-top and jump-to-bottom review navigation.
1011

1112
### Changed

src/ui/App.tsx

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { useMenuController } from "./hooks/useMenuController";
2424
import { useReviewController } from "./hooks/useReviewController";
2525
import { buildAppMenus } from "./lib/appMenus";
2626
import { fileRowId } from "./lib/ids";
27+
import { openSelectedFileInEditor } from "./lib/openInEditor";
2728
import { resolveResponsiveLayout } from "./lib/responsive";
2829
import { resizeSidebarWidth } from "./lib/sidebar";
2930
import { resolveTheme, THEMES } from "./themes";
@@ -121,6 +122,8 @@ export function App({
121122
const [sidebarWidth, setSidebarWidth] = useState(34);
122123
const [resizeDragOriginX, setResizeDragOriginX] = useState<number | null>(null);
123124
const [resizeStartWidth, setResizeStartWidth] = useState<number | null>(null);
125+
const [sessionNoticeText, setSessionNoticeText] = useState<string | null>(null);
126+
const sessionNoticeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
124127

125128
const activeTheme = resolveTheme(themeId, detectedThemeMode ?? null);
126129
const review = useReviewController({ files: bootstrap.changeset.files });
@@ -144,6 +147,26 @@ export function App({
144147
setShowAgentNotes(true);
145148
}, []);
146149

150+
const showSessionNotice = useCallback((message: string) => {
151+
setSessionNoticeText(message);
152+
if (sessionNoticeTimeoutRef.current) {
153+
clearTimeout(sessionNoticeTimeoutRef.current);
154+
}
155+
156+
sessionNoticeTimeoutRef.current = setTimeout(() => {
157+
setSessionNoticeText((current) => (current === message ? null : current));
158+
sessionNoticeTimeoutRef.current = null;
159+
}, 4000);
160+
}, []);
161+
162+
useEffect(() => {
163+
return () => {
164+
if (sessionNoticeTimeoutRef.current) {
165+
clearTimeout(sessionNoticeTimeoutRef.current);
166+
}
167+
};
168+
}, []);
169+
147170
useHunkSessionBridge({
148171
addLiveComment: review.addLiveComment,
149172
addLiveCommentBatch: review.addLiveCommentBatch,
@@ -384,6 +407,30 @@ export function App({
384407
});
385408
}, [refreshCurrentInput]);
386409

410+
const triggerEditSelectedFile = useCallback(() => {
411+
const message = openSelectedFileInEditor({
412+
file: selectedFile,
413+
renderer,
414+
selectedHunk: review.selectedHunk,
415+
});
416+
417+
if (message) {
418+
showSessionNotice(message);
419+
return;
420+
}
421+
422+
if (canRefreshCurrentInput) {
423+
triggerRefreshCurrentInput();
424+
}
425+
}, [
426+
canRefreshCurrentInput,
427+
renderer,
428+
review.selectedHunk,
429+
selectedFile,
430+
showSessionNotice,
431+
triggerRefreshCurrentInput,
432+
]);
433+
387434
useEffect(() => {
388435
if (!watchEnabled) {
389436
return;
@@ -498,6 +545,7 @@ export function App({
498545
toggleLineNumbers,
499546
toggleLineWrap,
500547
toggleSidebar,
548+
triggerEditSelectedFile,
501549
wrapLines,
502550
}),
503551
[
@@ -523,6 +571,7 @@ export function App({
523571
toggleLineNumbers,
524572
toggleLineWrap,
525573
toggleSidebar,
574+
triggerEditSelectedFile,
526575
wrapLines,
527576
],
528577
);
@@ -571,6 +620,7 @@ export function App({
571620
toggleLineNumbers,
572621
toggleLineWrap,
573622
toggleSidebar,
623+
triggerEditSelectedFile,
574624
triggerRefreshCurrentInput,
575625
});
576626

@@ -738,11 +788,15 @@ export function App({
738788
/>
739789
</box>
740790

741-
{!pagerMode && (focusArea === "filter" || Boolean(review.filter) || Boolean(noticeText)) ? (
791+
{!pagerMode &&
792+
(focusArea === "filter" ||
793+
Boolean(review.filter) ||
794+
Boolean(sessionNoticeText) ||
795+
Boolean(noticeText)) ? (
742796
<StatusBar
743797
filter={review.filter}
744798
filterFocused={focusArea === "filter"}
745-
noticeText={noticeText ?? undefined}
799+
noticeText={sessionNoticeText ?? noticeText ?? undefined}
746800
terminalWidth={terminal.width}
747801
theme={activeTheme}
748802
onCloseMenu={closeMenu}

src/ui/components/chrome/HelpDialog.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ export function HelpDialog({
4747
["s / t", "sidebar / theme"],
4848
["a", "toggle AI notes"],
4949
["l / w / m", "lines / wrap / metadata"],
50+
["e", "open file in $EDITOR"],
5051
],
5152
},
5253
{

src/ui/components/ui-components.test.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1586,13 +1586,13 @@ describe("UI components", () => {
15861586
const frame = await captureFrame(
15871587
<HelpDialog
15881588
canRefresh={true}
1589-
terminalHeight={36}
1589+
terminalHeight={38}
15901590
terminalWidth={76}
15911591
theme={theme}
15921592
onClose={() => {}}
15931593
/>,
15941594
76,
1595-
36,
1595+
38,
15961596
);
15971597

15981598
const expectedRows = [
@@ -1605,6 +1605,7 @@ describe("UI components", () => {
16051605
"Shift+Space page up (alt)",
16061606
"d / u half page down / up",
16071607
"[ / ] previous / next hunk",
1608+
", / . previous / next file",
16081609
"{ / } previous / next comment",
16091610
"← / → scroll code left / right (Shift = faster)",
16101611
"Home / End jump to top / bottom",
@@ -1617,6 +1618,7 @@ describe("UI components", () => {
16171618
"s / t sidebar / theme",
16181619
"a toggle AI notes",
16191620
"l / w / m lines / wrap / metadata",
1621+
"e open file in $EDITOR",
16201622
"Review",
16211623
"/ focus file filter",
16221624
"Tab toggle files/filter focus",

src/ui/hooks/useAppKeyboardShortcuts.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ export interface UseAppKeyboardShortcutsOptions {
6868
toggleLineNumbers: () => void;
6969
toggleLineWrap: () => void;
7070
toggleSidebar: () => void;
71+
triggerEditSelectedFile: () => void;
7172
triggerRefreshCurrentInput: () => void;
7273
}
7374

@@ -100,6 +101,7 @@ export function useAppKeyboardShortcuts({
100101
toggleLineNumbers,
101102
toggleLineWrap,
102103
toggleSidebar,
104+
triggerEditSelectedFile,
103105
triggerRefreshCurrentInput,
104106
}: UseAppKeyboardShortcutsOptions) {
105107
const activeMenuIdRef = useRef(activeMenuId);
@@ -423,6 +425,11 @@ export function useAppKeyboardShortcuts({
423425
return;
424426
}
425427

428+
if (key.name === "e" || key.sequence === "e") {
429+
runAndCloseMenu(triggerEditSelectedFile);
430+
return;
431+
}
432+
426433
if (key.name === "[") {
427434
runAndCloseMenu(() => moveToHunk(-1));
428435
return;

src/ui/lib/appMenus.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ export interface BuildAppMenusOptions {
2626
toggleLineNumbers: () => void;
2727
toggleLineWrap: () => void;
2828
toggleSidebar: () => void;
29+
triggerEditSelectedFile: () => void;
2930
wrapLines: boolean;
3031
}
3132

@@ -54,6 +55,7 @@ export function buildAppMenus({
5455
toggleLineNumbers,
5556
toggleLineWrap,
5657
toggleSidebar,
58+
triggerEditSelectedFile,
5759
wrapLines,
5860
}: BuildAppMenusOptions): Record<MenuId, MenuEntry[]> {
5961
const themeMenuEntries: MenuEntry[] = THEMES.map((theme) => ({
@@ -76,6 +78,12 @@ export function buildAppMenus({
7678
hint: "/",
7779
action: focusFilter,
7880
},
81+
{
82+
kind: "item",
83+
label: "Open file in editor",
84+
hint: "e",
85+
action: triggerEditSelectedFile,
86+
},
7987
];
8088

8189
if (canRefreshCurrentInput) {

src/ui/lib/openInEditor.test.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { describe, expect, test } from "bun:test";
2+
import { buildEditorCommand, shouldSuspendForEditor } from "./openInEditor";
3+
4+
describe("open in editor helpers", () => {
5+
test("builds vi-style editor args without shell quoting", () => {
6+
expect(
7+
buildEditorCommand({
8+
editor: "nvim",
9+
filePath: "/tmp/project/file with spaces's.ts",
10+
line: 12,
11+
}),
12+
).toEqual({
13+
command: "nvim",
14+
args: ["+12", "/tmp/project/file with spaces's.ts"],
15+
});
16+
});
17+
18+
test("preserves editor flags before appending the target file", () => {
19+
expect(
20+
buildEditorCommand({
21+
editor: "code --reuse-window",
22+
filePath: "/tmp/project/example.ts",
23+
line: 4,
24+
}),
25+
).toEqual({
26+
command: "code",
27+
args: ["--reuse-window", "--goto", "/tmp/project/example.ts:4"],
28+
});
29+
});
30+
31+
test("handles quoted editor commands and Windows executable paths", () => {
32+
expect(
33+
buildEditorCommand({
34+
editor: '"C:\\Program Files\\Microsoft VS Code\\bin\\code.cmd" --wait',
35+
filePath: "C:\\Users\\Duarte\\repo\\file with spaces.ts",
36+
line: 7,
37+
}),
38+
).toEqual({
39+
command: "C:\\Program Files\\Microsoft VS Code\\bin\\code.cmd",
40+
args: ["--wait", "--goto", "C:\\Users\\Duarte\\repo\\file with spaces.ts:7"],
41+
});
42+
});
43+
44+
test("defaults unknown editors to opening the file path only", () => {
45+
expect(
46+
buildEditorCommand({
47+
editor: "zed --new-window",
48+
filePath: "/tmp/project/example.ts",
49+
line: 4,
50+
}),
51+
).toEqual({
52+
command: "zed",
53+
args: ["--new-window", "/tmp/project/example.ts"],
54+
});
55+
});
56+
57+
test("does not suspend for code-style GUI editors", () => {
58+
expect(shouldSuspendForEditor("code --reuse-window")).toBe(false);
59+
expect(shouldSuspendForEditor('"C:\\Program Files\\Cursor\\cursor.exe"')).toBe(false);
60+
expect(shouldSuspendForEditor("nvim")).toBe(true);
61+
});
62+
});

0 commit comments

Comments
 (0)