Compare commits

...

6 Commits

Author SHA1 Message Date
James Long 193b9d5b8a test(tui): defer diff hunk coverage 2026-07-30 16:52:59 +00:00
James Long cfd8fa4961 fix(tui): align hunk header inset 2026-07-30 16:42:44 +00:00
James Long 71a86995c6 fix(tui): preserve line number alignment 2026-07-30 16:38:17 +00:00
James Long 0d4ab8e6be fix(tui): synchronize diff gutters 2026-07-30 16:28:45 +00:00
James Long ffa9a597dc fix(tui): fill hunk header background 2026-07-30 16:09:44 +00:00
James Long 71b2fa2920 fix(tui): preserve diff hunk boundaries 2026-07-30 16:02:45 +00:00
6 changed files with 143 additions and 5 deletions
+73
View File
@@ -0,0 +1,73 @@
/** @jsxImportSource @opentui/solid */
import { DiffRenderable, LineNumberRenderable, type ColorInput } from "@opentui/core"
import type { JSX } from "@opentui/solid"
import { createMemo, For, Show, splitProps } from "solid-js"
import { splitPatchHunks } from "../util/diff"
import { stringWidth } from "../util/string-width"
type Props = Omit<JSX.IntrinsicElements["diff"], "diff" | "lineNumberBg"> & {
diff: string
hunkFg: ColorInput
lineNumberBg: ColorInput
}
export function PatchDiff(props: Props) {
const [local, diffProps] = splitProps(props, ["diff", "hunkFg", "lineNumberBg"])
const hunks = createMemo(() => splitPatchHunks(local.diff))
const nodes = new Set<DiffRenderable>()
const syncGutters = (attempt = 0) => {
requestAnimationFrame(() => {
const sides = [...nodes]
.filter((item) => !item.isDestroyed)
.flatMap((item) => item.getChildren().filter((side) => side instanceof LineNumberRenderable))
const lineNumbers = sides.map((side) => new Map([...side.getLineNumbers()].filter(([line]) => line >= 0)))
const digits = lineNumbers.map((numbers) => Math.max(0, ...numbers.values()).toString().length)
const after = sides.map((side) =>
Math.max(
0,
...[...side.getLineSigns()]
.filter(([line]) => line >= 0)
.map(([, sign]) => stringWidth(sign.after ?? "")),
),
)
const maxDigits = Math.max(...digits)
const maxAfter = Math.max(...after)
if (!maxDigits && attempt < 2) return syncGutters(attempt + 1)
if (!maxDigits) return
sides.forEach((side) => {
const index = sides.indexOf(side)
const signs = new Map([...side.getLineSigns()].filter(([line]) => line >= 0))
signs.set(-1, { after: " ".repeat(maxAfter + maxDigits - digits[index]) })
side.setLineNumbers(lineNumbers[index])
side.setLineSigns(signs)
})
})
}
const register = (node: DiffRenderable) => {
nodes.add(node)
syncGutters()
}
return (
<For each={hunks()}>
{(hunk, index) => (
<>
<Show when={index() > 0}>
<box width="100%" height={1} backgroundColor={local.lineNumberBg}>
<text fg={local.hunkFg} bg={local.lineNumberBg}>
{` ${hunk.header ?? ""}`}
</text>
</box>
</Show>
<diff
{...diffProps}
ref={register}
diff={hunk.patch}
minHeight={hunk.rows}
lineNumberBg={local.lineNumberBg}
/>
</>
)}
</For>
)
}
+3 -1
View File
@@ -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,9 @@ export function RunPermissionBody(props: {
</Show>
}
>
<diff
<PatchDiff
diff={info().diff!}
hunkFg={props.block.diffLineNumber}
view="unified"
filetype={ft()}
syntaxStyle={props.block.syntax}
+3 -1
View File
@@ -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,9 @@ export function RunEntryContent(props: {
</text>
{item.diff.trim() ? (
<box width="100%" paddingLeft={1}>
<diff
<PatchDiff
diff={item.diff}
hunkFg={theme().block.diffLineNumber}
view="unified"
filetype={toolFiletype(item.file)}
syntaxStyle={syntax()}
+5 -2
View File
@@ -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,9 @@ function Edit(props: ToolProps) {
{(item) => (
<BlockTool path={{ label: "← Edit", value: pathFormatter.format(path()) }} part={props.part}>
<box paddingLeft={1}>
<diff
<PatchDiff
diff={item().patch}
hunkFg={theme.diff.text.hunkHeader}
view={view()}
filetype={filetype(path())}
syntaxStyle={syntax()}
@@ -3116,8 +3118,9 @@ function ApplyPatch(props: ToolProps) {
}
>
<box paddingLeft={1}>
<diff
<PatchDiff
diff={file.patch}
hunkFg={theme.diff.text.hunkHeader}
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,9 @@ function EditBody(props: { file?: string; diff?: string; patch?: string }) {
},
}}
>
<diff
<PatchDiff
diff={diff()}
hunkFg={theme.diff.text.hunkHeader}
view={view()}
filetype={ft()}
syntaxStyle={syntax()}
+56
View File
@@ -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
}