diff --git a/packages/tui/src/component/patch-diff.tsx b/packages/tui/src/component/patch-diff.tsx new file mode 100644 index 0000000000..a56d118368 --- /dev/null +++ b/packages/tui/src/component/patch-diff.tsx @@ -0,0 +1,31 @@ +/** @jsxImportSource @opentui/solid */ +import type { ColorInput } from "@opentui/core" +import type { JSX } from "@opentui/solid" +import { createMemo, For, Show, splitProps } from "solid-js" +import { splitPatchHunks } from "../util/diff" + +type Props = Omit & { + diff: string + hunkBg: ColorInput + hunkFg: ColorInput +} + +export function PatchDiff(props: Props) { + const [local, diffProps] = splitProps(props, ["diff", "hunkBg", "hunkFg"]) + const hunks = createMemo(() => splitPatchHunks(local.diff)) + + return ( + + {(hunk, index) => ( + <> + 0}> + + {hunk.header ?? ""} + + + + + )} + + ) +} diff --git a/packages/tui/src/mini/footer.permission.tsx b/packages/tui/src/mini/footer.permission.tsx index d120b83268..3e1c6c002f 100644 --- a/packages/tui/src/mini/footer.permission.tsx +++ b/packages/tui/src/mini/footer.permission.tsx @@ -32,6 +32,7 @@ import { footerWidthPolicy } from "./footer.width" import { toolFiletype } from "./tool" import { transparent, type RunBlockTheme, type RunFooterTheme } from "./theme" import type { MiniPermissionRequest, PermissionReply } from "./types" +import { PatchDiff } from "../component/patch-diff" function buttons( list: PermissionOption[], @@ -405,8 +406,10 @@ export function RunPermissionBody(props: { } > - {item.diff.trim() ? ( - ( - - - match.index) + if (starts.length <= 1) return [{ patch }] + + const prefix = patch.slice(0, starts[0]) + return starts.map((start, index) => { + const end = starts[index + 1] ?? patch.length + const lineEnd = patch.indexOf("\n", start) + return { + header: patch.slice(start, lineEnd === -1 ? end : lineEnd), + patch: prefix + patch.slice(start, end), + rows: splitRows(patch.slice(start, end)), + } + }) +} + +function splitRows(hunk: string) { + const lines = hunk.replace(/\n$/, "").split("\n").slice(1) + let rows = 0 + let index = 0 + + while (index < lines.length) { + const prefix = lines[index][0] + if (prefix === " " || !prefix) { + rows++ + index++ + continue + } + if (prefix === "\\") { + index++ + continue + } + + let additions = 0 + let deletions = 0 + while ( + index < lines.length && + (lines[index][0] === "+" || lines[index][0] === "-") + ) { + if (lines[index][0] === "+") additions++ + if (lines[index][0] === "-") deletions++ + index++ + } + rows += Math.max(additions, deletions) + } + + return rows +} diff --git a/packages/tui/test/component/patch-diff.test.tsx b/packages/tui/test/component/patch-diff.test.tsx new file mode 100644 index 0000000000..c4332592da --- /dev/null +++ b/packages/tui/test/component/patch-diff.test.tsx @@ -0,0 +1,61 @@ +/** @jsxImportSource @opentui/solid */ +import { afterEach, expect, test } from "bun:test" +import { DiffRenderable, type Renderable, SyntaxStyle } from "@opentui/core" +import { testRender } from "@opentui/solid" +import { PatchDiff } from "../../src/component/patch-diff" + +let app: Awaited> | undefined + +afterEach(() => { + app?.renderer.destroy() + app = undefined +}) + +test("renders separate diff nodes with a full-width hunk row", async () => { + const patch = `--- a/file.ts ++++ b/file.ts +@@ -1,2 +1,3 @@ + const first = true ++const addedFirst = true + const afterFirst = true +@@ -20,3 +20,3 @@ + const second = true +-const oldSecond = true ++const newSecond = true + const afterSecond = true` + + app = await testRender( + () => ( + + + + ), + { width: 120, height: 30 }, + ) + const frame = await app.waitForFrame((value) => + value.includes("@@ -20,3 +20,3 @@"), + ) + const header = frame + .split("\n") + .find((line) => line.includes("@@ -20,3 +20,3 @@")) + + expect(header?.startsWith("@@ -20,3 +20,3 @@")).toBe(true) + expect(header?.trimEnd()).toBe("@@ -20,3 +20,3 @@") + expect(findDiffs(app.renderer.root)).toHaveLength(2) +}) + +function findDiffs(root: Renderable): DiffRenderable[] { + return [ + ...(root instanceof DiffRenderable ? [root] : []), + ...root.getChildren().flatMap((child) => findDiffs(child)), + ] +} diff --git a/packages/tui/test/util/diff.test.ts b/packages/tui/test/util/diff.test.ts new file mode 100644 index 0000000000..98553d97f1 --- /dev/null +++ b/packages/tui/test/util/diff.test.ts @@ -0,0 +1,40 @@ +import { expect, test } from "bun:test" +import { splitPatchHunks } from "../../src/util/diff" + +test("splits a per-file patch into independently renderable hunks", () => { + const patch = `--- a/file.ts ++++ b/file.ts +@@ -1,3 +1,3 @@ + const first = true +-const oldFirst = true ++const newFirst = true + const afterFirst = true +@@ -20,3 +20,3 @@ + const second = true +-const oldSecond = true ++const newSecond = true + const afterSecond = true` + + const hunks = splitPatchHunks(patch) + + expect(hunks).toHaveLength(2) + expect(hunks[0].header).toBe("@@ -1,3 +1,3 @@") + expect(hunks[1].header).toBe("@@ -20,3 +20,3 @@") + expect(hunks[0].rows).toBe(3) + expect(hunks[1].rows).toBe(3) + expect(hunks[0].patch).toContain("--- a/file.ts\n+++ b/file.ts") + expect(hunks[1].patch).toContain("--- a/file.ts\n+++ b/file.ts") + expect(hunks[0].patch).not.toContain("const second") + expect(hunks[1].patch).not.toContain("const first") +}) + +test("keeps patches with one or no hunks intact", () => { + const patch = `--- a/file.ts ++++ b/file.ts +@@ -1 +1 @@ +-old ++new` + + expect(splitPatchHunks(patch)).toEqual([{ patch }]) + expect(splitPatchHunks("not a patch")).toEqual([{ patch: "not a patch" }]) +})