diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f0054c..ac62250 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 5f4239f..326a945 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -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"; @@ -119,6 +120,8 @@ export function App({ const [sidebarWidth, setSidebarWidth] = useState(34); const [resizeDragOriginX, setResizeDragOriginX] = useState(null); const [resizeStartWidth, setResizeStartWidth] = useState(null); + const [sessionNoticeText, setSessionNoticeText] = useState(null); + const sessionNoticeTimeoutRef = useRef | null>(null); const activeTheme = resolveTheme(themeId, bootstrap.initialThemeMode ?? null); const review = useReviewController({ files: bootstrap.changeset.files }); @@ -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, @@ -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; @@ -496,6 +543,7 @@ export function App({ toggleLineNumbers, toggleLineWrap, toggleSidebar, + triggerEditSelectedFile, wrapLines, }), [ @@ -521,6 +569,7 @@ export function App({ toggleLineNumbers, toggleLineWrap, toggleSidebar, + triggerEditSelectedFile, wrapLines, ], ); @@ -569,6 +618,7 @@ export function App({ toggleLineNumbers, toggleLineWrap, toggleSidebar, + triggerEditSelectedFile, triggerRefreshCurrentInput, }); @@ -736,11 +786,15 @@ export function App({ /> - {!pagerMode && (focusArea === "filter" || Boolean(review.filter) || Boolean(noticeText)) ? ( + {!pagerMode && + (focusArea === "filter" || + Boolean(review.filter) || + Boolean(sessionNoticeText) || + Boolean(noticeText)) ? ( void; toggleLineWrap: () => void; toggleSidebar: () => void; + triggerEditSelectedFile: () => void; triggerRefreshCurrentInput: () => void; } @@ -100,6 +101,7 @@ export function useAppKeyboardShortcuts({ toggleLineNumbers, toggleLineWrap, toggleSidebar, + triggerEditSelectedFile, triggerRefreshCurrentInput, }: UseAppKeyboardShortcutsOptions) { const activeMenuIdRef = useRef(activeMenuId); @@ -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; diff --git a/src/ui/lib/appMenus.ts b/src/ui/lib/appMenus.ts index 058bb33..11942a2 100644 --- a/src/ui/lib/appMenus.ts +++ b/src/ui/lib/appMenus.ts @@ -26,6 +26,7 @@ export interface BuildAppMenusOptions { toggleLineNumbers: () => void; toggleLineWrap: () => void; toggleSidebar: () => void; + triggerEditSelectedFile: () => void; wrapLines: boolean; } @@ -54,6 +55,7 @@ export function buildAppMenus({ toggleLineNumbers, toggleLineWrap, toggleSidebar, + triggerEditSelectedFile, wrapLines, }: BuildAppMenusOptions): Record { const themeMenuEntries: MenuEntry[] = THEMES.map((theme) => ({ @@ -76,6 +78,12 @@ export function buildAppMenus({ hint: "/", action: focusFilter, }, + { + kind: "item", + label: "Open file in editor", + hint: "e", + action: triggerEditSelectedFile, + }, ]; if (canRefreshCurrentInput) { diff --git a/src/ui/lib/openInEditor.test.ts b/src/ui/lib/openInEditor.test.ts new file mode 100644 index 0000000..48a07c3 --- /dev/null +++ b/src/ui/lib/openInEditor.test.ts @@ -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); + }); +}); diff --git a/src/ui/lib/openInEditor.ts b/src/ui/lib/openInEditor.ts new file mode 100644 index 0000000..616b6df --- /dev/null +++ b/src/ui/lib/openInEditor.ts @@ -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; + 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; +} diff --git a/src/ui/lib/ui-lib.test.ts b/src/ui/lib/ui-lib.test.ts index 14136c5..7c7dd03 100644 --- a/src/ui/lib/ui-lib.test.ts +++ b/src/ui/lib/ui-lib.test.ts @@ -144,6 +144,7 @@ describe("ui helpers", () => { toggleLineNumbers: () => {}, toggleLineWrap: () => {}, toggleSidebar: () => {}, + triggerEditSelectedFile: () => {}, wrapLines: true, }); @@ -151,7 +152,13 @@ describe("ui helpers", () => { menus.file .filter((entry): entry is Extract => entry.kind === "item") .map((entry) => entry.label), - ).toEqual(["Toggle files/filter focus", "Focus filter", "Reload", "Quit"]); + ).toEqual([ + "Toggle files/filter focus", + "Focus filter", + "Open file in editor", + "Reload", + "Quit", + ]); expect(menus.file[0]).toMatchObject({ kind: "item", label: "Toggle files/filter focus",