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 25478ffbe..ce1b726a8 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 }]); @@ -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.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..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 })) : []; @@ -236,8 +268,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") { @@ -248,15 +284,22 @@ 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; } - 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, 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, scope: b.scope, body: b.body, 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 @@ -306,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.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..da7daff1c 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,10 +39,13 @@ 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; + /** 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; } @@ -124,6 +131,10 @@ 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); + const accent = scopeFg(theme, "function") ?? scopeFg(theme, "ui.text.focus") ?? fg; return { name: theme.name, isLight, @@ -131,6 +142,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), @@ -139,10 +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, - foldBackground: scopeBg(theme, "ui.cursorline.primary") ?? mix(bg, fg, 0.1), + 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), }; } @@ -182,8 +196,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..5e34ca44a 100644 --- a/tui/packages/hunk/src/ui/App.tsx +++ b/tui/packages/hunk/src/ui/App.tsx @@ -11,10 +11,12 @@ 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"; import { CodeRowView } from "./diff/CodeRowView"; +import type { ScopeFocus } from "./diff/diffRowModel"; import { rowsForFile, type Layout, @@ -28,12 +30,12 @@ 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"; 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({ @@ -54,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; @@ -100,7 +102,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 +123,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; @@ -158,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 = @@ -189,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)); @@ -296,21 +307,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 = []; @@ -361,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 5fe9f0ddb..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); @@ -83,8 +100,29 @@ 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; + // 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)} > @@ -103,7 +149,7 @@ export const CodeRowView = memo(function CodeRowView({ { if (fold && !visualLine) event.stopPropagation(); @@ -111,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); } }} > @@ -125,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