Skip to content

Commit b630cc9

Browse files
authored
feat(opentui): expose embeddable primitives (#272)
1 parent fd409df commit b630cc9

16 files changed

Lines changed: 1023 additions & 196 deletions

CHANGELOG.md

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

99
- Added Homebrew tap release automation and Homebrew-aware startup update notices.
10+
- Added lower-level `hunkdiff/opentui` primitives for embedding Hunk diff bodies, file headers, file navigation, and multi-file review streams in custom OpenTUI apps.
1011

1112
### Changed
1213

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,7 @@ diff-formatter = ":git"
165165

166166
### OpenTUI component
167167

168-
Hunk also publishes `HunkDiffView` from `hunkdiff/opentui` for embedding the same diff renderer in your own OpenTUI app.
168+
Hunk also publishes `HunkDiffView` and lower-level primitives from `hunkdiff/opentui` for embedding the same diff renderer in your own OpenTUI app.
169169

170170
See [docs/opentui-component.md](docs/opentui-component.md) for install, API, and runnable examples.
171171

docs/opentui-component.md

Lines changed: 132 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
# OpenTUI component
22

3-
`hunkdiff/opentui` exports `HunkDiffView`, a reusable terminal diff component built from the same renderer as the Hunk CLI.
3+
`hunkdiff/opentui` exports reusable terminal diff components built from the same renderer as the Hunk CLI.
44

5-
Use it when you want Hunk's split or stack diff view inside your own OpenTUI app.
5+
Use `HunkDiffView` when you want a batteries-included single-file diff, or compose the lower-level primitives when you want to build your own Hunk-like review UI without Hunk's sidebar, menus, global keyboard shortcuts, or session behavior.
66

77
## Install
88

@@ -17,7 +17,7 @@ npm i hunkdiff @opentui/core @opentui/react react
1717
```tsx
1818
import { createCliRenderer } from "@opentui/core";
1919
import { createRoot } from "@opentui/react";
20-
import { HunkDiffView, parseDiffFromFile } from "hunkdiff/opentui";
20+
import { HunkDiffView, createHunkDiffFile, parseDiffFromFile } from "hunkdiff/opentui";
2121

2222
const metadata = parseDiffFromFile(
2323
{
@@ -43,12 +43,12 @@ const root = createRoot(renderer);
4343

4444
root.render(
4545
<HunkDiffView
46-
diff={{
46+
diff={createHunkDiffFile({
4747
id: "example",
4848
metadata,
4949
language: "typescript",
5050
path: "example.ts",
51-
}}
51+
})}
5252
layout="split"
5353
width={88}
5454
theme="midnight"
@@ -58,73 +58,170 @@ root.render(
5858

5959
In a real app, derive `width` from your layout or `useTerminalDimensions()`.
6060

61-
## Building the `diff` input
61+
## Convenience vs primitives
62+
63+
### `HunkDiffView`
64+
65+
`HunkDiffView` renders one file and can own an OpenTUI `scrollbox`:
66+
67+
```tsx
68+
<HunkDiffView diff={file} width={88} layout="split" scrollable />
69+
```
70+
71+
Use it when you just want a drop-in diff viewer.
72+
73+
### `HunkDiffBody`
74+
75+
`HunkDiffBody` renders only the diff body for one file. It does not create a scrollbox, file nav, keyboard shortcuts, menus, or session bridge behavior:
76+
77+
```tsx
78+
<scrollbox width="100%" height="100%" scrollY>
79+
<HunkDiffBody file={file} width={88} layout="stack" selectedHunkIndex={2} />
80+
</scrollbox>
81+
```
82+
83+
Use it when your app owns scrolling or surrounding layout.
84+
85+
### `HunkDiffFileHeader`
86+
87+
`HunkDiffFileHeader` renders Hunk's compact file label/stats header:
88+
89+
```tsx
90+
<HunkDiffFileHeader file={file} width={88} onSelect={() => selectFile(file.id)} />
91+
```
92+
93+
### `HunkReviewStream`
94+
95+
`HunkReviewStream` renders a top-to-bottom multi-file review stream without Hunk's app shell, chrome, keybindings, or scroll owner:
96+
97+
```tsx
98+
<scrollbox width="100%" height="100%" scrollY>
99+
<HunkReviewStream
100+
files={files}
101+
width={terminal.width}
102+
layout="split"
103+
selection={{ fileId, hunkIndex }}
104+
onSelectionChange={({ fileId, hunkIndex }) => {
105+
setFileId(fileId);
106+
setHunkIndex(hunkIndex);
107+
}}
108+
/>
109+
</scrollbox>
110+
```
111+
112+
Use it when you want Hunk's main review stream but your own navigation, chrome, scrolling, and keybindings.
113+
114+
### `HunkFileNav`
62115

63-
`HunkDiffView` renders one file at a time. Pass a `diff` object shaped like this:
116+
`HunkFileNav` renders Hunk's file navigation list as an optional primitive. It does not render borders, outer padding, or a scrollbox; host apps own surrounding chrome and scrolling.
117+
118+
```tsx
119+
<scrollbox width={32} height="100%" scrollY>
120+
<HunkFileNav
121+
files={files}
122+
selectedFileId={fileId}
123+
width={32}
124+
onSelectFile={(nextFileId) => setFileId(nextFileId)}
125+
/>
126+
</scrollbox>
127+
```
128+
129+
## Building file inputs
130+
131+
The public file model is intentionally higher-level than Hunk's internal renderer rows. Row models are not exported.
64132

65133
```ts
66-
type HunkDiffFile = {
134+
type HunkDiffFileInput = {
67135
id: string;
68136
metadata: FileDiffMetadata;
69137
language?: string;
70138
path?: string;
139+
previousPath?: string;
71140
patch?: string;
141+
stats?: { additions: number; deletions: number };
142+
isBinary?: boolean;
143+
isTooLarge?: boolean;
144+
isUntracked?: boolean;
145+
statsTruncated?: boolean;
146+
};
147+
148+
type HunkDiffFile = Omit<HunkDiffFileInput, "stats"> & {
149+
stats: { additions: number; deletions: number };
72150
};
73151
```
74152

153+
Components accept `HunkDiffFileInput` directly. Use `createHunkDiffFile(...)` when you want a normalized `HunkDiffFile` with paths and stats filled in once:
154+
155+
```tsx
156+
import { createHunkDiffFile, parseDiffFromFile } from "hunkdiff/opentui";
157+
158+
const file = createHunkDiffFile({
159+
id: "example",
160+
metadata: parseDiffFromFile(beforeFile, afterFile, { context: 3 }, true),
161+
path: "example.ts",
162+
language: "typescript",
163+
});
164+
```
165+
75166
### From before/after contents
76167

77168
Use `parseDiffFromFile(...)` when you already have the old and new file contents.
78169

79170
```tsx
80-
import { parseDiffFromFile } from "hunkdiff/opentui";
171+
import { createHunkDiffFile, parseDiffFromFile } from "hunkdiff/opentui";
81172

82-
const metadata = parseDiffFromFile(beforeFile, afterFile, { context: 3 }, true);
173+
const file = createHunkDiffFile({
174+
id: "example",
175+
metadata: parseDiffFromFile(beforeFile, afterFile, { context: 3 }, true),
176+
});
83177
```
84178

85179
### From unified diff text
86180

87-
Use `parsePatchFiles(...)` when you already have a patch string.
181+
Use `createHunkDiffFilesFromPatch(...)` for a quick multi-file patch path:
88182

89183
```tsx
90-
import { parsePatchFiles } from "hunkdiff/opentui";
91-
92-
const parsed = parsePatchFiles(patchText, "example:patch", true);
93-
const metadata = parsed.flatMap((entry) => entry.files)[0];
184+
import { createHunkDiffFilesFromPatch } from "hunkdiff/opentui";
94185

95-
if (!metadata) {
96-
throw new Error("Expected at least one diff file.");
97-
}
186+
const files = createHunkDiffFilesFromPatch(patchText, "example:patch");
98187
```
99188

100-
## Props
101-
102-
| Prop | Type | Default | Notes |
103-
| ------------------- | ------------------------------------------------ | ------------ | ------------------------------------------------------------------------- |
104-
| `diff` | `HunkDiffFile` | `undefined` | File to render. When omitted, the component shows an empty-state message. |
105-
| `layout` | `"split" \| "stack"` | `"split"` | Chooses side-by-side or stacked rendering. |
106-
| `width` | `number` || Required content width in terminal columns. |
107-
| `theme` | `"graphite" \| "midnight" \| "paper" \| "ember"` | `"graphite"` | Matches Hunk's built-in themes. |
108-
| `showLineNumbers` | `boolean` | `true` | Toggles line-number columns. |
109-
| `showHunkHeaders` | `boolean` | `true` | Toggles `@@ ... @@` hunk header rows. |
110-
| `wrapLines` | `boolean` | `false` | Wraps long lines instead of clipping horizontally. |
111-
| `horizontalOffset` | `number` | `0` | Scroll offset for non-wrapped code rows. |
112-
| `highlight` | `boolean` | `true` | Enables syntax highlighting. |
113-
| `scrollable` | `boolean` | `true` | Set to `false` if your parent view owns scrolling. |
114-
| `selectedHunkIndex` | `number` | `0` | Highlights one hunk as the active target. |
189+
If you need direct access to Pierre's parser, `parsePatchFiles(...)` is still re-exported.
190+
191+
## Common props
192+
193+
| Prop | Type | Default | Notes |
194+
| -------------------- | ------------------------------------------------ | ------------ | ----------------------------------------------------------------------------------- |
195+
| `layout` | `"split" \| "stack"` | `"split"` | Chooses side-by-side or stacked rendering. |
196+
| `width` | `number` || Required content width in terminal columns. |
197+
| `theme` | `"graphite" \| "midnight" \| "paper" \| "ember"` | `"graphite"` | Matches Hunk's built-in themes. |
198+
| `showLineNumbers` | `boolean` | `true` | Toggles line-number columns. |
199+
| `showHunkHeaders` | `boolean` | `true` | Toggles `@@ ... @@` hunk header rows. |
200+
| `showFileSeparators` | `boolean` | `true` | Toggles separator rows between files in `HunkReviewStream`. |
201+
| `wrapLines` | `boolean` | `false` | Wraps long lines instead of clipping horizontally. |
202+
| `horizontalOffset` | `number` | `0` | Scroll offset for non-wrapped code rows. |
203+
| `highlight` | `boolean` | `true` | Enables syntax highlighting. |
204+
| `selectedHunkIndex` | `number` | `0` | Highlights one hunk as the active target for single-file components. |
205+
| `scrollable` | `boolean` | `true` | `HunkDiffView` only; primitives should be wrapped in OpenTUI scrollbox when needed. |
115206

116207
## Other exports
117208

118209
- `parseDiffFromFile`
119210
- `parsePatchFiles`
120211
- `FileDiffMetadata`
212+
- `createHunkDiffFile`
213+
- `createHunkDiffFilesFromPatch`
214+
- `countHunkDiffStats`
121215
- `HUNK_DIFF_THEME_NAMES`
122216
- `HunkDiffThemeName`
123217
- `HunkDiffLayout`
124218
- `HunkDiffFile`
125-
- `HunkDiffViewProps`
219+
- `HunkDiffFileInput`
220+
- `HunkDiffStats`
221+
- `HunkDiffSelection`
222+
- component prop types
126223

127-
`parseDiffFromFile`, `parsePatchFiles`, and `FileDiffMetadata` are re-exported from `@pierre/diffs` so you can build `metadata` without adding a second diff dependency.
224+
`parseDiffFromFile`, `parsePatchFiles`, and `FileDiffMetadata` are re-exported from `@pierre/diffs` so you can build metadata without adding a second diff dependency.
128225

129226
## Examples
130227

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# 8-opentui-primitives
2+
3+
A small custom OpenTUI app assembled from Hunk's lower-level primitives instead of the full Hunk CLI app shell.
4+
5+
For package install and API details, see [OpenTUI component docs](../../docs/opentui-component.md).
6+
7+
## Run
8+
9+
```bash
10+
bun run examples/8-opentui-primitives/primitives-demo.tsx
11+
```
12+
13+
## What it shows
14+
15+
- `createHunkDiffFilesFromPatch` for turning unified diff text into public Hunk file models
16+
- `HunkFileNav` for a standalone file list
17+
- `HunkReviewStream` for a multi-file review stream without Hunk's menu bar or global shortcuts
18+
- `HunkDiffFileHeader` and `HunkDiffBody` for a single-file view assembled by the host app
19+
- Host-owned window borders/chrome around each primitive so you can inspect component boundaries
20+
- Host-owned state for selected file and split/stack layout
21+
22+
The in-repo demo imports from `../../src/opentui` so it runs from source. Published consumers should import from `hunkdiff/opentui` instead.

0 commit comments

Comments
 (0)