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
5 changes: 5 additions & 0 deletions .changeset/current-line-editor.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"hunkdiff": minor
---

Open `$EDITOR` at the current line instead of the start of the selected hunk.
14 changes: 10 additions & 4 deletions src/ui/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -417,6 +417,11 @@ export function App({
const currentLinePaintPending =
currentLinePaintState.status === "pending" ||
(currentLinePaintState.status === "ready" && !currentLinePaintMatchesCursor);
/** The review stream's current line, or null when line-level navigation is off. */
const activeLineCursor = useMemo(
() => (cursorLine === "off" ? null : review.lineCursor),
[cursorLine, review.lineCursor],
);
const sessionFileViews = useMemo(
() => (extensions ? resolveExtensionFileViews(extensions.registry).views : []),
[extensions],
Expand Down Expand Up @@ -1222,7 +1227,7 @@ export function App({

/** Step one line: move the current line, or scroll the viewport when there is no marker. */
const stepDiffLine = (delta: number) => {
if (cursorLine === "off" || !review.lineCursor) {
if (!activeLineCursor) {
scrollDiff(delta, "step");
return;
}
Expand Down Expand Up @@ -1569,6 +1574,7 @@ export function App({
const message = openSelectedFileInEditor({
basePath,
file: selectedFile,
lineCursor: activeLineCursor,
renderer,
selectedHunk: review.selectedHunk,
});
Expand All @@ -1582,6 +1588,7 @@ export function App({
triggerRefreshCurrentInput();
}
}, [
activeLineCursor,
bootstrap.changeset.sourceLabel,
bootstrap.input.kind,
canRefreshCurrentInput,
Expand Down Expand Up @@ -1729,8 +1736,7 @@ export function App({
const startUserNote = useCallback(
(fileId?: string, hunkIndex?: number, target?: UserNoteLineTarget) => {
const hoverTarget = fileId === undefined ? activeAddNoteTarget : null;
const keyboardTarget =
hoverTarget ?? (fileId === undefined && cursorLine !== "off" ? review.lineCursor : null);
const keyboardTarget = hoverTarget ?? (fileId === undefined ? activeLineCursor : null);
const draft = review.startUserNote(
fileId ?? keyboardTarget?.fileId,
hunkIndex ?? keyboardTarget?.hunkIndex,
Expand All @@ -1742,7 +1748,7 @@ export function App({
setFocusArea("note");
}
},
[activeAddNoteTarget, cursorLine, review.lineCursor, review.startUserNote],
[activeAddNoteTarget, activeLineCursor, review.startUserNote],
);

/** Mark the inline draft note textarea as the active keyboard input. */
Expand Down
148 changes: 148 additions & 0 deletions src/ui/AppHost.edit-in-editor.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { testRender } from "@opentui/react/test-utils";
import { mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { act } from "react";
import type { AppBootstrap } from "../core/types";
import { createTestVcsAppBootstrap } from "../../test/helpers/app-bootstrap";
import { createTestDiffFile, lines } from "../../test/helpers/diff-helpers";

const { AppHost } = await import("./AppHost");

const WIDE = { width: 200, height: 24 };

const BEFORE = lines(
"const alpha = 1;",
"const beta = 2;",
"const gamma = 3;",
"const delta = 4;",
"const epsilon = 5;",
);
const AFTER = lines(
"const alpha = 1;",
"const beta = 22222;",
"const gamma = 3;",
"const delta = 4;",
"const epsilon = 5;",
);

const originalEditor = process.env.EDITOR;
const originalSpawnSync = Bun.spawnSync;
const tempDirs: string[] = [];

let setup: Awaited<ReturnType<typeof testRender>> | undefined;

function createTempWorkspace() {
const dir = realpathSync(mkdtempSync(join(tmpdir(), "hunk-apphost-editor-")));
tempDirs.push(dir);
writeFileSync(join(dir, "sample.ts"), AFTER);
return dir;
}

function mockSpawnSync(implementation: typeof Bun.spawnSync) {
const mutableBun = Bun as unknown as { spawnSync: typeof Bun.spawnSync };
mutableBun.spawnSync = implementation;
}

/** Bootstrap one working-tree review whose file really exists under `sourceLabel`. */
function createEditorBootstrap(sourceLabel: string): AppBootstrap {
return createTestVcsAppBootstrap({
changesetId: "changeset:edit-in-editor",
initialMode: "stack",
sourceLabel,
files: [
createTestDiffFile({
after: AFTER,
agent: false,
before: BEFORE,
context: 3,
id: "sample",
path: "sample.ts",
}),
],
});
}

async function flush(target: Awaited<ReturnType<typeof testRender>>) {
await act(async () => {
await target.renderOnce();
await Bun.sleep(0);
await target.renderOnce();
});
}

async function pressKeys(target: Awaited<ReturnType<typeof testRender>>, keys: string) {
for (const key of keys) {
await act(async () => {
await target.mockInput.typeText(key);
});
await flush(target);
}
}

beforeEach(() => {
delete process.env.EDITOR;
});

afterEach(async () => {
if (setup) {
const current = setup;
setup = undefined;
await act(async () => {
current.renderer.destroy();
});
}

if (originalEditor === undefined) {
delete process.env.EDITOR;
} else {
process.env.EDITOR = originalEditor;
}
mockSpawnSync(originalSpawnSync);

while (tempDirs.length > 0) {
const dir = tempDirs.pop();
if (dir) {
rmSync(dir, { recursive: true, force: true });
}
}
});

describe("AppHost edit-selected-file shortcut", () => {
test("pressing e with no $EDITOR surfaces a notice instead of crashing", async () => {
setup = await testRender(
<AppHost bootstrap={createEditorBootstrap(createTempWorkspace())} />,
WIDE,
);
await flush(setup);

await pressKeys(setup, "e");

// openSelectedFileInEditor returns "$EDITOR is not set." which shows as a session notice.
expect(setup.captureCharFrame()).toContain("EDITOR is not set");
});

test("pressing e opens the editor at the current line, not the hunk start", async () => {
const workspace = createTempWorkspace();
process.env.EDITOR = "vim";

const spawnCalls: string[][] = [];
mockSpawnSync(((cmds: string[]) => {
spawnCalls.push(cmds);
return { exitCode: 1 };
}) as unknown as typeof Bun.spawnSync);

setup = await testRender(<AppHost bootstrap={createEditorBootstrap(workspace)} />, WIDE);
await flush(setup);

// The hunk starts at line 1; step down onto the changed line, then one line past it.
await pressKeys(setup, "jje");
await pressKeys(setup, "je");

expect(spawnCalls).toEqual([
["vim", "+2", join(workspace, "sample.ts")],
["vim", "+3", join(workspace, "sample.ts")],
]);
});
});
29 changes: 0 additions & 29 deletions src/ui/AppHost.sidebar-resize.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -196,32 +196,3 @@ describe("AppHost sidebar resize", () => {
expect(dividerColumn(setup)).toBe(INITIAL_DIVIDER_COLUMN);
});
});

describe("AppHost edit-selected-file shortcut", () => {
const originalEditor = process.env.EDITOR;

beforeEach(() => {
delete process.env.EDITOR;
});

afterEach(() => {
if (originalEditor === undefined) {
delete process.env.EDITOR;
} else {
process.env.EDITOR = originalEditor;
}
});

test("pressing e with no $EDITOR surfaces a notice instead of crashing", async () => {
setup = await testRender(<AppHost bootstrap={createResizeBootstrap()} />, WIDE);
await flush(setup);

await act(async () => {
await setup!.mockInput.typeText("e");
});
await flush(setup);

// openSelectedFileInEditor returns "$EDITOR is not set." which shows as a session notice.
expect(setup.captureCharFrame()).toContain("EDITOR is not set");
});
});
Loading