Skip to content

Commit cd00647

Browse files
committed
feat(embedded): add host API for mounting Hunk
1 parent 9b01f12 commit cd00647

11 files changed

Lines changed: 610 additions & 5 deletions

package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,10 @@
3939
"types": "./dist/npm/opentui/index.d.ts",
4040
"import": "./dist/npm/opentui/index.js"
4141
},
42+
"./embedded": {
43+
"types": "./dist/npm/embedded/index.d.ts",
44+
"import": "./dist/npm/embedded/index.js"
45+
},
4246
"./package.json": "./package.json"
4347
},
4448
"publishConfig": {

scripts/build-npm.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ const outdir = path.join(repoRoot, "dist", "npm");
88
const typesOutdir = path.join(repoRoot, "dist", "npm-types");
99
const opentuiOutdir = path.join(outdir, "opentui");
1010
const opentuiTypesDir = path.join(typesOutdir, "opentui");
11+
const embeddedOutdir = path.join(outdir, "embedded");
1112

1213
const bunEnv = {
1314
...process.env,
@@ -32,6 +33,7 @@ function runBun(args: string[]) {
3233
rmSync(outdir, { recursive: true, force: true });
3334
rmSync(typesOutdir, { recursive: true, force: true });
3435
mkdirSync(opentuiOutdir, { recursive: true });
36+
mkdirSync(embeddedOutdir, { recursive: true });
3537

3638
runBun([
3739
"build",
@@ -81,6 +83,22 @@ runBun([
8183
"index.js",
8284
]);
8385

86+
const embeddedBuild = await Bun.build({
87+
entrypoints: [path.join(repoRoot, "src", "embedded", "index.tsx")],
88+
target: "node",
89+
format: "esm",
90+
outdir: embeddedOutdir,
91+
naming: { entry: "index.js" },
92+
external: ["@opentui/core", "@opentui/react", "@pierre/diffs", "react", "react/jsx-runtime"],
93+
});
94+
95+
if (!embeddedBuild.success) {
96+
for (const log of embeddedBuild.logs) {
97+
console.error(log.message);
98+
}
99+
throw new Error("Failed to build embedded Hunk export.");
100+
}
101+
84102
runBun(["x", "tsc", "-p", path.join(repoRoot, "tsconfig.opentui.json")]);
85103

86104
for (const entry of readdirSync(opentuiTypesDir)) {
@@ -89,7 +107,13 @@ for (const entry of readdirSync(opentuiTypesDir)) {
89107
}
90108
}
91109

110+
copyFileSync(
111+
path.join(repoRoot, "src", "embedded", "index.d.ts"),
112+
path.join(embeddedOutdir, "index.d.ts"),
113+
);
114+
92115
rmSync(typesOutdir, { recursive: true, force: true });
93116

94117
console.log(`Built ${mainJs}`);
95118
console.log(`Built ${path.join(opentuiOutdir, "index.js")}`);
119+
console.log(`Built ${path.join(embeddedOutdir, "index.js")}`);

src/embedded/embedded.test.ts

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
import { describe, expect, test } from "bun:test";
2+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3+
import { tmpdir } from "node:os";
4+
import { join } from "node:path";
5+
import { createEmbeddedHunkSession, embeddedSourceToCliInput } from "./index";
6+
7+
const patchText = [
8+
"diff --git a/example.ts b/example.ts",
9+
"--- a/example.ts",
10+
"+++ b/example.ts",
11+
"@@ -1 +1 @@",
12+
"-const value = 1;",
13+
"+const value = 2;",
14+
"",
15+
].join("\n");
16+
17+
describe("embeddedSourceToCliInput", () => {
18+
test("maps embedded review sources to Hunk CLI inputs", () => {
19+
expect(embeddedSourceToCliInput({ kind: "worktree", options: { theme: "midnight" } })).toEqual({
20+
kind: "vcs",
21+
staged: false,
22+
options: { theme: "midnight" },
23+
});
24+
expect(embeddedSourceToCliInput({ kind: "staged" })).toEqual({
25+
kind: "vcs",
26+
staged: true,
27+
options: {},
28+
});
29+
expect(embeddedSourceToCliInput({ kind: "show", ref: "HEAD~1" })).toEqual({
30+
kind: "show",
31+
ref: "HEAD~1",
32+
options: {},
33+
});
34+
expect(
35+
embeddedSourceToCliInput({
36+
kind: "vcs",
37+
range: "main",
38+
staged: false,
39+
pathspecs: ["src/app.ts"],
40+
options: { vcs: "jj" },
41+
}),
42+
).toEqual({
43+
kind: "vcs",
44+
range: "main",
45+
staged: false,
46+
pathspecs: ["src/app.ts"],
47+
options: { vcs: "jj" },
48+
});
49+
expect(
50+
embeddedSourceToCliInput({ kind: "diff", left: "before.ts", right: "after.ts" }),
51+
).toEqual({
52+
kind: "diff",
53+
left: "before.ts",
54+
right: "after.ts",
55+
options: {},
56+
});
57+
expect(embeddedSourceToCliInput({ kind: "stash-show", ref: "stash@{1}" })).toEqual({
58+
kind: "stash-show",
59+
ref: "stash@{1}",
60+
options: {},
61+
});
62+
expect(embeddedSourceToCliInput({ kind: "patch", file: "changes.patch" })).toEqual({
63+
kind: "patch",
64+
file: "changes.patch",
65+
options: {},
66+
});
67+
expect(
68+
embeddedSourceToCliInput({
69+
kind: "difftool",
70+
left: "left.ts",
71+
right: "right.ts",
72+
path: "src/app.ts",
73+
}),
74+
).toEqual({
75+
kind: "difftool",
76+
left: "left.ts",
77+
right: "right.ts",
78+
path: "src/app.ts",
79+
options: {},
80+
});
81+
});
82+
83+
test("loads embedded sessions through Hunk config resolution", async () => {
84+
const root = mkdtempSync(join(tmpdir(), "hunk-embedded-config-"));
85+
const previousXdgConfigHome = process.env.XDG_CONFIG_HOME;
86+
87+
try {
88+
const configHome = join(root, "config");
89+
mkdirSync(join(configHome, "hunk"), { recursive: true });
90+
writeFileSync(
91+
join(configHome, "hunk", "config.toml"),
92+
['theme = "midnight"', 'mode = "stack"', "line_numbers = false"].join("\n"),
93+
);
94+
process.env.XDG_CONFIG_HOME = configHome;
95+
96+
const session = await createEmbeddedHunkSession({
97+
cwd: root,
98+
source: { kind: "patch", text: patchText, options: { theme: "paper" } },
99+
});
100+
const snapshot = session.getSnapshot();
101+
102+
expect(snapshot.status).toBe("ready");
103+
if (snapshot.status !== "ready") throw new Error("Expected embedded session to load.");
104+
expect(snapshot.bootstrap.initialMode).toBe("stack");
105+
expect(snapshot.bootstrap.initialShowLineNumbers).toBe(false);
106+
expect(snapshot.bootstrap.initialTheme).toBe("paper");
107+
108+
session.dispose();
109+
} finally {
110+
if (previousXdgConfigHome === undefined) {
111+
delete process.env.XDG_CONFIG_HOME;
112+
} else {
113+
process.env.XDG_CONFIG_HOME = previousXdgConfigHome;
114+
}
115+
rmSync(root, { force: true, recursive: true });
116+
}
117+
});
118+
119+
test("keeps the previous source and reports errors when reload fails", async () => {
120+
const root = mkdtempSync(join(tmpdir(), "hunk-embedded-reload-error-"));
121+
122+
try {
123+
const initialSource = { kind: "patch", text: patchText, label: "initial patch" } as const;
124+
const session = await createEmbeddedHunkSession({
125+
cwd: root,
126+
source: initialSource,
127+
});
128+
129+
await expect(session.load({ kind: "patch", file: "missing.patch" })).rejects.toThrow();
130+
131+
expect(session.source).toEqual(initialSource);
132+
const snapshot = session.getSnapshot();
133+
expect(snapshot.status).toBe("error");
134+
expect(snapshot.bootstrap).toBeDefined();
135+
136+
session.dispose();
137+
} finally {
138+
rmSync(root, { force: true, recursive: true });
139+
}
140+
});
141+
});

src/embedded/index.d.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import type { CliRenderer, Renderable } from "@opentui/core";
2+
3+
export interface EmbeddedHunkOptions {
4+
mode?: "auto" | "split" | "stack";
5+
vcs?: "git" | "jj";
6+
theme?: string;
7+
watch?: boolean;
8+
excludeUntracked?: boolean;
9+
lineNumbers?: boolean;
10+
wrapLines?: boolean;
11+
hunkHeaders?: boolean;
12+
agentNotes?: boolean;
13+
}
14+
15+
export type EmbeddedHunkSource =
16+
| { kind: "worktree"; pathspecs?: string[]; options?: EmbeddedHunkOptions }
17+
| { kind: "staged"; pathspecs?: string[]; options?: EmbeddedHunkOptions }
18+
| {
19+
kind: "vcs";
20+
range?: string;
21+
staged: boolean;
22+
pathspecs?: string[];
23+
options?: EmbeddedHunkOptions;
24+
}
25+
| { kind: "show"; ref?: string; pathspecs?: string[]; options?: EmbeddedHunkOptions }
26+
| { kind: "stash-show"; ref?: string; options?: EmbeddedHunkOptions }
27+
| { kind: "diff"; left: string; right: string; options?: EmbeddedHunkOptions }
28+
| {
29+
kind: "patch";
30+
file?: string;
31+
text?: string;
32+
label?: string;
33+
options?: EmbeddedHunkOptions;
34+
}
35+
| {
36+
kind: "difftool";
37+
left: string;
38+
right: string;
39+
path?: string;
40+
options?: EmbeddedHunkOptions;
41+
};
42+
43+
export type EmbeddedHunkSnapshot =
44+
| { status: "loading"; bootstrap?: unknown; error?: undefined }
45+
| { status: "ready"; bootstrap: unknown; error?: undefined }
46+
| { status: "error"; bootstrap?: unknown; error: string };
47+
48+
export interface EmbeddedHunkSession {
49+
readonly cwd: string;
50+
readonly source: EmbeddedHunkSource;
51+
getSnapshot(): EmbeddedHunkSnapshot;
52+
load(source: EmbeddedHunkSource): Promise<void>;
53+
subscribe(listener: () => void): () => void;
54+
dispose(): void;
55+
}
56+
57+
export interface EmbeddedHunkMount {
58+
update(options: { active: boolean; onQuit: () => void }): void;
59+
unmount(): void;
60+
}
61+
62+
export declare function embeddedSourceToCliInput(source: EmbeddedHunkSource): unknown;
63+
export declare function createEmbeddedHunkSession(input: {
64+
cwd?: string;
65+
source: EmbeddedHunkSource;
66+
}): Promise<EmbeddedHunkSession>;
67+
export declare function mountEmbeddedHunkApp(input: {
68+
active: boolean;
69+
container: Renderable;
70+
onQuit: () => void;
71+
renderer: CliRenderer;
72+
session: EmbeddedHunkSession;
73+
}): EmbeddedHunkMount;

0 commit comments

Comments
 (0)