From 63e31b01a1b28b4d4cdeb000329a6e507722d35d Mon Sep 17 00:00:00 2001 From: Sidharth Menon Date: Wed, 7 Oct 2026 03:41:09 -0700 Subject: [PATCH 1/2] TUI: give file headers their own band; quiet folds; tint split lines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit onedark painted file headers (ui.statusline) and folds (ui.cursorline.primary) the same light-black, a hair off the code background, so a new file read as another fold. solarized_light's statusline is the colour of its text, so header paths were unreadable. File headers now get a surface derived from bg and fg, an accent edge, and a dimmed directory with a bold file name. A blank row closes an open file before the next header. Paired folds sit on the code background with a ┄ rule to the edge; one-sided folds keep their change tint. In unified view, a row printed with one line number is gone from or new to the other side, so it takes the deletion or addition tint even when diffr found no changed words in it (a split line, say). It keeps its own kind only when the other side's line is folded away. AI-assisted: Claude Code. Agent-Session: 3c0a7f53-4e2b-4d6b-b99f-471c7e752571 --- tui/packages/hunk/src/diffr/regions.test.ts | 4 +- tui/packages/hunk/src/diffr/rows.test.ts | 22 ++++++++- tui/packages/hunk/src/diffr/rows.ts | 27 +++++++---- tui/packages/hunk/src/diffr/theme.test.ts | 20 +++++++- tui/packages/hunk/src/diffr/theme.ts | 19 +++++--- tui/packages/hunk/src/ui/App.test.tsx | 2 +- tui/packages/hunk/src/ui/App.tsx | 46 +++++++++++++------ tui/packages/hunk/src/ui/diff/CodeRowView.tsx | 8 +++- 8 files changed, 112 insertions(+), 36 deletions(-) diff --git a/tui/packages/hunk/src/diffr/regions.test.ts b/tui/packages/hunk/src/diffr/regions.test.ts index 25478ffbe..7493fc83a 100644 --- a/tui/packages/hunk/src/diffr/regions.test.ts +++ b/tui/packages/hunk/src/diffr/regions.test.ts @@ -258,11 +258,11 @@ test("a collapsed fold takes its side's change tint when one-sided and stays neu expect(removed.find((r) => r.left?.fold)!.left!.fold!.tint).toBe("removed"); expect(foldBackground(dark, "removed")).toBe(dark.deletion); // Paired: a matched fold pair, each fold with its own id and one fold state between - // them, keeps the neutral fold background. + // them, sits on the code's own background. const paired = rowsFor([body(7, "Body")], [{ ...body(8, "Body"), fold_state_id: 7, children: body(7, "Body").children }]); const both = paired.find((r) => r.left?.fold && r.right?.fold)!; expect([both.left!.fold!.tint, both.right!.fold!.tint]).toEqual(["neutral", "neutral"]); - expect(foldBackground(dark, "neutral")).toBe(dark.foldBackground); + expect(foldBackground(dark, "neutral")).toBe(dark.bg); // Modified: the same pair hiding a changed line takes the modification tint and counts it. const changedBody = (id: number) => ({ ...body(id, "Body"), children: [leaf(107, 1, 2, [line(1, 4, 5)])] }); const modified = rowsFor([changedBody(7)], [{ ...changedBody(8), fold_state_id: 7 }]); diff --git a/tui/packages/hunk/src/diffr/rows.test.ts b/tui/packages/hunk/src/diffr/rows.test.ts index 61c36bd3f..38917ddc6 100644 --- a/tui/packages/hunk/src/diffr/rows.test.ts +++ b/tui/packages/hunk/src/diffr/rows.test.ts @@ -185,10 +185,28 @@ test("unified trusts diffr's changed spans despite different source indentation" expect(copySelection([file], rows, {anchor: rows[1].key, end: rows[1].key, side})) .toBe(side === "left" ? " call();" : " call();"); } - // Only the side with a changed span may receive change styling. + // Only the side with a changed span gets word emphasis, but the old line, printed alone with + // its old number, still reads as removed. file.diff.rhs.root.children = [leaf(1, 0, 1, [line(0, 0, 11)])]; const changed = rowsForFile(file, 0, "unified", dark).slice(1); - expect(changed.map(r => r.cell!.kind)).toEqual(["context", "addition"]); + expect(changed.map(r => r.cell!.kind)).toEqual(["deletion", "addition"]); + expect(changed[0].cell!.spans.some(s => s.bg)).toBe(false); +}); +test("unified marks a split line removed and its halves added; a folded-away partner stays context", () => { + const file = createTestDiffFile(); + if (file.diff.type !== "text") throw new Error(); + // `f(a, b)` became `f(a,` / ` b)`: diffr changed only the inserted break, so the old line has no spans. + file.diff.lhs = { text: "f(a, b)\nend\n", syntax: [], root: root([leaf(1, 0, 1), leaf(2, 1, 2)])}; + file.diff.rhs = { text: "f(a,\n b)\nend\n", syntax: [], root: root([leaf(1, 0, 2, [line(0, 4, 4)]), leaf(2, 2, 3)])}; + const rows = rowsForFile(file, 0, "unified", dark).filter((r) => r.cell); + expect(rows.map((r) => [r.cell!.kind, r.cell!.oldLineNumber, r.cell!.newLineNumber])).toEqual([ + ["deletion", 1, undefined], ["addition", undefined, 1], ["addition", undefined, 2], ["context", 2, 3], + ]); + // Collapsing the old side's last leaf alone leaves its new partner printed with one number; + // that line is folded away, not removed, so it keeps its own kind. + file.diff.lhs.root.children[1] = { ...leaf(2, 1, 2), fold_state_id: 5 }; + const folded = rowsForFile(file, 0, "unified", dark, new Set([5])).filter((r) => r.cell?.newLineNumber === 3); + expect(folded.map((r) => r.cell!.kind)).toEqual(["context"]); }); test("binary and one-sided files render without a second side", () => { const file = createTestDiffFile(); diff --git a/tui/packages/hunk/src/diffr/rows.ts b/tui/packages/hunk/src/diffr/rows.ts index 3f2431454..3c29fb1bb 100644 --- a/tui/packages/hunk/src/diffr/rows.ts +++ b/tui/packages/hunk/src/diffr/rows.ts @@ -236,8 +236,12 @@ export function rowsForFile( }; const emit = (l: number | null, r: number | null, left: Leaf | null, right: Leaf | null) => { const key = `${fileIndex}:${l ?? "_"}:${r ?? "_"}`; - if (l !== null && hidden[0].has(l)) l = null; - if (r !== null && hidden[1].has(r)) r = null; + // A side's line is folded away, as a hidden line or a collapsed leaf, rather than absent. + const folded = (line: number | null, leaf: Leaf | null, side: Side) => + line === null ? leaf !== null && collapsed.has(leaf.foldStateId) : hidden[side].has(line); + const [leftFolded, rightFolded] = [folded(l, left, 0), folded(r, right, 1)]; + if (leftFolded) l = null; + if (rightFolded) r = null; if (l === null && r === null) return; const a = cell(left, l, 0), b = cell(right, r, 1); if (layout === "split") { @@ -251,12 +255,19 @@ export function rowsForFile( newLineNumber: r + 1, fold: b.fold ?? a.fold, spans: b.spans } }); return; } - if (l !== null) - pendingOld.push({ key: `${key}:old`, fileIndex, cell: { kind: a.kind === "deletion" ? "deletion" : "context", - sign: a.sign, oldLineNumber: l + 1, fold: a.fold, spans: a.spans } }); - if (r !== null) - pendingNew.push({ key: `${key}:new`, fileIndex, cell: { kind: b.kind === "addition" ? "addition" : "context", - sign: b.sign, newLineNumber: r + 1, fold: b.fold, spans: b.spans } }); + // A row with one line number is gone from, or new to, the other side even when diffr found + // no changed words in it, as when a line was split; it keeps its kind only when the other + // side's line is folded away. + if (l !== null) { + const removed = a.kind === "deletion" || !rightFolded; + pendingOld.push({ key: `${key}:old`, fileIndex, cell: { kind: removed ? "deletion" : "context", + sign: removed ? "-" : " ", oldLineNumber: l + 1, fold: a.fold, spans: a.spans } }); + } + if (r !== null) { + const added = b.kind === "addition" || !leftFolded; + pendingNew.push({ key: `${key}:new`, fileIndex, cell: { kind: added ? "addition" : "context", + sign: added ? "+" : " ", newLineNumber: r + 1, fold: b.fold, spans: b.spans } }); + } }; const leafRows = (left: Leaf | null, right: Leaf | null) => { // Every leaf is split at its folds' edges, so a fold's first line is a leaf's: the fold's diff --git a/tui/packages/hunk/src/diffr/theme.test.ts b/tui/packages/hunk/src/diffr/theme.test.ts index aec8213ed..956419ab0 100644 --- a/tui/packages/hunk/src/diffr/theme.test.ts +++ b/tui/packages/hunk/src/diffr/theme.test.ts @@ -1,5 +1,5 @@ import { expect, test } from "bun:test"; -import { bundledThemes, colorOf, loadBundledTheme, paletteFromHelix, parseHelixTheme, scopeStyle, themeConfig, themesFromConfig } from "./theme"; +import { bundledThemes, colorOf, foldBackground, loadBundledTheme, paletteFromHelix, parseHelixTheme, scopeStyle, themeConfig, themesFromConfig } from "./theme"; const sample = ` "keyword" = { fg = "red", modifiers = ["bold"] } "keyword.control" = { fg = "purple" } @@ -65,3 +65,21 @@ test("the theme set follows diffr's config and errors on a missing section", () expect(() => themesFromConfig({ name: "nope", path: null })).toThrow("Unknown theme"); expect(() => themesFromConfig({ name: "default-dark", path: "/nonexistent/theme.toml" })).toThrow(); }); +test("in every bundled theme a file header stands apart from code and folds, and its path reads", () => { + const channel = (hex: string, i: number) => { + const c = parseInt(hex.slice(i, i + 2), 16) / 255; + return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; + }; + const luminance = (hex: string) => 0.2126 * channel(hex, 1) + 0.7152 * channel(hex, 3) + 0.0722 * channel(hex, 5); + const contrast = (a: string, b: string) => { + const [high, low] = [luminance(a), luminance(b)].sort((x, y) => y - x); + return (high + 0.05) / (low + 0.05); + }; + for (const name of Object.keys(bundledThemes)) { + const palette = loadBundledTheme(name); + expect(palette.fileHeader).not.toBe(palette.bg); + expect(palette.fileHeader).not.toBe(foldBackground(palette, "neutral")); + expect(contrast(palette.fg, palette.fileHeader)).toBeGreaterThanOrEqual(4); + expect(contrast(palette.fileHeaderDir, palette.fileHeader)).toBeGreaterThanOrEqual(2.5); + } +}); diff --git a/tui/packages/hunk/src/diffr/theme.ts b/tui/packages/hunk/src/diffr/theme.ts index 41cc5365a..299c3fbfe 100644 --- a/tui/packages/hunk/src/diffr/theme.ts +++ b/tui/packages/hunk/src/diffr/theme.ts @@ -22,8 +22,12 @@ export interface Palette { bg: string; fg: string; muted: string; - /** Header, menu, and sidebar chrome. */ + /** Menu bar, status bar, and overlay chrome. */ chrome: string; + /** A file header's surface: the one band in the diff that marks where a file starts. */ + fileHeader: string; + /** The directory half of a header's path, so the file name stands out. */ + fileHeaderDir: string; /** Sidebar highlight for the active file. */ highlight: string; addition: string; @@ -35,8 +39,7 @@ export interface Palette { removedText: string; /** An accent for interactive text such as links and the layout badge. */ accent: string; - /** VS Code's editor.foldBackground and foldPlaceholderForeground. */ - foldBackground: string; + /** VS Code's editor.foldPlaceholderForeground. */ foldPlaceholder: string; guide: string; /** Foreground for a tree-sitter capture such as `keyword.return`; undefined when the theme has no scope for it. */ @@ -124,6 +127,9 @@ export function paletteFromHelix(theme: HelixTheme): Palette { const minus = scopeFg(theme, "diff.minus") ?? (isLight ? "#cf222e" : "#ffa198"); const delta = scopeFg(theme, "diff.delta") ?? (isLight ? "#9a6700" : "#e3b341"); const selection = scopeBg(theme, "ui.selection") ?? mix(bg, fg, 0.15); + // Derived rather than read from ui.statusline, which themes paint for other jobs: onedark's + // matches its cursorline, solarized_light's is the colour of its text. + const fileHeader = mix(bg, fg, isLight ? 0.12 : 0.16); return { name: theme.name, isLight, @@ -131,6 +137,8 @@ export function paletteFromHelix(theme: HelixTheme): Palette { fg, muted, chrome: scopeBg(theme, "ui.statusline") ?? mix(bg, fg, 0.06), + fileHeader, + fileHeaderDir: mix(fg, fileHeader, 0.3), highlight: selection, addition: mix(bg, plus, 0.12), deletion: mix(bg, minus, 0.12), @@ -140,7 +148,6 @@ export function paletteFromHelix(theme: HelixTheme): Palette { addedText: plus, removedText: minus, accent: scopeFg(theme, "function") ?? scopeFg(theme, "ui.text.focus") ?? fg, - foldBackground: scopeBg(theme, "ui.cursorline.primary") ?? mix(bg, fg, 0.1), foldPlaceholder: mix(muted, fg, 0.45), guide: scopeFg(theme, "ui.virtual.indent-guide") ?? mix(bg, fg, 0.22), syntax: (capture) => scopeFg(theme, capture), @@ -182,8 +189,8 @@ export function themeConfig(show: unknown): { name: string; path: string | null return { name: theme.name, path: theme.path ?? null }; } -/** Paired folds are neutral; only one-sided folds carry a change tint. */ +/** Paired folds sit on the code's own background; only one-sided folds carry a change tint. */ export function foldBackground(theme: Palette, tint: FoldTint) { return tint === "inserted" ? theme.addition : tint === "removed" ? theme.deletion - : tint === "modified" ? theme.modification : theme.foldBackground; + : tint === "modified" ? theme.modification : theme.bg; } diff --git a/tui/packages/hunk/src/ui/App.test.tsx b/tui/packages/hunk/src/ui/App.test.tsx index ed479dbab..792a4ea49 100644 --- a/tui/packages/hunk/src/ui/App.test.tsx +++ b/tui/packages/hunk/src/ui/App.test.tsx @@ -133,7 +133,7 @@ test("hierarchical tree navigation, sticky counts, sidebar toggle and menus", as expect(t.captureCharFrame().split("\n")[2]).toContain("+2 −1"); await act(async () => { t.mockInput.pressKey("\\"); }); await t.waitForFrame(f => !f.includes("▾ src ")); - expect(t.captureCharFrame().split("\n")[2].trimStart()).toStartWith("▾ src/nested/beta.ts"); + expect(t.captureCharFrame().split("\n")[2].trimStart()).toStartWith("▌▾ src/nested/beta.ts"); await act(async () => { t.mockInput.pressKey("\\"); }); await t.waitForFrame(f => f.includes("▾ nested")); await act(async () => { await t.mockMouse.click(9, 0); }); diff --git a/tui/packages/hunk/src/ui/App.tsx b/tui/packages/hunk/src/ui/App.tsx index a7b9252e3..a0d0c9274 100644 --- a/tui/packages/hunk/src/ui/App.tsx +++ b/tui/packages/hunk/src/ui/App.tsx @@ -11,6 +11,7 @@ import { useRenderer, useTerminalDimensions, } from "@opentui/react"; +import { TextAttributes } from "@opentui/core"; import { buildFileTree, flattenFileTree, parentDirectories, lineCounts } from "../diffr/fileTree"; import { matchesKey } from "./lib/keys"; import { resizeSidebarWidth } from "./lib/sidebar"; @@ -33,7 +34,7 @@ import { placeholderRows } from "../diffr/rows"; import { add, blockBar, comparisonLabel, zero, type LineCounts } from "../diffr/counts"; import type { DiffStore } from "../diffr/store"; import { sanitizeTerminalLine } from "../lib/terminalText"; -import { sliceTextByWidth } from "./lib/text"; +import { measureTextWidth, sliceTextByWidth } from "./lib/text"; const fit = (text: string, width: number) => sliceTextByWidth(text, 0, width).text; export function App({ @@ -100,7 +101,7 @@ export function App({ new WeakMap(), ); const rows = useMemo(() => { - const all = fileOrder.flatMap((index): ViewerRow[] => { + const perFile = fileOrder.map((index): ViewerRow[] => { const file = files[index]; if (!file) { const failure = failures[index]; @@ -121,6 +122,15 @@ export function App({ ? [cached.rows[0], ...placeholderRows(index, fileVisibility(file).label)] : cached.rows.slice(0, 1); }); + // A blank row closes an open file before the next header. It belongs to the file it closes, + // so the sticky header doesn't repeat the header just below it. + const all: ViewerRow[] = []; + for (const fileRows of perFile) { + const last = all.at(-1); + if (last && !last.key.endsWith(":header") && last.label !== "") + all.push({ key: `${last.fileIndex}:end`, fileIndex: last.fileIndex, label: "" }); + all.push(...fileRows); + } for (const [i, error] of snapshot.errors.entries()) all.push({ key: `error:${i}`, fileIndex: -1, label: error }); return all; @@ -296,21 +306,29 @@ export function App({ : index >= old + viewportHeight ? index - viewportHeight + 1 : old); }, [currentFile, treeRows, viewportHeight]); const sidebarStart = Math.min(treeScroll, Math.max(0, treeRows.length - viewportHeight)); + // A file header is the diff's one band: an accent edge, then the directory dimmed so the file + // name carries the row. const fileHeader = (fileIndex: number, key: string) => { const file = files[fileIndex], count = counts[fileIndex]?.visible; - const path = filePath(inventory[fileIndex].file); - if (!file || !count) return - {fit(sanitizeTerminalLine(` ${statusGlyph(fileIndex)} ${path}`), contentWidth)} - ; - const statsWidth = String(count.added).length + String(count.removed).length + 5; + const path = sanitizeTerminalLine(filePath(inventory[fileIndex].file)); + const loaded = !!file && !!count; + const statsWidth = loaded ? String(count.added).length + String(count.removed).length + 5 : 0; + const pathWidth = Math.max(1, contentWidth - statsWidth - 1); + const glyph = loaded ? (isClosed(fileIndex, file) ? "▸" : "▾") : statusGlyph(fileIndex); + const directory = fit(`${glyph} ${path.slice(0, path.lastIndexOf("/") + 1)}`, pathWidth); + const directoryWidth = measureTextWidth(directory); + const name = fit(path.slice(path.lastIndexOf("/") + 1), Math.max(0, pathWidth - directoryWidth)); return toggleFile(fileIndex)}> - - {fit(sanitizeTerminalLine(`${isClosed(fileIndex, file) ? "▸" : "▾"} ${path}`), Math.max(1, contentWidth - statsWidth))} - - {` +${count.added}`} - {` −${count.removed} `} + backgroundColor={theme.fileHeader} + onMouseUp={() => { if (loaded) toggleFile(fileIndex); }}> + ▌ + {directory} + {name} + {loaded && <> + {` +${count.added}`} + {` −${count.removed} `} + } ; }; const rendered = []; diff --git a/tui/packages/hunk/src/ui/diff/CodeRowView.tsx b/tui/packages/hunk/src/ui/diff/CodeRowView.tsx index 5fe9f0ddb..39925b65b 100644 --- a/tui/packages/hunk/src/ui/diff/CodeRowView.tsx +++ b/tui/packages/hunk/src/ui/diff/CodeRowView.tsx @@ -83,8 +83,12 @@ export const CodeRowView = memo(function CodeRowView({ const gutterWidth = unified ? geometry.unifiedGutter : geometry.gutter; const available = Math.max(1, width - gutterWidth); const used = Math.min(available, spans.reduce((n, s) => n + measureTextWidth(s.text), 0)); - const painted = value.band && used < available - ? [...spans, {text: " ".repeat(available - used), bg: foldBackground(theme, value.band)}] : spans; + // A collapsed row's tint fills the rest of the line; its header row also draws a ┄ rule to + // the edge, so a fold reads as a seam in the code rather than a band like a file header. + const rest = available - used; + const painted = value.band && rest > 0 + ? [...spans, {text: (value.fold ? " " + "┄".repeat(rest) : " ".repeat(rest)).slice(0, rest), + fg: theme.guide, bg: foldBackground(theme, value.band)}] : spans; return ( Date: Wed, 7 Oct 2026 04:06:41 -0700 Subject: [PATCH 2/2] TUI: one scope, one target for rails, brackets and chevrons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A statement and the body it opens can start on one line. The chevron went to the outermost fold while the rail belonged to the body, so their fold-state ids differed: hovering a header lit nothing, and its chevron folded the statement into a band rather than the body inline. A scope opened on the line now takes the chevron. Each row knows the innermost open scope from its opener's line to its closer's, and the scopes whose body holds it; brackets carry their scope's id. Hovering a line focuses its innermost scope: rail and both brackets take the accent, so a closer lights its own scope rather than its parent's. Pointing at a rail picks that rail's scope, even an outer one, and arms it like the chevron does: the rail thickens to ┃, the brackets get the bracket-match box and the body a wash. Clicking a rail folds its scope instead of starting a line selection. Open chevrons rest faint; collapsed ones stay legible. AI-assisted: Claude Code. Agent-Session: 3c0a7f53-4e2b-4d6b-b99f-471c7e752571 --- tui/packages/hunk/src/diffr/guides.test.ts | 16 ++++ tui/packages/hunk/src/diffr/regions.test.ts | 9 ++ tui/packages/hunk/src/diffr/regions.ts | 9 +- tui/packages/hunk/src/diffr/rows.ts | 51 ++++++++++-- tui/packages/hunk/src/diffr/theme.ts | 9 +- tui/packages/hunk/src/ui/App.tsx | 26 +++--- tui/packages/hunk/src/ui/Interaction.test.tsx | 22 ++++- tui/packages/hunk/src/ui/diff/CodeRowView.tsx | 82 ++++++++++++++----- tui/packages/hunk/src/ui/diff/diffRowModel.ts | 14 ++++ .../hunk/src/ui/diff/styledSpanLayout.ts | 1 + 10 files changed, 196 insertions(+), 43 deletions(-) diff --git a/tui/packages/hunk/src/diffr/guides.test.ts b/tui/packages/hunk/src/diffr/guides.test.ts index efa0e05c0..877234a53 100644 --- a/tui/packages/hunk/src/diffr/guides.test.ts +++ b/tui/packages/hunk/src/diffr/guides.test.ts @@ -43,3 +43,19 @@ test("wire byte columns use the code row's tab stops and Unicode cell widths", ( expect(byteColumn("\t界 café {", 4)).toBe(6); expect(byteColumn("\t界 café {", 10)).toBe(11); }); +test("a scope runs from its opener to its closer: each line knows its innermost scope, and its brackets carry its id", () => { + const file = createGuideDiffFile(); + if (file.diff.type !== "text") throw new Error(); + const rows = rowsForFile(file, 0, "split", dark, defaultCollapsed(file.diff)); + const at = (line: number) => rows.find(r => r.right?.lineNumber === line)!.right!; + // The opener's line and the closer's line belong to the scope itself, not its parent. + expect([at(3).scope, at(8).scope, at(4).scope, at(9).scope]).toEqual([30, 30, 30, 20]); + // Only the lines between opener and closer are the body that folding hides. + expect(at(4).body).toEqual([10, 20, 30]); + expect(at(3).body).toEqual([10, 20]); + const braces = (line: number) => at(line).spans.filter(s => s.brace !== undefined).map(s => [s.text, s.brace]); + expect(braces(3)).toEqual([["{", 30]]); + expect(braces(8)).toEqual([["}", 30]]); + // The chevron on an opener's line folds that scope, the one its rail and brackets show. + expect(at(3).fold?.id).toBe(30); +}); diff --git a/tui/packages/hunk/src/diffr/regions.test.ts b/tui/packages/hunk/src/diffr/regions.test.ts index 7493fc83a..ce1b726a8 100644 --- a/tui/packages/hunk/src/diffr/regions.test.ts +++ b/tui/packages/hunk/src/diffr/regions.test.ts @@ -305,3 +305,12 @@ test("a group is one row that stands for every collapsed region under it", () => .toMatchObject({ id: 20, collapsed: false }); expect(gapIds(file.diff)).toEqual([]); }); +test("a scope opened on a line takes its chevron from a statement fold starting there", () => { + const base = { side: 1 as const, label: "", tags: [], collapsed: false, nested: [], parentColumn: 0 }; + const statement = { ...base, id: 1, foldStateId: 1, startLine: 4, lastHidden: 20 }; + const scope = { ...base, id: 2, foldStateId: 2, startLine: 5, lastHidden: 19, + syntax: { start: { line: 4, column: 30 }, end: { line: 20, column: 0 } } }; + expect(foldHeaders([statement, scope], [], new Set(), new Set()).get(4)?.id).toBe(2); + // Without a scope on the line, the outermost fold keeps it. + expect(foldHeaders([statement, { ...scope, syntax: undefined }], [], new Set(), new Set()).get(4)?.id).toBe(1); +}); diff --git a/tui/packages/hunk/src/diffr/regions.ts b/tui/packages/hunk/src/diffr/regions.ts index 8e541c675..d1e2586ec 100644 --- a/tui/packages/hunk/src/diffr/regions.ts +++ b/tui/packages/hunk/src/diffr/regions.ts @@ -214,8 +214,10 @@ export const foldableLeaf = (leaf: Leaf) => leaf.collapsed || leaf.label !== ""; /** * The chevron each source line carries, one map per side. An open fold puts one on the first * syntax opener (or its first covered line without syntax). Collapsed syntax bodies join - * opener and closer; other collapsed folds have a standalone band. Folds can start on one line — the fold - * the context plugin wraps around its first member — and the outermost wins. + * opener and closer; other collapsed folds have a standalone band. Folds can start on one line — + * a statement and the body it opens, or the fold the context plugin wraps around its first + * member. A scope opened on the line wins, so the chevron folds the same scope its rail and + * brackets show; otherwise the outermost wins. */ export function foldHeaders( folds: Fold[], @@ -235,7 +237,8 @@ export function foldHeaders( byLine.set(header, [...(byLine.get(header) ?? []), fold]); } for (const [line, sharing] of byLine) { - const fold = [...sharing].sort((a, b) => b.lastHidden - a.lastHidden)[0]; + const scopes = sharing.filter((fold) => fold.syntax?.start.line === line); + const fold = [...(scopes.length ? scopes : sharing)].sort((a, b) => b.lastHidden - a.lastHidden)[0]; headers.set(line, { id: fold.foldStateId, label: fold.label, collapsed: false, tint: foldTint(fold.id, fold.side, paired) }); } diff --git a/tui/packages/hunk/src/diffr/rows.ts b/tui/packages/hunk/src/diffr/rows.ts index 3c29fb1bb..66a4d81e1 100644 --- a/tui/packages/hunk/src/diffr/rows.ts +++ b/tui/packages/hunk/src/diffr/rows.ts @@ -132,6 +132,37 @@ export function rowsForFile( } return lines; }); + // An open scope is a syntax fold on screen, whose rail, brackets and chevron are one target. + // The pointer anywhere from its opener's line to its closer's focuses the innermost one; its + // body is the lines between, which folding it hides. + const scopes = folds.map((side) => { + const lines = new Map(); + for (const fold of side) { + if (!fold.syntax || collapsed.has(fold.foldStateId)) continue; + for (let line = fold.syntax.start.line; line <= fold.syntax.end.line; line++) + lines.set(line, [...(lines.get(line) ?? []), fold as SyntaxFold]); + } + return lines; + }); + const scopeOf = (side: Side, line: number) => { + const around = scopes[side].get(line) ?? []; + const extent = (fold: SyntaxFold) => fold.syntax.end.line - fold.syntax.start.line; + // Two scopes can share their lines, as `({` does: the one opening later is inside. + const inner = around.reduce((best, fold) => !best || extent(fold) < extent(best) + || (extent(fold) === extent(best) && fold.syntax.start.column > best.syntax.start.column) ? fold : best, undefined); + return { scope: inner?.foldStateId, + body: around.filter(fold => fold.syntax.start.line < line && line < fold.syntax.end.line).map(fold => fold.foldStateId) }; + }; + // The opener is the byte before a scope's syntax range and the closer the byte at its end. + const withBraces = (spans: RenderSpan[], side: Side, line: number) => { + let result = spans; + for (const fold of scopes[side].get(line) ?? []) { + const { start, end } = fold.syntax, text = texts[side][line]!; + if (start.line === line && start.column > 0) result = markBrace(result, byteColumn(text, start.column - 1), fold.foldStateId); + if (end.line === line) result = markBrace(result, byteColumn(text, end.column), fold.foldStateId); + } + return result; + }; const tintOf = (region: Leaf | Fold) => foldTint(region.id, region.side, paired[region.side]); const alignments = leaves.map(side => new Set(side.map(leaf => leaf.alignmentId))); const isChanged = (leaf: Leaf, line: number) => @@ -165,7 +196,7 @@ export function rowsForFile( }; const cell = (leaf: Leaf | null, line: number | null, side: Side): SplitLineCell => { if (line === null || leaf === null) return empty; - let spans = spansOf(side, line, leaf); + let spans = withBraces(spansOf(side, line, leaf), side, line); let fold = headers[side].get(line); const folded = inline[side].get(line); if (folded) { @@ -186,6 +217,7 @@ export function rowsForFile( lineNumber: line + 1, spans: withGuides(spans, guides[side].get(line) ?? [], theme), fold, + ...scopeOf(side, line), }; }; let pendingOld: ViewerRow[] = [], pendingNew: ViewerRow[] = []; @@ -209,12 +241,12 @@ export function rowsForFile( if (quoted) { const [first, ...rest] = region.label.split("\n"); const header = { kind: "context" as const, sign: " ", band: tint, spans: lead(`> ${first}${note}`), - fold: { id: region.foldStateId, label: region.label, collapsed: true, tint } }; + fold: { id: region.foldStateId, label: region.label, collapsed: true, tint }, ...scopeOf(region.side, region.startLine) }; return { header, labels: rest.map(text => ({ ...header, foldLabel: true, spans: lead(`> ${text}`), fold: undefined })) }; } const header = { kind: "context" as const, sign: " ", band: tint, spans: lead(`⋯${region.label && !multiline ? " " + region.label : ""}${note}`), - fold: { id: region.foldStateId, label: region.label, collapsed: true, tint } }; + fold: { id: region.foldStateId, label: region.label, collapsed: true, tint }, ...scopeOf(region.side, region.startLine) }; const labels = multiline ? region.label.split("\n").map(text => ({ ...header, foldLabel: true, spans: lead(text), fold: undefined })) : []; @@ -252,7 +284,7 @@ export function rowsForFile( if (l !== null && r !== null && a.kind === "context" && b.kind === "context") { flush(); rows.push({ key, fileIndex, cell: { kind: "context", sign: " ", oldLineNumber: l + 1, - newLineNumber: r + 1, fold: b.fold ?? a.fold, spans: b.spans } }); + newLineNumber: r + 1, fold: b.fold ?? a.fold, scope: b.scope, body: b.body, spans: b.spans } }); return; } // A row with one line number is gone from, or new to, the other side even when diffr found @@ -261,12 +293,12 @@ export function rowsForFile( if (l !== null) { const removed = a.kind === "deletion" || !rightFolded; pendingOld.push({ key: `${key}:old`, fileIndex, cell: { kind: removed ? "deletion" : "context", - sign: removed ? "-" : " ", oldLineNumber: l + 1, fold: a.fold, spans: a.spans } }); + sign: removed ? "-" : " ", oldLineNumber: l + 1, fold: a.fold, scope: a.scope, body: a.body, spans: a.spans } }); } if (r !== null) { const added = b.kind === "addition" || !leftFolded; pendingNew.push({ key: `${key}:new`, fileIndex, cell: { kind: added ? "addition" : "context", - sign: added ? "+" : " ", newLineNumber: r + 1, fold: b.fold, spans: b.spans } }); + sign: added ? "+" : " ", newLineNumber: r + 1, fold: b.fold, scope: b.scope, body: b.body, spans: b.spans } }); } }; const leafRows = (left: Leaf | null, right: Leaf | null) => { @@ -317,6 +349,13 @@ function markHunks(rows: ViewerRow[]): ViewerRow[] { } return rows; } +/** Tag the one cell at a terminal column as a scope's bracket. */ +function markBrace(spans: RenderSpan[], column: number, id: number): RenderSpan[] { + const at = sliceSpansWindow(spans, column, 1).spans; + if (!at.length) return spans; + return [...sliceSpansWindow(spans, 0, column).spans, ...at.map(span => ({ ...span, brace: id })), + ...sliceSpansWindow(spans, column + 1, Infinity).spans]; +} /** Replace only whitespace with guides. Blank source lines still carry their enclosing scopes. */ interface Guide { column: number; diff --git a/tui/packages/hunk/src/diffr/theme.ts b/tui/packages/hunk/src/diffr/theme.ts index 299c3fbfe..da7daff1c 100644 --- a/tui/packages/hunk/src/diffr/theme.ts +++ b/tui/packages/hunk/src/diffr/theme.ts @@ -42,6 +42,10 @@ export interface Palette { /** VS Code's editor.foldPlaceholderForeground. */ foldPlaceholder: string; guide: string; + /** The body of a scope about to fold, while the pointer is on its rail or chevron. */ + focusWash: string; + /** VS Code's bracket-match box, on the brackets of a scope about to fold. */ + focusBrace: string; /** Foreground for a tree-sitter capture such as `keyword.return`; undefined when the theme has no scope for it. */ syntax: (capture: string) => string | undefined; } @@ -130,6 +134,7 @@ export function paletteFromHelix(theme: HelixTheme): Palette { // Derived rather than read from ui.statusline, which themes paint for other jobs: onedark's // matches its cursorline, solarized_light's is the colour of its text. const fileHeader = mix(bg, fg, isLight ? 0.12 : 0.16); + const accent = scopeFg(theme, "function") ?? scopeFg(theme, "ui.text.focus") ?? fg; return { name: theme.name, isLight, @@ -147,9 +152,11 @@ export function paletteFromHelix(theme: HelixTheme): Palette { deleteWord: mix(bg, minus, 0.28), addedText: plus, removedText: minus, - accent: scopeFg(theme, "function") ?? scopeFg(theme, "ui.text.focus") ?? fg, + accent, foldPlaceholder: mix(muted, fg, 0.45), guide: scopeFg(theme, "ui.virtual.indent-guide") ?? mix(bg, fg, 0.22), + focusWash: mix(bg, accent, 0.08), + focusBrace: mix(bg, accent, 0.3), syntax: (capture) => scopeFg(theme, capture), }; } diff --git a/tui/packages/hunk/src/ui/App.tsx b/tui/packages/hunk/src/ui/App.tsx index a0d0c9274..5e34ca44a 100644 --- a/tui/packages/hunk/src/ui/App.tsx +++ b/tui/packages/hunk/src/ui/App.tsx @@ -16,6 +16,7 @@ import { buildFileTree, flattenFileTree, parentDirectories, lineCounts } from ". import { matchesKey } from "./lib/keys"; import { resizeSidebarWidth } from "./lib/sidebar"; import { CodeRowView } from "./diff/CodeRowView"; +import type { ScopeFocus } from "./diff/diffRowModel"; import { rowsForFile, type Layout, @@ -29,7 +30,7 @@ import { type SourceSelection, } from "../diffr/selection"; import { filePath, fileVisibility, type DiffFile, type TextDiff } from "../diffr/wire"; -import { defaultCollapsed, foldIds, gapIds, nestedIds, sourceLines, type RowFold } from "../diffr/regions"; +import { defaultCollapsed, foldIds, gapIds, nestedIds, sourceLines } from "../diffr/regions"; import { placeholderRows } from "../diffr/rows"; import { add, blockBar, comparisonLabel, zero, type LineCounts } from "../diffr/counts"; import type { DiffStore } from "../diffr/store"; @@ -55,7 +56,7 @@ export function App({ [theme, setTheme] = useState(themes.initial); const [position, setPosition] = useState(null); const [horizontal, setHorizontal] = useState(0); - const [hoveredFold, setHoveredFold] = useState<{file: number; id: number} | null>(null); + const [hoveredFold, setHoveredFold] = useState<{file: number; focus: ScopeFocus} | null>(null); const [spinner, setSpinner] = useState(0); useEffect(() => { if (snapshot.complete) return; @@ -168,18 +169,18 @@ export function App({ return new Map(old).set(fileIndex, next); }); // Recursive commands (Alt-click, zC, zO, zA) include every fold nested inside. - const setFold = (fileIndex: number, fold: RowFold, collapse: boolean | "toggle", recursive: boolean) => { + const setFold = (fileIndex: number, id: number, collapse: boolean | "toggle", recursive: boolean) => { const diff = files[fileIndex]?.diff; if (diff?.type !== "text") throw new Error(`File ${fileIndex} has no folds`); - setFolds(fileIndex, diff, recursive ? [fold.id, ...nestedIds(diff, fold.id)] : [fold.id], collapse); + setFolds(fileIndex, diff, recursive ? [id, ...nestedIds(diff, id)] : [id], collapse); }; // `c`: reveal every context gap, or hide them again. const toggleContext = () => { const opened = textDiffs.some(({ index, diff }) => gapIds(diff).some((id) => !foldsOf(index, diff).has(id))); textDiffs.forEach(({ index, diff }) => setFolds(index, diff, gapIds(diff), opened)); }; - const toggleFold = (fileIndex: number, fold: RowFold, recursive: boolean) => - setFold(fileIndex, fold, "toggle", recursive); + const toggleFold = (fileIndex: number, id: number, recursive: boolean) => + setFold(fileIndex, id, "toggle", recursive); const navigateFold = (direction: number) => { const headers = geometry.rows.filter((r) => rowFold(r.row)); const target = @@ -199,9 +200,9 @@ export function App({ if (!current || !fold) return; const recursive = command === command.toUpperCase(); const letter = command.toLowerCase(); - if (letter === "a") toggleFold(current.fileIndex, fold, recursive); - else if (letter === "o") setFold(current.fileIndex, fold, false, recursive); - else if (letter === "c") setFold(current.fileIndex, fold, true, recursive); + if (letter === "a") toggleFold(current.fileIndex, fold.id, recursive); + else if (letter === "o") setFold(current.fileIndex, fold.id, false, recursive); + else if (letter === "c") setFold(current.fileIndex, fold.id, true, recursive); }; const foldAll = (collapse: boolean) => textDiffs.forEach(({ index, diff }) => setFolds(index, diff, foldIds(diff), collapse)); @@ -379,9 +380,10 @@ export function App({ if (dragging.current) setSelection((s) => (s ? { ...s, end: row.key } : s)); }} - activeFold={hoveredFold?.file === row.fileIndex ? hoveredFold.id : undefined} - onHover={id => setHoveredFold(old => old?.file === row.fileIndex && old.id === id ? old : id === undefined ? null : {file: row.fileIndex, id})} - onFold={(fold, recursive) => toggleFold(row.fileIndex, fold, recursive)} + focus={hoveredFold?.file === row.fileIndex ? hoveredFold.focus : undefined} + onHover={focus => setHoveredFold(old => old?.file === row.fileIndex && old.focus.id === focus?.id + && old.focus.armed === focus.armed ? old : focus === undefined ? null : {file: row.fileIndex, focus})} + onFold={(id, recursive) => toggleFold(row.fileIndex, id, recursive)} />, ); } diff --git a/tui/packages/hunk/src/ui/Interaction.test.tsx b/tui/packages/hunk/src/ui/Interaction.test.tsx index 19f9d88ea..391f71af1 100644 --- a/tui/packages/hunk/src/ui/Interaction.test.tsx +++ b/tui/packages/hunk/src/ui/Interaction.test.tsx @@ -66,7 +66,7 @@ test("Paper folds stay under the mouse, pair both sides, accent guides, and surv try { await act(async () => { await t.renderOnce(); }); const y = row("if event.open"), x = frame().split("\n")[y].indexOf("▾"); - const accents = () => t.captureSpans().lines.flatMap(l => l.spans).filter(s => s.text.includes("│") && rgbToHex(s.fg).toLowerCase() === dark.accent.toLowerCase()).length; + const accents = () => t.captureSpans().lines.flatMap(l => l.spans).filter(s => /[│┃]/.test(s.text) && rgbToHex(s.fg).toLowerCase() === dark.accent.toLowerCase()).length; expect(accents()).toBe(0); await act(async () => { await t.mockMouse.moveTo(x, y); await t.renderOnce(); }); await t.waitFor(() => accents() > 0); @@ -96,6 +96,26 @@ test("Paper folds stay under the mouse, pair both sides, accent guides, and surv expect(frame()).toContain("open(true)"); } finally { await act(async () => { t.renderer.destroy(); }); } }); +test("pointing at a rail arms that scope, even an outer one, and clicking the rail folds it", async () => { + const store = new DiffStore(), file = createGuideDiffFile(); + store.accept(startFor([file])); store.accept(file); store.accept({type:"complete", succeeded:1, failed:0}); + const t = await testRender( {}} />, {width:170, height:25}); + const frame = () => t.captureCharFrame(); + try { + await act(async () => { await t.renderOnce(); }); + // `open(true)` sits inside impl, fn handle and if; its row crosses all three rails. + const y = frame().split("\n").findIndex(l => l.includes("open(true)")); + const row = frame().split("\n")[y], code = row.indexOf("│", row.indexOf(" 4 ") + 3); + const handleRail = row.indexOf("│", code + 1); + await act(async () => { await t.mockMouse.moveTo(handleRail, y); await t.renderOnce(); }); + await act(async () => { await t.renderOnce(); }); + // Armed, the fn handle rail thickens; the if rail inside it stays thin. + expect(frame().split("\n")[y].indexOf("┃")).toBe(handleRail); + await act(async () => { await t.mockMouse.click(handleRail, y); await t.renderOnce(); }); + await t.waitForFrame(f => f.includes("fn handle(&self) { ⋯ 2 lines · 1 line changed }")); + expect(frame()).not.toContain("open(true)"); + } finally { await act(async () => { t.renderer.destroy(); }); } +}); test("closing a scrolled file and reopening it starts at its header and first source line", async () => { const store = new DiffStore(); const file = path(withIdenticalLines(createTestDiffFile(), 120), "long.ts"); diff --git a/tui/packages/hunk/src/ui/diff/CodeRowView.tsx b/tui/packages/hunk/src/ui/diff/CodeRowView.tsx index 39925b65b..29dc80659 100644 --- a/tui/packages/hunk/src/ui/diff/CodeRowView.tsx +++ b/tui/packages/hunk/src/ui/diff/CodeRowView.tsx @@ -1,8 +1,9 @@ /** Paint measured Hunk-style code cells; source identity and viewport geometry stay outside React. */ import { memo } from "react"; -import { StyledText, parseColor } from "@opentui/core"; +import { StyledText, parseColor, type MouseEvent } from "@opentui/core"; import type { RenderSpan, + ScopeFocus, SplitLineCell, UnifiedLineCell, } from "./diffRowModel"; @@ -19,21 +20,36 @@ function color(value: string) { } return c; } -function styled(spans: RenderSpan[], theme: Palette, bg: string, activeFold?: number) { +/** The focused scope's rail and brackets take the accent; armed, the rail thickens and the + * brackets get the bracket-match box. */ +function styled(spans: RenderSpan[], theme: Palette, bg: string, focus?: ScopeFocus) { return new StyledText( - spans.map((span) => ({ - __isChunk: true as const, - text: span.text, - fg: color(span.guide !== undefined && span.guide === activeFold ? theme.accent : span.fg ?? theme.fg), - bg: color(span.bg ?? bg), - })), + spans.map((span) => { + const rail = span.guide !== undefined && span.guide === focus?.id; + const brace = span.brace !== undefined && span.brace === focus?.id; + return { + __isChunk: true as const, + text: rail && focus!.armed ? span.text.replace(/│/g, "┃") : span.text, + fg: color(rail || brace ? theme.accent : span.fg ?? theme.fg), + bg: color(brace && focus!.armed ? theme.focusBrace : span.bg ?? bg), + }; + }), ); } -/** VS Code's showFoldingControls "always": expandable rows keep their chevron visible. */ function chevron(fold: RowFold | undefined) { if (!fold) return " "; return fold.collapsed ? "▸" : "▾"; } +/** The rail under a terminal column of a row's spans, if any. */ +function railAt(spans: RenderSpan[], column: number): number | undefined { + let at = 0; + for (const span of spans) { + const width = measureTextWidth(span.text); + if (column < at + width) return span.guide; + at += width; + } + return undefined; +} export const CodeRowView = memo(function CodeRowView({ measured, visualLine, @@ -43,7 +59,7 @@ export const CodeRowView = memo(function CodeRowView({ onSelect, onExtend, onFold, - activeFold, + focus, onHover, }: { measured: MeasuredRow; @@ -53,9 +69,9 @@ export const CodeRowView = memo(function CodeRowView({ selectedSide?: "left" | "right"; onSelect: (side: "left" | "right") => void; onExtend: () => void; - activeFold?: number; - onHover: (id: number | undefined) => void; - onFold: (fold: RowFold, recursive: boolean) => void; + focus?: ScopeFocus; + onHover: (focus: ScopeFocus | undefined) => void; + onFold: (id: number, recursive: boolean) => void; }) { const row = measured.row; function cell( @@ -66,6 +82,7 @@ export const CodeRowView = memo(function CodeRowView({ unified = false, ) { const fold = value.fold; + const washed = focus?.armed && value.body?.includes(focus.id); const bg = selectedSide === side ? theme.highlight @@ -73,7 +90,7 @@ export const CodeRowView = memo(function CodeRowView({ ? theme.addition : value.kind === "deletion" ? theme.deletion - : theme.bg; + : washed ? theme.focusWash : theme.bg; // Row colours carry addition and deletion, so the gutter holds numbers and the chevron only. const digits = geometry.gutter - 4; const number = (n: number | undefined) => `${visualLine ? "" : (n ?? "")}`.padStart(digits); @@ -89,6 +106,23 @@ export const CodeRowView = memo(function CodeRowView({ const painted = value.band && rest > 0 ? [...spans, {text: (value.fold ? " " + "┄".repeat(rest) : " ".repeat(rest)).slice(0, rest), fg: theme.guide, bg: foldBackground(theme, value.band)}] : spans; + // What the pointer is on. A rail or the chevron arms its scope, so a click folds it; anywhere + // else on the row reads the innermost scope around the line. + const chevronColumn = numbers.length, codeColumn = numbers.length + 2; + const rail = (event: MouseEvent) => { + const column = event.x - (event.currentTarget?.x ?? 0); + return column >= codeColumn ? railAt(painted, column - codeColumn) : undefined; + }; + const target = (event: MouseEvent): ScopeFocus | undefined => { + const column = event.x - (event.currentTarget?.x ?? 0); + const id = column === chevronColumn && fold && !fold.collapsed && !visualLine ? fold.id : rail(event); + if (id !== undefined) return { id, armed: true }; + return value.scope === undefined ? undefined : { id: value.scope, armed: false }; + }; + // Open chevrons rest faint so they don't compete with the code; a collapsed one stays + // legible, since it is the way back in, and the focused scope's lights up. + const chevronFg = fold && !fold.collapsed && fold.id === focus?.id ? theme.accent + : fold?.collapsed ? theme.muted : theme.guide; return ( { - if (event.button === 0) onSelect(side); + if (event.button !== 0) return; + if (rail(event) !== undefined) event.stopPropagation(); + else onSelect(side); + }} + onMouseUp={(event) => { + const id = event.button === 0 ? rail(event) : undefined; + if (id === undefined) return; + event.stopPropagation(); + onFold(id, event.modifiers.alt); }} - onMouseMove={() => { onExtend(); onHover(fold?.id ?? spans.findLast(s => s.guide !== undefined)?.guide); }} + onMouseMove={(event) => { onExtend(); onHover(target(event)); }} onMouseOut={() => onHover(undefined)} > @@ -107,7 +149,7 @@ export const CodeRowView = memo(function CodeRowView({ { if (fold && !visualLine) event.stopPropagation(); @@ -115,7 +157,7 @@ export const CodeRowView = memo(function CodeRowView({ onMouseUp={(event) => { if (fold && !visualLine && event.button === 0) { event.stopPropagation(); - onFold(fold, event.modifiers.alt); + onFold(fold.id, event.modifiers.alt); } }} > @@ -129,13 +171,13 @@ export const CodeRowView = memo(function CodeRowView({ height={1} onMouseDown={event => { if (fold?.collapsed) event.stopPropagation(); }} onMouseUp={event => { - if (fold?.collapsed && event.button === 0) { event.stopPropagation(); onFold(fold, event.modifiers.alt); } + if (fold?.collapsed && event.button === 0) { event.stopPropagation(); onFold(fold.id, event.modifiers.alt); } }} content={styled( selectedSide === side ? painted.map((s) => ({ ...s, bg })) : painted, theme, bg, - activeFold, + focus, )} selectable={false} /> diff --git a/tui/packages/hunk/src/ui/diff/diffRowModel.ts b/tui/packages/hunk/src/ui/diff/diffRowModel.ts index c4f9baf5f..93d8a84b5 100644 --- a/tui/packages/hunk/src/ui/diff/diffRowModel.ts +++ b/tui/packages/hunk/src/ui/diff/diffRowModel.ts @@ -12,6 +12,8 @@ export interface RenderSpan { text: string; /** Fold-state id of an indent guide; paint-only hover accent. */ guide?: number; + /** Fold-state id of the scope whose opening or closing bracket this is. */ + brace?: number; fg?: string; bg?: string; /** Resolve paint-only foreground effects after cursor and copy-selection backgrounds apply. */ @@ -28,6 +30,10 @@ export interface SplitLineCell { foldLabel?: boolean; /** A collapsed row or label line: its tint fills the rest of the line. */ band?: FoldTint; + /** The innermost open scope holding this line, its opener and closer included. */ + scope?: number; + /** Open scopes whose body (between opener and closer) holds this line. */ + body?: number[]; spans: RenderSpan[]; } @@ -39,5 +45,13 @@ export interface UnifiedLineCell { fold?: RowFold; foldLabel?: boolean; band?: FoldTint; + scope?: number; + body?: number[]; spans: RenderSpan[]; } + +/** The scope the pointer is on. Armed when it points at the scope's rail or chevron, which fold it. */ +export interface ScopeFocus { + id: number; + armed: boolean; +} diff --git a/tui/packages/hunk/src/ui/diff/styledSpanLayout.ts b/tui/packages/hunk/src/ui/diff/styledSpanLayout.ts index c44b88d28..4261736f3 100644 --- a/tui/packages/hunk/src/ui/diff/styledSpanLayout.ts +++ b/tui/packages/hunk/src/ui/diff/styledSpanLayout.ts @@ -16,6 +16,7 @@ function appendRenderSpan(target: RenderSpan[], span: RenderSpan) { if ( previous && previous.guide === span.guide && + previous.brace === span.brace && previous.fg === span.fg && previous.bg === span.bg && previous.transformFg === span.transformFg