fix(tui): preserve diff hunk boundaries
This commit is contained in:
@@ -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<JSX.IntrinsicElements["diff"], "diff"> & {
|
||||
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 (
|
||||
<For each={hunks()}>
|
||||
{(hunk, index) => (
|
||||
<>
|
||||
<Show when={index() > 0}>
|
||||
<text width="100%" height={1} fg={local.hunkFg} bg={local.hunkBg}>
|
||||
{hunk.header ?? ""}
|
||||
</text>
|
||||
</Show>
|
||||
<diff {...diffProps} diff={hunk.patch} minHeight={hunk.rows} />
|
||||
</>
|
||||
)}
|
||||
</For>
|
||||
)
|
||||
}
|
||||
@@ -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: {
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<diff
|
||||
<PatchDiff
|
||||
diff={info().diff!}
|
||||
hunkBg={props.block.diffContextBg}
|
||||
hunkFg={props.block.diffLineNumber}
|
||||
view="unified"
|
||||
filetype={ft()}
|
||||
syntaxStyle={props.block.syntax}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { entryColor, entryLook, entrySyntax } from "./scrollback.shared"
|
||||
import { toolFiletype, toolStructuredFinal } from "./tool"
|
||||
import { RUN_THEME_FALLBACK, transparent, type RunTheme } from "./theme"
|
||||
import type { EntryLayout, RunEntryBody, ScrollbackOptions, StreamCommit, TurnSummary } from "./types"
|
||||
import { PatchDiff } from "../component/patch-diff"
|
||||
|
||||
export function entryGroupKey(commit: StreamCommit): string | undefined {
|
||||
if (!commit.partID) {
|
||||
@@ -178,8 +179,10 @@ export function RunEntryContent(props: {
|
||||
</text>
|
||||
{item.diff.trim() ? (
|
||||
<box width="100%" paddingLeft={1}>
|
||||
<diff
|
||||
<PatchDiff
|
||||
diff={item.diff}
|
||||
hunkBg={diffBg(theme().block.diffContextBg)}
|
||||
hunkFg={theme().block.diffLineNumber}
|
||||
view="unified"
|
||||
filetype={toolFiletype(item.file)}
|
||||
syntaxStyle={syntax()}
|
||||
|
||||
@@ -22,6 +22,7 @@ import { useData } from "../../context/data"
|
||||
import { SplitBorder } from "../../ui/border"
|
||||
import { useTuiPaths, useTuiTerminalEnvironment } from "../../context/runtime"
|
||||
import { Spinner, SPINNER_FRAMES } from "../../component/spinner"
|
||||
import { PatchDiff } from "../../component/patch-diff"
|
||||
import { ThemeContextProvider, useTheme, useThemes } from "../../context/theme"
|
||||
import { BoxRenderable, ScrollBoxRenderable, addDefaultParsers, TextAttributes, RGBA } from "@opentui/core"
|
||||
import { Prompt, type PromptRef } from "../../component/prompt"
|
||||
@@ -3027,8 +3028,10 @@ function Edit(props: ToolProps) {
|
||||
{(item) => (
|
||||
<BlockTool path={{ label: "← Edit", value: pathFormatter.format(path()) }} part={props.part}>
|
||||
<box paddingLeft={1}>
|
||||
<diff
|
||||
<PatchDiff
|
||||
diff={item().patch}
|
||||
hunkBg={theme.diff.background.context}
|
||||
hunkFg={theme.diff.lineNumber.text}
|
||||
view={view()}
|
||||
filetype={filetype(path())}
|
||||
syntaxStyle={syntax()}
|
||||
@@ -3116,8 +3119,10 @@ function ApplyPatch(props: ToolProps) {
|
||||
}
|
||||
>
|
||||
<box paddingLeft={1}>
|
||||
<diff
|
||||
<PatchDiff
|
||||
diff={file.patch}
|
||||
hunkBg={theme.diff.background.context}
|
||||
hunkFg={theme.diff.lineNumber.text}
|
||||
view={view()}
|
||||
filetype={filetype(file.relativePath)}
|
||||
syntaxStyle={syntax()}
|
||||
|
||||
@@ -14,6 +14,7 @@ import { useConfig } from "../../config"
|
||||
import { Keymap } from "../../context/keymap"
|
||||
import { usePathFormatter } from "../../context/path-format"
|
||||
import { SimulationSemantics } from "../../simulation/semantics"
|
||||
import { PatchDiff } from "../../component/patch-diff"
|
||||
|
||||
type PermissionStage = "permission" | "always" | "reject"
|
||||
|
||||
@@ -50,8 +51,10 @@ function EditBody(props: { file?: string; diff?: string; patch?: string }) {
|
||||
},
|
||||
}}
|
||||
>
|
||||
<diff
|
||||
<PatchDiff
|
||||
diff={diff()}
|
||||
hunkBg={theme.diff.background.context}
|
||||
hunkFg={theme.diff.lineNumber.text}
|
||||
view={view()}
|
||||
filetype={ft()}
|
||||
syntaxStyle={syntax()}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
export interface PatchHunk {
|
||||
readonly patch: string
|
||||
readonly header?: string
|
||||
readonly rows?: number
|
||||
}
|
||||
|
||||
export function splitPatchHunks(patch: string): PatchHunk[] {
|
||||
const starts = [
|
||||
...patch.matchAll(/^@@ -\d+(?:,\d+)? \+\d+(?:,\d+)? @@.*$/gm),
|
||||
].map((match) => 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
|
||||
}
|
||||
@@ -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<ReturnType<typeof testRender>> | 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(
|
||||
() => (
|
||||
<box width={120}>
|
||||
<PatchDiff
|
||||
diff={patch}
|
||||
hunkBg="#222222"
|
||||
hunkFg="#888888"
|
||||
view="split"
|
||||
filetype="typescript"
|
||||
syntaxStyle={SyntaxStyle.create()}
|
||||
showLineNumbers={true}
|
||||
width="100%"
|
||||
/>
|
||||
</box>
|
||||
),
|
||||
{ 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)),
|
||||
]
|
||||
}
|
||||
@@ -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" }])
|
||||
})
|
||||
Reference in New Issue
Block a user