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/flat-rivers-diff.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"hunkdiff": minor
---

Add a `hunkdiff/static` API for rendering unified patches as ANSI terminal output without starting an interactive review.
1 change: 1 addition & 0 deletions .dependency-cruiser.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ const PRODUCTION_ENTRY_POINTS = [
"^src/main\\.tsx$",
"^src/highlightWorkerEntry\\.ts$",
"^src/opentui/index\\.ts$",
"^src/static/index\\.ts$",
"^src/extension-api/index\\.ts$",
"^src/hunk-review/skillDocument\\.ts$",
];
Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,12 @@ Hunk also publishes `HunkDiffView` and lower-level primitives from `hunkdiff/ope

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

### Static renderer

`hunkdiff/static` renders an existing unified patch as colored ANSI text without starting Hunk's interactive application. It is useful for terminal hosts that already have patch text and need stack or split presentation.

See [docs/static-renderer.md](docs/static-renderer.md) for the API and options.

## Examples

Ready-to-run demo diffs live in [`examples/`](examples/README.md).
Expand Down
46 changes: 46 additions & 0 deletions docs/static-renderer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# Static renderer

`hunkdiff/static` turns a unified patch into Hunk's non-interactive ANSI output. Use it when your application already has patch text and needs a terminal-rendered diff without creating an OpenTUI application.

## Install

```bash
npm i hunkdiff
```

## Usage

```ts
import { renderStaticDiff } from "hunkdiff/static";

const patch = [
"diff --git a/greeting.ts b/greeting.ts",
"--- a/greeting.ts",
"+++ b/greeting.ts",
"@@ -1 +1 @@",
"-export const greeting = 'hello';",
"+export const greeting = 'hello, world';",
"",
].join("\n");

const output = await renderStaticDiff(patch, {
layout: "stack",
width: process.stdout.columns,
});

process.stdout.write(output);
```

The renderer sanitizes patch text before writing terminal output. It returns ANSI text and does not create an alternate screen, read input, or start Hunk's interactive review UI.

## Options

| Option | Description |
| ----------------------- | ----------------------------------------------------------------------------- |
| `layout` | `"stack"` (default) or `"split"` rendering. |
| `theme` | Built-in Hunk theme id. Unknown ids use the default theme. |
| `lineNumbers` | Show old and new line-number gutters. Defaults to `true`. |
| `hunkHeaders` | Show `@@` hunk headers. Defaults to `true`. |
| `tabWidth` | Source-code tab stop width from 1 through 16. Defaults to `4`. |
| `transparentBackground` | Leave neutral surfaces transparent while preserving changed-line backgrounds. |
| `width` | Available terminal columns. Defaults to stdout columns or 120. |
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@
"types": "./dist/npm/opentui/index.d.ts",
"import": "./dist/npm/opentui/index.js"
},
"./static": {
"types": "./dist/npm/static/index.d.ts",
"import": "./dist/npm/static/index.js"
},
"./package.json": "./package.json"
},
"publishConfig": {
Expand Down
27 changes: 27 additions & 0 deletions scripts/build-npm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ const outdir = path.join(repoRoot, "dist", "npm");
const typesOutdir = path.join(repoRoot, "dist", "npm-types");
const opentuiOutdir = path.join(outdir, "opentui");
const opentuiTypesDir = path.join(typesOutdir, "opentui");
const staticOutdir = path.join(outdir, "static");
const staticTypesDir = path.join(typesOutdir, "static");
const extensionOutdir = path.join(outdir, "extension");
const extensionTypesOutdir = path.join(repoRoot, "dist", "npm-extension-types");

Expand Down Expand Up @@ -43,6 +45,7 @@ rmSync(outdir, { recursive: true, force: true });
rmSync(typesOutdir, { recursive: true, force: true });
rmSync(extensionTypesOutdir, { recursive: true, force: true });
mkdirSync(opentuiOutdir, { recursive: true });
mkdirSync(staticOutdir, { recursive: true });
mkdirSync(extensionOutdir, { recursive: true });

const opentuiNativePackages = [
Expand Down Expand Up @@ -113,6 +116,29 @@ for (const entry of readdirSync(opentuiTypesDir)) {
}
}

runBun([
"build",
path.join(repoRoot, "src", "static", "index.ts"),
"--target",
"node",
"--format",
"esm",
"--splitting",
"--external",
"@pierre/diffs",
"--outdir",
staticOutdir,
"--entry-naming",
"index.js",
]);

runBun(["x", "tsc", "-p", path.join(repoRoot, "tsconfig.static.json")]);
for (const entry of readdirSync(staticTypesDir)) {
if (entry.endsWith(".d.ts")) {
copyFileSync(path.join(staticTypesDir, entry), path.join(staticOutdir, entry));
}
}

rmSync(typesOutdir, { recursive: true, force: true });

runBun([
Expand Down Expand Up @@ -146,4 +172,5 @@ rmSync(extensionTypesOutdir, { recursive: true, force: true });

console.log(`Built ${mainJs}`);
console.log(`Built ${path.join(opentuiOutdir, "index.js")}`);
console.log(`Built ${path.join(staticOutdir, "index.js")}`);
console.log(`Built ${path.join(extensionOutdir, "index.js")}`);
35 changes: 35 additions & 0 deletions scripts/check-pack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { readFileSync } from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { checkExtensionConsumerTypes } from "./extension-consumer-check";
import { buildDocExamples } from "./extension-doc-examples";
import { npmCommand } from "./script-helpers";
Expand Down Expand Up @@ -401,6 +402,9 @@ const requiredPaths = [
"dist/npm/extension/index.js",
"dist/npm/opentui/index.d.ts",
"dist/npm/opentui/index.js",
"dist/npm/static/index.d.ts",
"dist/npm/static/index.js",
"dist/npm/static/types.d.ts",
"README.md",
"LICENSE",
"package.json",
Expand All @@ -416,6 +420,37 @@ for (const path of requiredPaths) {
}
}

const staticEntry = path.join(repoRoot, "dist", "npm", "static", "index.js");
const staticSmoke = Bun.spawnSync(
[
"node",
"--input-type=module",
"--eval",
`
const { renderStaticDiff } = await import(${JSON.stringify(pathToFileURL(staticEntry).href)});
const output = await renderStaticDiff(
"diff --git a/a.ts b/a.ts\\n--- a/a.ts\\n+++ b/a.ts\\n@@ -1 +1 @@\\n-const value = 1;\\n+const value = 2;\\n",
{ width: 80 },
);
const plain = output.replace(/\\x1b\\[[0-?]*[ -/]*[@-~]/g, "");
if (!plain.includes("a.ts modified +1 -1")) {
throw new Error("The published static renderer did not render a patch.");
}
`,
],
{
cwd: repoRoot,
stdin: "ignore",
stdout: "pipe",
stderr: "pipe",
env: process.env,
},
);
if (staticSmoke.exitCode !== 0) {
const output = Buffer.from(staticSmoke.stderr).toString("utf8").trim();
throw new Error(`The published static renderer failed under Node.\n${output}`);
}

const forbiddenPrefixes = [
".github/",
"src/",
Expand Down
2 changes: 1 addition & 1 deletion src/core/changeset/loaders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -256,7 +256,7 @@ async function loadVcsChangeset(
}

/** Build a changeset from patch text supplied by file or stdin. */
async function loadPatchChangeset(
export async function loadPatchChangeset(
input: PatchCommandInput,
sidecar: SidecarContext | null,
cwd = process.cwd(),
Expand Down
22 changes: 17 additions & 5 deletions src/opentui/model.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { parsePatchFiles } from "@pierre/diffs";
import { patchLooksBinary } from "../core/changeset/binary";
import { normalizeDiffMetadataPaths, normalizeDiffPath } from "../core/changeset/diffPaths";
import { countDiffStats } from "../core/changeset/diffFile";
import { buildDiffFile, countDiffStats } from "../core/changeset/diffFile";
import { splitPatchIntoFileChunks, findPatchChunk } from "../core/patch/chunks";
import { sanitizePatch } from "../core/patch/sanitize";
import type { DiffFile } from "../core/changeset/model";
Expand Down Expand Up @@ -82,13 +82,25 @@ export function createHunkDiffFilesFromPatch(patchText: string, sourceId = "patc
? { ...metadata, name: decodedPaths.path, prevName: decodedPaths.previousPath }
: metadata;

const file = buildDiffFile(
normalizedMetadata,
findPatchChunk(metadata, chunks, index),
index,
sourceId,
null,
{ pathsAreExact: Boolean(decodedPaths) },
);
return buildHunkDiffFile(
{
id: `${sourceId}:${index}:${normalizedMetadata.name}`,
metadata: normalizedMetadata,
patch: findPatchChunk(metadata, chunks, index),
id: file.id,
language: file.language,
metadata: file.metadata,
patch: file.patch,
path: file.path,
previousPath: file.previousPath,
stats: file.stats,
},
Boolean(decodedPaths),
true,
);
});
}
Expand Down
44 changes: 44 additions & 0 deletions src/static/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import type { StaticDiffOptions } from "./types.js";

export type { StaticDiffOptions } from "./types.js";

type StaticRenderer = typeof import("../ui/staticDiffPager");

let rendererPromise: Promise<StaticRenderer> | undefined;

/** Load Pierre-backed rendering after providing the browser metadata its root entry expects. */
function loadRenderer() {
rendererPromise ??= (async () => {
const runtime = globalThis as typeof globalThis & {
navigator?: Pick<Navigator, "maxTouchPoints" | "platform" | "userAgent">;
};
const navigatorDescriptor = Object.getOwnPropertyDescriptor(runtime, "navigator");
if (runtime.navigator === undefined) {
Object.defineProperty(runtime, "navigator", {
configurable: true,
value: {
maxTouchPoints: 0,
platform: "",
userAgent: "",
},
});
}

try {
return await import("../ui/staticDiffPager");
} finally {
if (navigatorDescriptor) {
Object.defineProperty(runtime, "navigator", navigatorDescriptor);
} else {
Reflect.deleteProperty(runtime, "navigator");
}
}
})();
return rendererPromise;
}

/** Render a unified patch as ANSI text without starting Hunk's interactive application. */
export async function renderStaticDiff(text: string, options: StaticDiffOptions = {}) {
const { renderStaticDiff: render } = await loadRenderer();
return render(text, options);
}
17 changes: 17 additions & 0 deletions src/static/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
/** Options for rendering a unified patch as a non-interactive terminal diff. */
export interface StaticDiffOptions {
/** Stack changed lines vertically or place deletion/addition lines side by side. Defaults to stack. */
layout?: "stack" | "split";
/** Built-in Hunk theme id. Unknown ids fall back to the default theme. */
theme?: string;
/** Show old and new line-number gutters. Defaults to true. */
lineNumbers?: boolean;
/** Show unified hunk headers. Defaults to true. */
hunkHeaders?: boolean;
/** Source-code tab stop width from 1 through 16. Defaults to 4. */
tabWidth?: number;
/** Keep neutral surfaces transparent while preserving changed-line backgrounds. */
transparentBackground?: boolean;
/** Available terminal columns. Defaults to stdout columns or 120 when unavailable. */
width?: number;
}
Loading