Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ All notable user-visible changes to Hunk are documented in this file.

### Added

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

### Changed
Expand Down
58 changes: 56 additions & 2 deletions src/ui/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { useMenuController } from "./hooks/useMenuController";
import { useReviewController } from "./hooks/useReviewController";
import { buildAppMenus } from "./lib/appMenus";
import { fileRowId } from "./lib/ids";
import { openSelectedFileInEditor } from "./lib/openInEditor";
import { resolveResponsiveLayout } from "./lib/responsive";
import { resizeSidebarWidth } from "./lib/sidebar";
import { resolveTheme, THEMES } from "./themes";
Expand Down Expand Up @@ -119,6 +120,8 @@ export function App({
const [sidebarWidth, setSidebarWidth] = useState(34);
const [resizeDragOriginX, setResizeDragOriginX] = useState<number | null>(null);
const [resizeStartWidth, setResizeStartWidth] = useState<number | null>(null);
const [sessionNoticeText, setSessionNoticeText] = useState<string | null>(null);
const sessionNoticeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);

const activeTheme = resolveTheme(themeId, bootstrap.initialThemeMode ?? null);
const review = useReviewController({ files: bootstrap.changeset.files });
Expand All @@ -142,6 +145,26 @@ export function App({
setShowAgentNotes(true);
}, []);

const showSessionNotice = useCallback((message: string) => {
setSessionNoticeText(message);
if (sessionNoticeTimeoutRef.current) {
clearTimeout(sessionNoticeTimeoutRef.current);
}

sessionNoticeTimeoutRef.current = setTimeout(() => {
setSessionNoticeText((current) => (current === message ? null : current));
sessionNoticeTimeoutRef.current = null;
}, 4000);
}, []);

useEffect(() => {
return () => {
if (sessionNoticeTimeoutRef.current) {
clearTimeout(sessionNoticeTimeoutRef.current);
}
};
}, []);

useHunkSessionBridge({
addLiveComment: review.addLiveComment,
addLiveCommentBatch: review.addLiveCommentBatch,
Expand Down Expand Up @@ -382,6 +405,30 @@ export function App({
});
}, [refreshCurrentInput]);

const triggerEditSelectedFile = useCallback(() => {
const message = openSelectedFileInEditor({
file: selectedFile,
renderer,
selectedHunk: review.selectedHunk,
});

if (message) {
showSessionNotice(message);
return;
}

if (canRefreshCurrentInput) {
triggerRefreshCurrentInput();
}
}, [
canRefreshCurrentInput,
renderer,
review.selectedHunk,
selectedFile,
showSessionNotice,
triggerRefreshCurrentInput,
]);

useEffect(() => {
if (!watchEnabled) {
return;
Expand Down Expand Up @@ -496,6 +543,7 @@ export function App({
toggleLineNumbers,
toggleLineWrap,
toggleSidebar,
triggerEditSelectedFile,
wrapLines,
}),
[
Expand All @@ -521,6 +569,7 @@ export function App({
toggleLineNumbers,
toggleLineWrap,
toggleSidebar,
triggerEditSelectedFile,
wrapLines,
],
);
Expand Down Expand Up @@ -569,6 +618,7 @@ export function App({
toggleLineNumbers,
toggleLineWrap,
toggleSidebar,
triggerEditSelectedFile,
triggerRefreshCurrentInput,
});

Expand Down Expand Up @@ -736,11 +786,15 @@ export function App({
/>
</box>

{!pagerMode && (focusArea === "filter" || Boolean(review.filter) || Boolean(noticeText)) ? (
{!pagerMode &&
(focusArea === "filter" ||
Boolean(review.filter) ||
Boolean(sessionNoticeText) ||
Boolean(noticeText)) ? (
<StatusBar
filter={review.filter}
filterFocused={focusArea === "filter"}
noticeText={noticeText ?? undefined}
noticeText={sessionNoticeText ?? noticeText ?? undefined}
terminalWidth={terminal.width}
theme={activeTheme}
onCloseMenu={closeMenu}
Expand Down
1 change: 1 addition & 0 deletions src/ui/components/chrome/HelpDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ export function HelpDialog({
["s / t", "sidebar / theme"],
["a", "toggle AI notes"],
["l / w / m", "lines / wrap / metadata"],
["e", "open file in $EDITOR"],
],
},
{
Expand Down
7 changes: 7 additions & 0 deletions src/ui/hooks/useAppKeyboardShortcuts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ export interface UseAppKeyboardShortcutsOptions {
toggleLineNumbers: () => void;
toggleLineWrap: () => void;
toggleSidebar: () => void;
triggerEditSelectedFile: () => void;
triggerRefreshCurrentInput: () => void;
}

Expand Down Expand Up @@ -100,6 +101,7 @@ export function useAppKeyboardShortcuts({
toggleLineNumbers,
toggleLineWrap,
toggleSidebar,
triggerEditSelectedFile,
triggerRefreshCurrentInput,
}: UseAppKeyboardShortcutsOptions) {
const activeMenuIdRef = useRef(activeMenuId);
Expand Down Expand Up @@ -423,6 +425,11 @@ export function useAppKeyboardShortcuts({
return;
}

if (key.name === "e" || key.sequence === "e") {
runAndCloseMenu(triggerEditSelectedFile);
return;
}

if (key.name === "[") {
runAndCloseMenu(() => moveToHunk(-1));
return;
Expand Down
8 changes: 8 additions & 0 deletions src/ui/lib/appMenus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export interface BuildAppMenusOptions {
toggleLineNumbers: () => void;
toggleLineWrap: () => void;
toggleSidebar: () => void;
triggerEditSelectedFile: () => void;
wrapLines: boolean;
}

Expand Down Expand Up @@ -54,6 +55,7 @@ export function buildAppMenus({
toggleLineNumbers,
toggleLineWrap,
toggleSidebar,
triggerEditSelectedFile,
wrapLines,
}: BuildAppMenusOptions): Record<MenuId, MenuEntry[]> {
const themeMenuEntries: MenuEntry[] = THEMES.map((theme) => ({
Expand All @@ -76,6 +78,12 @@ export function buildAppMenus({
hint: "/",
action: focusFilter,
},
{
kind: "item",
label: "Open file in editor",
hint: "e",
action: triggerEditSelectedFile,
},
];

if (canRefreshCurrentInput) {
Expand Down
62 changes: 62 additions & 0 deletions src/ui/lib/openInEditor.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { describe, expect, test } from "bun:test";
import { buildEditorCommand, shouldSuspendForEditor } from "./openInEditor";

describe("open in editor helpers", () => {
test("builds vi-style editor args without shell quoting", () => {
expect(
buildEditorCommand({
editor: "nvim",
filePath: "/tmp/project/file with spaces's.ts",
line: 12,
}),
).toEqual({
command: "nvim",
args: ["+12", "/tmp/project/file with spaces's.ts"],
});
});

test("preserves editor flags before appending the target file", () => {
expect(
buildEditorCommand({
editor: "code --reuse-window",
filePath: "/tmp/project/example.ts",
line: 4,
}),
).toEqual({
command: "code",
args: ["--reuse-window", "--goto", "/tmp/project/example.ts:4"],
});
});

test("handles quoted editor commands and Windows executable paths", () => {
expect(
buildEditorCommand({
editor: '"C:\\Program Files\\Microsoft VS Code\\bin\\code.cmd" --wait',
filePath: "C:\\Users\\Duarte\\repo\\file with spaces.ts",
line: 7,
}),
).toEqual({
command: "C:\\Program Files\\Microsoft VS Code\\bin\\code.cmd",
args: ["--wait", "--goto", "C:\\Users\\Duarte\\repo\\file with spaces.ts:7"],
});
});

test("defaults unknown editors to opening the file path only", () => {
expect(
buildEditorCommand({
editor: "zed --new-window",
filePath: "/tmp/project/example.ts",
line: 4,
}),
).toEqual({
command: "zed",
args: ["--new-window", "/tmp/project/example.ts"],
});
});

test("does not suspend for code-style GUI editors", () => {
expect(shouldSuspendForEditor("code --reuse-window")).toBe(false);
expect(shouldSuspendForEditor('"C:\\Program Files\\Cursor\\cursor.exe"')).toBe(false);
expect(shouldSuspendForEditor("nvim")).toBe(true);
});
});
136 changes: 136 additions & 0 deletions src/ui/lib/openInEditor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import { existsSync } from "node:fs";
import { basename, resolve, win32 } from "node:path";
import type { CliRenderer } from "@opentui/core";
import type { DiffFile } from "../../core/types";

export interface EditorCommand {
command: string;
args: string[];
}

function selectedLine(
file: DiffFile,
selectedHunk: DiffFile["metadata"]["hunks"][number] | undefined,
) {
if (file.metadata.type === "deleted") {
return selectedHunk?.deletionStart ?? 1;
}

return selectedHunk?.additionStart ?? 1;
}

function splitEditorCommand(editor: string) {
return (
editor
.match(/(?:[^\s"']+|"(?:\\.|[^"])*"|'(?:\\.|[^'])*')+/g)
?.map((token) => token.replace(/^(["'])(.*)\1$/, "$2")) ?? []
);
}

function editorProgram(editor: string) {
const [firstToken = ""] = splitEditorCommand(editor);
return basename(win32.basename(firstToken))
.replace(/\.(?:cmd|exe)$/i, "")
.toLowerCase();
}

const VI_STYLE_EDITORS = ["vim", "nvim", "vi"];
const CODE_STYLE_EDITORS = ["code", "code-insiders", "cursor"];

/** Suspend for terminal editors. */
export function shouldSuspendForEditor(editor: string) {
const program = editorProgram(editor);
if (CODE_STYLE_EDITORS.includes(program)) {
return false;
}

return true;
}

/** Build an editor process invocation without shell quoting so paths stay cross-platform. */
export function buildEditorCommand({
editor,
filePath,
line,
}: {
editor: string;
filePath: string;
line: number;
}): EditorCommand {
const [command = "", ...editorArgs] = splitEditorCommand(editor);
const program = editorProgram(editor);

if (VI_STYLE_EDITORS.includes(program)) {
return { command, args: [...editorArgs, `+${line}`, filePath] };
}

if (CODE_STYLE_EDITORS.includes(program)) {
return { command, args: [...editorArgs, "--goto", `${filePath}:${line}`] };
}

return { command, args: [...editorArgs, filePath] };
}

/** Open the selected file in $EDITOR, suspending TUI for terminal editors. */
export function openSelectedFileInEditor({
file,
renderer,
selectedHunk,
}: {
file: DiffFile | undefined;
renderer: Pick<CliRenderer, "suspend" | "resume" | "isDestroyed">;
selectedHunk: DiffFile["metadata"]["hunks"][number] | undefined;
}) {
if (!file) {
return "No file selected.";
}

const editor = process.env.EDITOR?.trim();
if (!editor) {
return "$EDITOR is not set.";
}

const absolutePath = resolve(process.cwd(), file.path);
if (!existsSync(absolutePath)) {
return `Cannot edit ${file.path}: file does not exist on disk.`;
}

const line = Math.max(1, selectedLine(file, selectedHunk));
const command = buildEditorCommand({
editor,
filePath: absolutePath,
line,
});

const shouldSuspend = shouldSuspendForEditor(editor);
if (shouldSuspend) {
renderer.suspend();
}

let exitCode = 0;
let failureMessage: string | null = null;
try {
const result = Bun.spawnSync([command.command, ...command.args], {
stdin: "inherit",
stdout: "inherit",
stderr: "inherit",
});
exitCode = result.exitCode;
} catch (error) {
failureMessage = error instanceof Error ? error.message : String(error);
}

if (shouldSuspend && !renderer.isDestroyed) {
renderer.resume();
}

if (failureMessage) {
return `Failed to launch editor: ${failureMessage}`;
}

if (exitCode !== 0) {
return `Editor exited with status ${exitCode}.`;
}

return null;
}
Loading