diff --git a/.github/workflows/diffr-release.yml b/.github/workflows/diffr-release.yml index 53f9f5820..f609c291f 100644 --- a/.github/workflows/diffr-release.yml +++ b/.github/workflows/diffr-release.yml @@ -45,6 +45,10 @@ jobs: - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: bun-version: 1.3.14 + - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6 + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version: 24 - uses: dtolnay/rust-toolchain@stable - name: Build and package env: diff --git a/.github/workflows/diffr-test.yml b/.github/workflows/diffr-test.yml index 2c30e807d..9630e69e8 100644 --- a/.github/workflows/diffr-test.yml +++ b/.github/workflows/diffr-test.yml @@ -271,6 +271,33 @@ jobs: pnpm build pnpm test + typescript_packages: + name: TypeScript frontends + permissions: + contents: read + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6 + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version: 24 + cache: pnpm + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: 1.3.14 + - run: pnpm install --frozen-lockfile + - working-directory: diffr/packages/viewer + run: | + pnpm typecheck + pnpm test + - working-directory: diffr/packages/tui + run: | + pnpm typecheck + pnpm test + whiteboard_integration: name: Whiteboard integration with diffr runs-on: ubuntu-22.04 diff --git a/.github/workflows/review-desktop-ci.yml b/.github/workflows/review-desktop-ci.yml index fa36304ae..d98c04268 100644 --- a/.github/workflows/review-desktop-ci.yml +++ b/.github/workflows/review-desktop-ci.yml @@ -151,15 +151,16 @@ jobs: run: pnpm --filter @dev.fast/whiteboard check:tutorial # The build already built workspace dependencies and typechecked Code OSS. + # Diffr Test checks diffr's packages, with the Bun and Claude Code types they need. - name: Typecheck run: | - pnpm -r --filter '!@dev.fast/review-desktop' --workspace-concurrency=1 --if-present typecheck + pnpm -r --filter '!@dev.fast/review-desktop' --filter '!./diffr/**' --workspace-concurrency=1 --if-present typecheck # Typecheck prepared the shared tutorial assets before concurrent pretests. - name: Test run: | node --test "scripts/**/*.test.mjs" - pnpm -r --filter '!@dev.fast/diffr' --no-sort --workspace-concurrency=3 --if-present test + pnpm -r --filter '!./diffr/**' --no-sort --workspace-concurrency=3 --if-present test - name: Upload browser test diagnostics if: failure() diff --git a/diffr/NOTICE b/diffr/NOTICE index 94d41b2e5..914d378ee 100644 --- a/diffr/NOTICE +++ b/diffr/NOTICE @@ -5,14 +5,14 @@ difftastic Copyright (c) 2021-2025 Wilfred Hughes. MIT License (see LICENSE). diffr forked difftastic at commit 274d0a8f5 (2026-09-08). -hunk (terminal frontend under tui/) +hunk (terminal frontend under packages/tui/ and packages/viewer/) https://github.com/modem-dev/hunk - Copyright (c) Modem Labs Inc. MIT License (see tui/LICENSE). + Copyright (c) Modem Labs Inc. MIT License (see packages/tui/LICENSE). Import details in TUI_UPSTREAM.md. -Helix editor themes (tui/themes/) +Helix editor themes (packages/viewer/themes/) https://github.com/helix-editor/helix/tree/master/runtime/themes - Mozilla Public License 2.0 (see tui/themes/LICENSE). + Mozilla Public License 2.0 (see packages/viewer/themes/LICENSE). GitHub Linguist language data (plugins/classify/rust/src/linguist/) https://github.com/github-linguist/linguist diff --git a/diffr/README.md b/diffr/README.md index 54452f058..27c62f9bf 100644 --- a/diffr/README.md +++ b/diffr/README.md @@ -45,7 +45,7 @@ cargo install diffr-cli --locked Windows x64: download `diffr--.tar.gz` from [Whiteboard Releases](https://github.com/devdotfast/whiteboard/releases) and extract it with `tar -xzf`, or use `cargo binstall`. -From a Whiteboard checkout: `cd diffr && cargo xtask install` (requires Rust and [Bun](https://bun.sh)). +TODO(smenon):UPDATEME! ## History diff --git a/diffr/TUI_UPSTREAM.md b/diffr/TUI_UPSTREAM.md index 210f78e52..9caad90ff 100644 --- a/diffr/TUI_UPSTREAM.md +++ b/diffr/TUI_UPSTREAM.md @@ -1,7 +1,7 @@ # Terminal frontend provenance Imported unchanged from https://github.com/modem-dev/hunk at f86a04ed0325e641d7d96067992f14849016ec8b. -Upstream MIT license is retained in tui/LICENSE. +Upstream MIT license is retained in packages/tui/LICENSE. Import and subsequent refactoring performed with AI assistance. Additional restored primitives: `ui/lib/sidebar.ts` is copied unchanged from Hunk; diff --git a/diffr/diffr-ts/README.md b/diffr/diffr-ts/README.md index 4fe41c8e5..4eded9529 100644 --- a/diffr/diffr-ts/README.md +++ b/diffr/diffr-ts/README.md @@ -21,7 +21,7 @@ Keep these files in sync: |---|---| | `crates/diffr-core/src/protocol/mod.rs`, `crates/diffr-core/src/pairing.rs`, `diffr-ts/src/contract.ts` | Contract tests against the binary; Rust version check | | `docs/streaming.md` | Review against the wire format | -| `tui/packages/hunk/src/diffr/wire.ts` (v3 only) | TUI fixture tests | +| `packages/viewer/src/protocol/wire.ts` (v3 only) | TUI fixture tests | | `../packages/review-protocol/package.json` and `../packages/review/package.json` | Exact package version pins | ## Release diff --git a/diffr/diffr-ts/src/contract.test.ts b/diffr/diffr-ts/src/contract.test.ts index 88e68061a..0eadf9f8d 100644 --- a/diffr/diffr-ts/src/contract.test.ts +++ b/diffr/diffr-ts/src/contract.test.ts @@ -88,7 +88,7 @@ describe("every record the binary writes validates", () => { }); test("the terminal UI's committed v3 fixture still validates", () => { - const text = readFileSync(join(repositoryRoot, "tui/test/fixtures/comparison.ndjson"), "utf8"); + const text = readFileSync(join(repositoryRoot, "packages/tui/test/fixtures/comparison.ndjson"), "utf8"); const events = text.split("\n").filter((line) => line.length > 0).map(decodeStructuralDiffEvent); expect(events[0]).toMatchObject({ type: "start", version: STRUCTURAL_DIFF_WIRE_VERSION }); expect(events.at(-1)).toMatchObject({ type: "complete", failed: 0 }); diff --git a/diffr/tui/.gitignore b/diffr/packages/tui/.gitignore similarity index 100% rename from diffr/tui/.gitignore rename to diffr/packages/tui/.gitignore diff --git a/diffr/tui/CLAUDE.md b/diffr/packages/tui/CLAUDE.md similarity index 100% rename from diffr/tui/CLAUDE.md rename to diffr/packages/tui/CLAUDE.md diff --git a/diffr/tui/LICENSE b/diffr/packages/tui/LICENSE similarity index 100% rename from diffr/tui/LICENSE rename to diffr/packages/tui/LICENSE diff --git a/diffr/tui/package.json b/diffr/packages/tui/package.json similarity index 65% rename from diffr/tui/package.json rename to diffr/packages/tui/package.json index 68908f639..3c8254e57 100644 --- a/diffr/tui/package.json +++ b/diffr/packages/tui/package.json @@ -4,17 +4,18 @@ "license": "MIT", "type": "module", "scripts": { - "start": "bun run packages/hunk/src/main.tsx", + "start": "bun run src/main.tsx", "typecheck": "tsc --noEmit", - "test": "bun test packages" + "test": "bun test src" }, "dependencies": { + "@diffr/viewer": "workspace:*", "@opentui/core": "0.5.6", "@opentui/react": "0.5.6", "react": "19.2.0", - "get-east-asian-width": "1.4.0", - "string-width": "8.1.0", - "zod": "4.1.12" + "react-devtools-core": "7.0.1", + "web-tree-sitter": "0.25.10", + "ws": "8.21.3" }, "devDependencies": { "@types/bun": "1.3.14", diff --git a/diffr/tui/packages/hunk/src/diffr/config.test.ts b/diffr/packages/tui/src/diffr/config.test.ts similarity index 100% rename from diffr/tui/packages/hunk/src/diffr/config.test.ts rename to diffr/packages/tui/src/diffr/config.test.ts diff --git a/diffr/tui/packages/hunk/src/diffr/config.ts b/diffr/packages/tui/src/diffr/config.ts similarity index 100% rename from diffr/tui/packages/hunk/src/diffr/config.ts rename to diffr/packages/tui/src/diffr/config.ts diff --git a/diffr/tui/packages/hunk/src/diffr/stream.test.ts b/diffr/packages/tui/src/diffr/stream.test.ts similarity index 95% rename from diffr/tui/packages/hunk/src/diffr/stream.test.ts rename to diffr/packages/tui/src/diffr/stream.test.ts index 997c82bde..149072445 100644 --- a/diffr/tui/packages/hunk/src/diffr/stream.test.ts +++ b/diffr/packages/tui/src/diffr/stream.test.ts @@ -1,7 +1,7 @@ import { expect, test } from "bun:test"; import { readDiffStream } from "./stream"; -import { createTestDiffFile, fold, leaf, root } from "./fixture"; -import { fileVisibility, type FileChange } from "./wire"; +import { createTestDiffFile, fold, leaf, root } from "@diffr/viewer/protocol/fixture"; +import { fileVisibility, type FileChange } from "@diffr/viewer/protocol/wire"; const manifest = (file: ReturnType): FileChange => ({ file: file.file, status: "modified", tags: [] }); const start = { @@ -86,7 +86,7 @@ test("results arrive in any order", async () => { test("a stream recorded from diffr parses, with manifest tags and each record's visibility", async () => { // Recorded with `diffr --format ndjson --syntax --jobs 1` from a repository with // one source file and one test file; the test file is hidden by the hide-files plugin. - const bytes = await Bun.file(new URL("../../../../test/fixtures/comparison.ndjson", import.meta.url)).bytes(); + const bytes = await Bun.file(new URL("../../test/fixtures/comparison.ndjson", import.meta.url)).bytes(); async function* chunks() { yield bytes; } diff --git a/diffr/packages/tui/src/diffr/stream.ts b/diffr/packages/tui/src/diffr/stream.ts new file mode 100644 index 000000000..bb20cdfcf --- /dev/null +++ b/diffr/packages/tui/src/diffr/stream.ts @@ -0,0 +1,12 @@ +import { parseDiffEvents } from "@diffr/viewer/protocol/events"; +import type { DiffEvent } from "@diffr/viewer/protocol/wire"; +export async function* readDiffStream( + chunks: AsyncIterable, +): AsyncGenerator { + const decoder = new TextDecoder("utf-8", { fatal: true }); + async function* texts() { + for await (const chunk of chunks) yield decoder.decode(chunk, { stream: true }); + yield decoder.decode(); + } + yield* parseDiffEvents(texts()); +} diff --git a/diffr/packages/tui/src/diffr/theme.test.ts b/diffr/packages/tui/src/diffr/theme.test.ts new file mode 100644 index 000000000..eace78b87 --- /dev/null +++ b/diffr/packages/tui/src/diffr/theme.test.ts @@ -0,0 +1,13 @@ +import { expect, test } from "bun:test"; +import { themeConfig } from "@diffr/viewer/theme/palette"; +import { themesFromConfig } from "./theme"; +test("the theme set follows diffr's config and errors on a missing section", () => { + expect(themeConfig({ theme: { name: "gruvbox", path: null } })).toEqual({ name: "gruvbox", path: null }); + expect(() => themeConfig({ folds: {} })).toThrow("theme section"); + const set = themesFromConfig({ name: "gruvbox", path: null }); + expect(set.initial.name).toBe("gruvbox"); + expect(set.dark.name).toBe("default-dark"); + expect(set.light.name).toBe("default-light"); + expect(() => themesFromConfig({ name: "nope", path: null })).toThrow("Unknown theme"); + expect(() => themesFromConfig({ name: "default-dark", path: "/nonexistent/theme.toml" })).toThrow(); +}); diff --git a/diffr/packages/tui/src/diffr/theme.ts b/diffr/packages/tui/src/diffr/theme.ts new file mode 100644 index 000000000..6fedc5932 --- /dev/null +++ b/diffr/packages/tui/src/diffr/theme.ts @@ -0,0 +1,17 @@ +import { readFileSync } from "node:fs"; +import { paletteFromHelix, type Palette } from "@diffr/viewer/theme/palette"; +import { loadBundledTheme, parseHelixTheme } from "@diffr/viewer/theme/themes"; +export function loadThemeFile(path: string): Palette { + return paletteFromHelix(parseHelixTheme(readFileSync(path, "utf8"), path)); +} +export interface ThemeSet { + initial: Palette; + dark: Palette; + light: Palette; +} +/** Resolve the theme diffr's config names, plus the two defaults the `t` key toggles between. */ +export function themesFromConfig(config: { name: string; path: string | null }): ThemeSet { + const dark = loadBundledTheme("default-dark"), light = loadBundledTheme("default-light"); + const initial = config.path ? loadThemeFile(config.path) : loadBundledTheme(config.name); + return { initial, dark, light }; +} diff --git a/diffr/tui/packages/hunk/src/main.tsx b/diffr/packages/tui/src/main.tsx similarity index 95% rename from diffr/tui/packages/hunk/src/main.tsx rename to diffr/packages/tui/src/main.tsx index db6f933b8..420fe1153 100644 --- a/diffr/tui/packages/hunk/src/main.tsx +++ b/diffr/packages/tui/src/main.tsx @@ -6,9 +6,11 @@ import { spawn } from "node:child_process"; import { createCliRenderer } from "@opentui/core"; import { createRoot } from "@opentui/react"; import { readDiffStream } from "./diffr/stream"; -import { DiffStore } from "./diffr/store"; +import { DiffStore } from "@diffr/viewer/protocol/store"; import { cliClient, flattenSchema, type Setting } from "./diffr/config"; -import { loadBundledTheme, loadThemeFile, themeConfig, themesFromConfig, type ThemeSet } from "./diffr/theme"; +import { loadThemeFile, themesFromConfig, type ThemeSet } from "./diffr/theme"; +import { loadBundledTheme } from "@diffr/viewer/theme/themes"; +import { themeConfig } from "@diffr/viewer/theme/palette"; import { App } from "./ui/App"; import { Settings } from "./ui/Settings"; const args = process.argv.slice(2), diff --git a/diffr/tui/packages/hunk/src/shutdown.test.ts b/diffr/packages/tui/src/shutdown.test.ts similarity index 100% rename from diffr/tui/packages/hunk/src/shutdown.test.ts rename to diffr/packages/tui/src/shutdown.test.ts diff --git a/diffr/tui/packages/hunk/src/shutdown.ts b/diffr/packages/tui/src/shutdown.ts similarity index 100% rename from diffr/tui/packages/hunk/src/shutdown.ts rename to diffr/packages/tui/src/shutdown.ts diff --git a/diffr/tui/packages/hunk/src/ui/App.test.tsx b/diffr/packages/tui/src/ui/App.test.tsx similarity index 98% rename from diffr/tui/packages/hunk/src/ui/App.test.tsx rename to diffr/packages/tui/src/ui/App.test.tsx index 792a4ea49..7f85379bd 100644 --- a/diffr/tui/packages/hunk/src/ui/App.test.tsx +++ b/diffr/packages/tui/src/ui/App.test.tsx @@ -4,11 +4,10 @@ import { act, Profiler } from "react"; import { TextRenderable, type BaseRenderable } from "@opentui/core"; import { App } from "./App"; -import { DiffStore } from "../diffr/store"; -import { createTestDiffFile, leaf, line, manifestEntry, startFor, withIdenticalLines, root } from "../diffr/fixture"; -import type { DiffEvent, DiffFile } from "../diffr/wire"; -import { createBundledDiffFile, createFoldedDiffFile } from "../diffr/regions.test"; -import { loadBundledTheme } from "../diffr/theme"; +import { DiffStore } from "@diffr/viewer/protocol/store"; +import { createBundledDiffFile, createFoldedDiffFile, createTestDiffFile, leaf, line, manifestEntry, startFor, withIdenticalLines, root } from "@diffr/viewer/protocol/fixture"; +import type { DiffEvent, DiffFile } from "@diffr/viewer/protocol/wire"; +import { loadBundledTheme } from "@diffr/viewer/theme/themes"; const themes = { initial: loadBundledTheme("default-dark"), dark: loadBundledTheme("default-dark"), light: loadBundledTheme("default-light") }; // Wait for the stream batch to be published inside React's act boundary. async function accept(store: DiffStore, ...events: DiffEvent[]) { diff --git a/diffr/tui/packages/hunk/src/ui/App.tsx b/diffr/packages/tui/src/ui/App.tsx similarity index 51% rename from diffr/tui/packages/hunk/src/ui/App.tsx rename to diffr/packages/tui/src/ui/App.tsx index 5e34ca44a..ca578e8c3 100644 --- a/diffr/tui/packages/hunk/src/ui/App.tsx +++ b/diffr/packages/tui/src/ui/App.tsx @@ -11,33 +11,44 @@ import { useRenderer, useTerminalDimensions, } from "@opentui/react"; -import { TextAttributes } from "@opentui/core"; -import { buildFileTree, flattenFileTree, parentDirectories, lineCounts } from "../diffr/fileTree"; +import { TextAttributes, type KeyEvent } from "@opentui/core"; +import { buildFileTree, flattenFileTree, lineCounts, parentDirectories } from "@diffr/viewer/document/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, - type ViewerRow, -} from "../diffr/rows"; -import type { Palette, ThemeSet } from "../diffr/theme"; -import { measureRows, visibleRows, positionAt, positionTop, rowFold, type ViewPosition } from "../diffr/geometry"; +import { CodeRowView, styled } from "./diff/CodeRowView"; +import type { ThemeSet } from "../diffr/theme"; import { + agentReference, copySelection, - selectionBounds, + selectedRanges, + selectionLead, + selectionCover, type SourceSelection, -} from "../diffr/selection"; -import { filePath, fileVisibility, type DiffFile, type TextDiff } from "../diffr/wire"; -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 { measureTextWidth, sliceTextByWidth } from "./lib/text"; +} from "@diffr/viewer/document/selection"; +import { filePath } from "@diffr/viewer/protocol/wire"; +import { add, blockBar, comparisonLabel, zero, type LineCounts } from "@diffr/viewer/document/counts"; +import type { DiffStore } from "@diffr/viewer/protocol/store"; +import { visibleRows } from "@diffr/viewer/viewport/geometry"; +import { sanitizeTerminalLine } from "@diffr/viewer/terminal/sanitize"; +import { measureTextWidth, sliceTextByWidth } from "@diffr/viewer/terminal/text"; +import { Viewer, type KeyPress } from "@diffr/viewer/viewer"; +import { litRuns, viewedBox, viewedHint } from "@diffr/viewer/viewport/cell"; +import { pickerLines } from "@diffr/viewer/viewport/picker"; +/** Pinned to the status line's right end, so cut hints never cut the way to the key list. */ +const KEYS_BUTTON = " ? keys "; +/** On the selection bar, which takes the status line while lines are selected. */ +const AGENT_BUTTON = " Y Copy for agent "; const fit = (text: string, width: number) => sliceTextByWidth(text, 0, width).text; +/** Kitty reports the base key; use the typed sequence for printables. */ +const keyPress = (key: KeyEvent): KeyPress => { + const modified = key.ctrl || key.meta || key.super === true; + const typed = !modified && key.sequence !== " " && /^[^\x00-\x1f\x7f]$/u.test(key.sequence) ? key.sequence : undefined; + return { + key: typed ?? (key.shift && /^[a-z]$/.test(key.name) ? key.name.toUpperCase() : key.name), + ctrl: key.ctrl, shift: key.shift, meta: key.meta || key.super === true, + }; +}; export function App({ store, onQuit, @@ -47,31 +58,13 @@ export function App({ onQuit: () => void; themes: ThemeSet; }) { - const snapshot = useSyncExternalStore(store.subscribe, store.getSnapshot); + const viewer = useMemo(() => new Viewer(store, themes.initial, 100), [store, themes]); + useSyncExternalStore(viewer.subscribe, viewer.getVersion); const renderer = useRenderer(), { width, height } = useTerminalDimensions(); - const [mode, setMode] = useState("auto"), - [showSidebar, setShowSidebar] = useState(true), - [wrap, setWrap] = useState(false), - [theme, setTheme] = useState(themes.initial); - const [position, setPosition] = useState(null); - const [horizontal, setHorizontal] = useState(0); - const [hoveredFold, setHoveredFold] = useState<{file: number; focus: ScopeFocus} | null>(null); - const [spinner, setSpinner] = useState(0); - useEffect(() => { - if (snapshot.complete) return; - const timer = setInterval(() => setSpinner(n => (n + 1) % 10), 80); - return () => clearInterval(timer); - }, [snapshot.complete]); - const loadingGlyph = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"[spinner]; - // Files the user closed or opened; unset files follow the visibility on their file record. - const [closed, setClosed] = useState>(new Map()); + const [showSidebar, setShowSidebar] = useState(true); const [selection, setSelection] = useState(null), [message, setMessage] = useState(""); - // Fold ids collapsed per manifest file; unset files start where diffr's visibility says. - const [collapsed, setCollapsed] = useState>>(new Map()); - // Vim's z prefix: the next key names the fold command. - const pendingZ = useRef(false); const [closedDirectories, setClosedDirectories] = useState>(new Set()); const [treeScroll, setTreeScroll] = useState(0); const [sidebarWidth, setSidebarWidth] = useState(28); @@ -80,141 +73,30 @@ export function App({ const [showBreakdown, setShowBreakdown] = useState(false); const dragging = useRef(false), thumbDragging = useRef(false); - // `t` swaps between the two bundled defaults; a configured theme is left by the first press. - const toggleTheme = () => setTheme((current) => (current.isLight ? themes.dark : themes.light)); const sidebar = showSidebar && width >= 60 ? Math.max(16, Math.min(sidebarWidth, width - 40)) : 0; const contentWidth = Math.max(10, width - sidebar - 1), viewportHeight = Math.max(1, height - 3); - const layout = - mode === "auto" ? (contentWidth >= 100 ? "split" : "unified") : mode; - // Viewer state is keyed by manifest index; `files[i]` is undefined until file i arrives. + const { snapshot, theme, wrap, hover: hovered, layout, rows, geometry, top, maxScroll, viewport, currentFile, sticky, thumb } = + viewer.lay({ columns: contentWidth, rows: viewportHeight }); + const [spinner, setSpinner] = useState(0); + useEffect(() => { + if (snapshot.complete) return; + const timer = setInterval(() => setSpinner(n => (n + 1) % 10), 80); + return () => clearInterval(timer); + }, [snapshot.complete]); + const loadingGlyph = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"[spinner]; const { inventory, files, failures } = snapshot; - const textDiffs = useMemo(() => files.flatMap((file, index) => - file?.diff.type === "text" ? [{ index, diff: file.diff }] : []), [files]); - const foldsOf = (index: number, diff: TextDiff): ReadonlySet => collapsed.get(index) ?? defaultCollapsed(diff); - const isClosed = (index: number, file: DiffFile) => closed.get(index) ?? fileVisibility(file).collapsed; // The file's mark in the tree and on its header while it has no diff. const statusGlyph = (index: number) => failures[index] || snapshot.complete ? "!" : loadingGlyph; + const progress = viewer.viewedFiles(); + // While typing, the status line is the prompt; after a search, it leads with where the search stands. + const found = viewer.searchState(); + const searched = !found || "prompt" in found ? "" + : `/${found.pattern} · ${found.total ? `match ${found.at} of ${found.total} in ${found.files} files · n/N` : "no matches"} · `; const tree = useMemo(() => buildFileTree(inventory), [inventory]); const fileOrder = useMemo(() => flattenFileTree(tree, new Set()).flatMap(({node}) => node.fileIndex === undefined ? [] : [node.fileIndex]), [tree]); - const rowCache = useRef( - new WeakMap(), - ); - const rows = useMemo(() => { - const perFile = fileOrder.map((index): ViewerRow[] => { - const file = files[index]; - if (!file) { - const failure = failures[index]; - const status = failure ?? (snapshot.complete ? "File did not load" : "Computing diff…"); - return [{ key: `${index}:header`, fileIndex: index, label: filePath(inventory[index].file) }, - ...["", status, "", ""].map((label, line) => ({ key: `${index}:pending:${line}`, fileIndex: index, - label, pending: line === 1 && !failure && !snapshot.complete }))]; - } - const folds = file.diff.type === "text" ? foldsOf(index, file.diff) : new Set(); - const key = `${index}:${layout}:${theme.name}:${[...folds].sort((a, b) => a - b).join(",")}`; - let cached = rowCache.current.get(file); - if (cached?.key !== key) { - cached = { key, rows: rowsForFile(file, index, layout, theme, folds) }; - rowCache.current.set(file, cached); - } - if (!isClosed(index, file)) return cached.rows; - return fileVisibility(file).collapsed - ? [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; - }, [files, failures, snapshot.errors, snapshot.complete, inventory, layout, theme, closed, fileOrder, collapsed]); - const maxLine = useMemo(() => Math.max(1, ...textDiffs.flatMap(({ diff }) => - [diff.lhs, diff.rhs].map(source => source ? sourceLines(source.text).length : 0))), [textDiffs]); - const geometry = useMemo( - () => measureRows(rows, contentWidth, wrap, horizontal, maxLine), - [rows, contentWidth, wrap, horizontal, maxLine], - ); - const lastFileTop = useMemo(() => - geometry.rows.findLast(r => r.row.key.endsWith(":header"))?.top ?? 0, [geometry]); - // One past the end, since the sticky file header takes the viewport's first row. - const maxScroll = Math.max(lastFileTop, geometry.height - viewportHeight + 1); - const clamp = (value: number) => Math.max(0, Math.min(maxScroll, value)); - const top = Math.min(positionTop(geometry, position), maxScroll); - const scrollTo = (value: number) => setPosition(positionAt(geometry, clamp(value))); - // Relative to the latest position, so key repeats within one frame add up. - const move = (amount: number) => - setPosition(current => positionAt(geometry, clamp(Math.min(positionTop(geometry, current), maxScroll) + amount))); - const toggleFile = (index: number) => { - const file = files[index]; - if (!file) return; - // Toggling the file being read moves to its header; the rows above it stay put. - const header = geometry.rows.find(r => r.row.key === `${index}:header`)!; - if (header.top < top) setPosition({ key: header.row.key, fileIndex: index, offset: 0, side: "right" }); - setClosed(old => new Map(old).set(index, !(old.get(index) ?? fileVisibility(file).collapsed))); - }; - // "toggle" reads the current state inside the update, so quick repeated clicks alternate. - const setFolds = (fileIndex: number, diff: TextDiff, ids: number[], collapse: boolean | "toggle") => - setCollapsed((old) => { - const next = new Set(old.get(fileIndex) ?? defaultCollapsed(diff)); - const close = collapse === "toggle" ? !next.has(ids[0]) : collapse; - for (const id of ids) if (close) next.add(id); else next.delete(id); - return new Map(old).set(fileIndex, next); - }); - // Recursive commands (Alt-click, zC, zO, zA) include every fold nested inside. - 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 ? [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, id: number, recursive: boolean) => - setFold(fileIndex, id, "toggle", recursive); - const navigateFold = (direction: number) => { - const headers = geometry.rows.filter((r) => rowFold(r.row)); - const target = - direction > 0 - ? headers.find((r) => r.top > top) - : headers.findLast((r) => r.top < top); - if (target) scrollTo(target.top); - }; - // Vim fold commands act on the fold whose header is the top visible row. - const foldCommand = (command: string) => { - if (command === "R") return foldAll(false); - if (command === "M") return foldAll(true); - if (command === "j") return navigateFold(1); - if (command === "k") return navigateFold(-1); - const current = visibleRows(geometry, top, 1)[0]?.row; - const fold = current && rowFold(current); - if (!current || !fold) return; - const recursive = command === command.toUpperCase(); - const letter = command.toLowerCase(); - 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)); - const jump = (index: number) => scrollTo(geometry.rows.find((r) => r.row.fileIndex === index)!.top); - const navigateHunk = (direction: number) => { - const headers = geometry.rows.filter((r) => r.row.hunkStart); - const target = - direction > 0 - ? headers.find((r) => r.top > top) - : headers.findLast((r) => r.top < top); - if (target) scrollTo(target.top); - }; + const toggleLayout = () => { viewer.toggleLayout(); setSelection(null); }; const copy = () => { if (!selection) return; const text = copySelection(files, rows, selection); @@ -223,59 +105,40 @@ export function App({ setMessage("Copied source lines"); } }; + const selected = useMemo(() => (selection ? selectedRanges(files, rows, selection) : []), [files, rows, selection]); + const copyForAgent = () => { + if (!selection) return; + if (!snapshot.comparison) throw new Error("A selection exists before diffr named the comparison"); + const text = agentReference(files, snapshot.comparison, rows, selection); + if (text) { + renderer.copyToClipboardOSC52(text); + setMessage("Copied for agent"); + } + }; useKeyboard((key) => { - // Hunk's chord matcher handles raw control bytes and Kitty events alike. - const is = (...chords: string[]) => !key.super && chords.some(chord => matchesKey(chord, key)); - if (pendingZ.current) { - pendingZ.current = false; - const command = key.shift ? key.name?.toUpperCase() : key.name; - if (!key.ctrl && !key.meta && command?.length === 1 && "aocAOCRMjk".includes(command)) - foldCommand(command); + // Viewer first, so a pending z chord takes any key. + const press = keyPress(key), chord = viewer.chording, typing = viewer.prompting; + if (viewer.press(press)) { + // s and c reshape the rows, so drop the selection, unless they finish a z chord or go into the prompt. + if (!chord && !typing && !press.ctrl && !press.meta && (press.key === "s" || press.key === "c")) setSelection(null); return; } + // Hunk's chord matcher handles raw control bytes and Kitty events alike. + const is = (...chords: string[]) => !key.super && chords.some(chord => matchesKey(chord, key)); if (is("q", "ctrl+c")) onQuit(); else if ((key.name === "b" && (key.super || key.meta)) || is("\\")) { key.preventDefault(); setShowSidebar(v => !v); } - else if (is("d", "ctrl+d", "u", "ctrl+u")) - move((is("d", "ctrl+d") ? 1 : -1) * Math.max(1, Math.floor(viewportHeight / 2))); - else if (is("pagedown", "space", "f", "ctrl+f")) move(viewportHeight); - else if (is("pageup", "b", "shift+space", "ctrl+b")) move(-viewportHeight); - else if (is("down", "j")) move(1); - else if (is("up", "k")) move(-1); - else if (is("g")) scrollTo(0); - else if (is("home")) scrollTo(0); - else if (is("G", "end")) scrollTo(maxScroll); - else if (is("right", "shift+right", "l")) setHorizontal(n => n + (key.shift ? 16 : 4)); - else if (is("left", "shift+left", "h")) setHorizontal(n => Math.max(0, n - (key.shift ? 16 : 4))); - else if (key.name === "]") navigateHunk(1); - else if (key.name === "[") navigateHunk(-1); - else if (key.name === "s") { - setMode(layout === "split" ? "unified" : "split"); - setSelection(null); - } else if (key.name === "w") setWrap((v) => !v); - else if (key.name === "c") { toggleContext(); setSelection(null); } - else if (key.name === "t") toggleTheme(); - else if (key.name === "y") copy(); + else if (key.name === "y") { if (key.shift) copyForAgent(); else copy(); } else if (key.name === "escape") { setSelection(null); setMenu(null); setShowBreakdown(false); } else if (key.name === "i") setShowBreakdown((v) => !v); - else if (key.name === "return") { - const current = visibleRows(geometry, top, 1)[0]; - if (current && current.row.fileIndex >= 0) - toggleFile(current.row.fileIndex); - } else if (is("z")) pendingZ.current = true; + else if (press.key === "?") setMenu((m) => (m === "Help" ? null : "Help")); }); - const [selectionStart, selectionEnd] = useMemo( - () => selectionBounds(rows, selection), - [rows, selection], - ); + const cover = useMemo(() => selectionCover(rows, selection), [rows, selection]); const indices = useMemo( () => new Map(rows.map((row, i) => [row.key, i])), [rows], ); - const viewport = visibleRows(geometry, top, viewportHeight); - const currentFile = viewport[0]?.row.fileIndex ?? 0; - const sticky = !!viewport.length && !viewport[0].row.key.endsWith(":header"); // Shortcuts past a file that is still loading. const place = fileOrder.indexOf(currentFile); const previousLoaded = fileOrder.slice(0, place).findLast(index => files[index]); @@ -313,23 +176,41 @@ export function App({ const file = files[fileIndex], count = counts[fileIndex]?.visible; 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 state = loaded ? viewer.isViewed(fileIndex) : undefined, viewed = state === true; + // The counts, then the viewed box. A viewed file is read, so its counts go. + const tally = loaded && !viewed ? [` +${count.added}`, ` −${count.removed}`] : []; + const box = state === undefined ? "" : ` ${viewedBox(state)}`; + // While the pointer is on the box, say what a click does beside it. + const hint = state !== undefined && hovered && "header" in hovered && hovered.file === fileIndex ? ` ${viewedHint(state)}` : ""; + const statsWidth = loaded ? measureTextWidth(tally.join("") + hint + box) + 1 : 0; const pathWidth = Math.max(1, contentWidth - statsWidth - 1); - const glyph = loaded ? (isClosed(fileIndex, file) ? "▸" : "▾") : statusGlyph(fileIndex); + const glyph = loaded ? (viewer.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)); + const lit = viewer.headerLit(fileIndex); return { if (loaded) toggleFile(fileIndex); }}> - ▌ - {directory} - {name} - {loaded && <> - {` +${count.added}`} - {` −${count.removed} `} + onMouseUp={() => { if (loaded) viewer.toggleFile(fileIndex); }}> + ▌ + + + {tally.length > 0 && <> + {tally[0]} + {tally[1]} + } + {hint && <> + + {hint.slice(1)} } + {state !== undefined && viewer.setHover({ file: fileIndex, header: true })} + onMouseOut={() => viewer.setHover(null)} + onMouseUp={(event) => { event.stopPropagation(); viewer.toggleViewedFile(fileIndex); }}>{box}} + {loaded && } ; }; const rendered = []; @@ -353,7 +234,7 @@ export function App({ fg={row.loadDiff ? theme.accent : theme.muted} selectable={false} onMouseUp={() => { - if (row.loadDiff) toggleFile(row.fileIndex); + if (row.loadDiff) viewer.toggleFile(row.fileIndex); }} > {fit(sanitizeTerminalLine(row.pending ? ` ${loadingGlyph} ${row.label}` : row.loadDiff ? ` ${row.label}` : row.label), contentWidth)} @@ -367,23 +248,20 @@ export function App({ visualLine={line} geometry={geometry} theme={theme} - selectedSide={ - selection && index >= selectionStart && index <= selectionEnd - ? selection.side - : undefined - } + selectedLeft={cover(index)?.left === true} + selectedRight={cover(index)?.right === true} onSelect={(side) => { dragging.current = true; - setSelection({ anchor: row.key, end: row.key, side }); + setSelection({ anchor: row.key, anchorSide: side, end: row.key, endSide: side }); }} - onExtend={() => { + onExtend={(side) => { if (dragging.current) - setSelection((s) => (s ? { ...s, end: row.key } : s)); + setSelection((s) => (s ? { ...s, end: row.key, endSide: side } : s)); }} - 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)} + focus={hovered?.file === row.fileIndex && "id" in hovered ? hovered : undefined} + onHover={focus => viewer.setHover(focus ? { file: row.fileIndex, ...focus } : null)} + onFold={(id, recursive) => viewer.setFold(row.fileIndex, id, "toggle", recursive)} + read={viewer.isViewed(row.fileIndex) === true} />, ); } @@ -392,39 +270,25 @@ export function App({ if (currentFile >= 0 && sticky) { rendered.unshift(fileHeader(currentFile, "sticky-header")); } - const thumbHeight = Math.max( - 1, - Math.floor( - (viewportHeight * viewportHeight) / - Math.max(viewportHeight, geometry.height), - ), - ); - const thumbTop = maxScroll - ? Math.round((top / maxScroll) * (viewportHeight - thumbHeight)) - : 0; - function scrub(y: number) { - scrollTo( - Math.round( - Math.max(0, Math.min(1, (y - 2) / Math.max(1, viewportHeight - 1))) * - maxScroll, - ), - ); - } const menuItems: Record void][]> = { File: [["Toggle file tree ⌘B / \\", () => setShowSidebar(v => !v)], ["Copy selection y", copy], ["Quit q", onQuit]], - View: [[`Layout: ${layout} s`, () => { setMode(layout === "split" ? "unified" : "split"); setSelection(null); }], - [`Wrap: ${wrap ? "on" : "off"} w`, () => setWrap(v => !v)], - ["Toggle context gaps c", () => { toggleContext(); setSelection(null); }], - ["Fold all zM", () => foldAll(true)], ["Unfold all zR", () => foldAll(false)]], - Navigate: [["Previous change [", () => navigateHunk(-1)], ["Next change ]", () => navigateHunk(1)], - ["First file Home", () => scrollTo(0)], ["Last file End", () => scrollTo(maxScroll)]], - Theme: [[`Dark (${themes.dark.name}) t`, () => setTheme(themes.dark)], [`Light (${themes.light.name}) t`, () => setTheme(themes.light)]], + View: [[`Layout: ${layout} s`, toggleLayout], + [`Wrap: ${wrap ? "on" : "off"} w`, () => viewer.toggleWrap()], + ["Toggle context gaps c", () => { viewer.toggleContext(); setSelection(null); }], + ["Fold all zM", () => viewer.foldAll(true)], ["Unfold all zR", () => viewer.foldAll(false)]], + Navigate: [["Previous change [c", () => viewer.navigateHunk(-1)], ["Next change ]c", () => viewer.navigateHunk(1)], + ["First file Home", () => viewer.scrollTo(0)], ["Last file End", () => viewer.scrollTo(maxScroll)]], + Theme: [[`Dark (${themes.dark.name}) t`, () => viewer.setTheme(themes.dark)], [`Light (${themes.light.name}) t`, () => viewer.setTheme(themes.light)]], Help: [["Scroll: j/k · h/l · gg/G", () => setMessage("j/k scroll · h/l pan · gg first · G last")], ["Half page: Ctrl-D / Ctrl-U", () => setMessage("d / Ctrl-D: half down · u / Ctrl-U: half up")], ["Full page: Ctrl-F / Ctrl-B", () => setMessage("Ctrl-F: page down · Ctrl-B: page up")], ["Drag to select · y to copy", () => setMessage("Drag source lines; y copies original source")], ["Change breakdown i", () => setShowBreakdown(true)], - ["Folds: click ▾ · za zo zc · zM zR", () => setMessage("Click the chevron or ⋯ · za toggle, zo open, zc close the top fold (zA zO zC recursive) · zM/zR fold/unfold all · zj/zk next/previous fold")]], + ["Folds: click ▾ · za zo zc · zM zR", () => setMessage("Click the chevron or ⋯ · za toggle, zo open, zc close the top fold (zA zO zC recursive) · zM/zR fold/unfold all · zj/zk next/previous fold")], + ["Search: / · n N", () => setMessage("/ searches paths and code · n/N next/previous match")], + ["Go to a file Ctrl-P / ⌘P", () => viewer.press({ key: "p", ctrl: true })], + ["Mark the file viewed V", () => viewer.toggleViewedFile(currentFile)], + ["Copy for an agent Y", copyForAgent]], }; return ( ))} { - setMode(layout === "split" ? "unified" : "split"); setSelection(null); + toggleLayout(); }}>{` ${layout} [s] `} @@ -484,11 +348,9 @@ export function App({ const d = event.scroll; if (d) { if (d.direction === "left" || d.direction === "right") - setHorizontal((n) => - Math.max(0, n + (d.direction === "left" ? -4 : 4)), - ); + viewer.pan(d.direction === "left" ? -4 : 4); else - move((d.direction === "up" ? -1 : 1) * Math.max(1, d.delta) * 3); + viewer.move((d.direction === "up" ? -1 : 1) * Math.max(1, d.delta) * 3); } }} > @@ -503,12 +365,13 @@ export function App({ }}> {treeRows.slice(sidebarStart, sidebarStart + viewportHeight).map(({node, depth}) => ( { if (node.fileIndex !== undefined) { - setMessage(""); jump(node.fileIndex); + setMessage(""); viewer.jump(node.fileIndex); } else setClosedDirectories(old => { const next = new Set(old); @@ -518,7 +381,7 @@ export function App({ }}> {fit(sanitizeTerminalLine(" ".repeat(depth) + (node.fileIndex === undefined ? (closedDirectories.has(node.key) ? "▸ " : "▾ ") - : `▤ ${files[node.fileIndex] ? " " : statusGlyph(node.fileIndex)} `) + node.name), sidebar - 1)} + : `▤ ${files[node.fileIndex] ? (viewer.isViewed(node.fileIndex) ? "✓" : " ") : statusGlyph(node.fileIndex)} `) + node.name), sidebar - 1)} ))} @@ -543,8 +406,8 @@ export function App({ > {currentFile >= 0 && !files[currentFile] && !snapshot.complete && - {previousLoaded !== undefined && { event.stopPropagation(); jump(previousLoaded); }}> ↑ previous loaded } - {nextLoaded !== undefined && { event.stopPropagation(); jump(nextLoaded); }}> ↓ next loaded } + {previousLoaded !== undefined && { event.stopPropagation(); viewer.jump(previousLoaded); }}> ↑ previous loaded } + {nextLoaded !== undefined && { event.stopPropagation(); viewer.jump(nextLoaded); }}> ↓ next loaded } } {rendered.length ? ( rendered @@ -559,20 +422,20 @@ export function App({ height={viewportHeight} onMouseDown={(event) => { thumbDragging.current = true; - scrub(event.y); + viewer.scrub(event.y - 2); }} onMouseMove={(event) => { if (sidebarDrag.current) { setSidebarWidth(resizeSidebarWidth(sidebarDrag.current.width, sidebarDrag.current.x, event.x, 16, width - 40)); - } else if (thumbDragging.current) scrub(event.y); + } else if (thumbDragging.current) viewer.scrub(event.y - 2); }} > @@ -599,6 +462,16 @@ export function App({ {fit(" esc close", boxWidth)} ; })()} + {(() => { + const picker = viewer.pickerState(); + if (!picker) return null; + const drawn = pickerLines(picker, width, Math.min(10, viewportHeight), theme, + (index) => counts[index]?.visible, (index) => viewer.isViewed(index) === true, "esc close"); + return + {drawn.map(({ runs, pick }, i) => { if (pick !== undefined) viewer.pickFile(pick); }} />)} + ; + })()} {menu && @@ -609,12 +482,32 @@ export function App({ ))} } - - {fit( - `${snapshot.loaded}/${inventory.length} files ${snapshot.complete ? "" : "loading…"} ${snapshot.errors.length ? `${snapshot.errors.length} errors` : ""} [/] hunks · za fold · i breakdown · drag selects lines · y copy · q quit ${message}`, - width, - )} - + {found && "prompt" in found + ? {fit(`/${found.prompt}▏ · ${found.count} matches · enter go · esc cancel`, width)} + : selected.length ? (() => { + // The selection bar, bright so it catches the eye: what is selected, then the button that copies it for an agent. + const lead = fit(selectionLead(message, selected, width - measureTextWidth(AGENT_BUTTON)), + Math.max(0, width - measureTextWidth(AGENT_BUTTON))); + const room = width - measureTextWidth(lead) - measureTextWidth(AGENT_BUTTON); + const hints = " esc clear"; + return + {lead} + {AGENT_BUTTON} + {room > 0 && + {measureTextWidth(hints) > room ? `${fit(hints, room - 1)}…` : hints}} + ; + })() + : (() => { + // The hints give way first, cut with an ellipsis; ? keys stays at the right end and opens the Help menu. + const room = Math.max(1, width - measureTextWidth(KEYS_BUTTON)); + const text = `${message ? `${message} · ` : ""}${searched}${snapshot.loaded}/${inventory.length} files · ${progress.viewed}/${progress.total} viewed ${snapshot.complete ? "" : "loading…"} ${snapshot.errors.length ? `${snapshot.errors.length} errors` : ""} h/l pan · / search · ctrl-p files · ⌘B tree · V viewed · drag selects lines · y/Y copy · q quit`; + return + {measureTextWidth(text) > room ? `${fit(text, room - 1)}…` : text} + setMenu((m) => (m === "Help" ? null : "Help"))}>{KEYS_BUTTON} + ; + })()} ); } diff --git a/diffr/tui/packages/hunk/src/ui/Interaction.test.tsx b/diffr/packages/tui/src/ui/Interaction.test.tsx similarity index 53% rename from diffr/tui/packages/hunk/src/ui/Interaction.test.tsx rename to diffr/packages/tui/src/ui/Interaction.test.tsx index 391f71af1..4c9ae6548 100644 --- a/diffr/tui/packages/hunk/src/ui/Interaction.test.tsx +++ b/diffr/packages/tui/src/ui/Interaction.test.tsx @@ -1,12 +1,12 @@ -import {expect, test} from "bun:test"; +import {expect, spyOn, test} from "bun:test"; import {act} from "react"; import {rgbToHex} from "@opentui/core"; import {testRender} from "@opentui/react/test-utils"; import {App} from "./App"; -import {DiffStore} from "../diffr/store"; -import {createGuideDiffFile, createTestDiffFile, startFor, withIdenticalLines} from "../diffr/fixture"; -import {loadBundledTheme} from "../diffr/theme"; -import type {DiffEvent, DiffFile} from "../diffr/wire"; +import {DiffStore} from "@diffr/viewer/protocol/store"; +import {createGuideDiffFile, createTestDiffFile, startFor, withIdenticalLines} from "@diffr/viewer/protocol/fixture"; +import { loadBundledTheme } from "@diffr/viewer/theme/themes"; +import type {DiffEvent, DiffFile} from "@diffr/viewer/protocol/wire"; const dark = loadBundledTheme("default-dark"), light = loadBundledTheme("default-light"); const themes = {initial: dark, dark, light}; async function publish(store: DiffStore, ...events: DiffEvent[]) { @@ -96,6 +96,28 @@ 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("a closed scope's pseudocode stands beside its rail: pointing at it arms the scope, and a click opens it", async () => { + const store = new DiffStore(), file = createGuideDiffFile(); + if (file.diff.type !== "text") throw new Error(); + for (const source of [file.diff.lhs!, file.diff.rhs!]) + source.root.children[1]!.children[3]!.visibility = {collapsed: true, label: "call one\ncall two"}; + store.accept(startFor([file])); store.accept(file); store.accept({type:"complete", succeeded:1, failed:0}); + const t = await testRender( {}} />, {width:170, height:25}); + const lines = () => t.captureCharFrame().split("\n"); + try { + await act(async () => { await t.renderOnce(); }); + const header = lines().findIndex(l => l.includes("fn other() {")); + expect(lines()[header].slice(0, 40)).toContain("▸"); + const y = lines().findIndex(l => l.includes("call one")); + expect(lines()[y]).not.toContain("┃"); + await act(async () => { await t.mockMouse.moveTo(lines()[y].indexOf("call one") + 2, y); await t.renderOnce(); }); + await t.waitFor(() => lines()[y].includes("┃") && lines()[y + 1].includes("┃")); + expect(lines()[y]).toContain("┃"); + await act(async () => { await t.mockMouse.click(lines()[y].indexOf("call one") + 2, y); await t.renderOnce(); }); + await t.waitForFrame(f => f.includes("one();")); + expect(t.captureCharFrame()).not.toContain("call one"); + } 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}); @@ -135,3 +157,76 @@ test("closing a scrolled file and reopening it starts at its header and first so expect(lines[3]).toContain("line 0"); } finally { await act(async () => { t.renderer.destroy(); }); } }); +test("V or the header's box marks a file viewed: it closes, and its header, the tree and the status line say so", async () => { + const store = new DiffStore(), file = createTestDiffFile(); + 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(); + const lines = () => frame().split("\n"); + const header = () => lines().find(l => l.includes("│▌")) ?? ""; + try { + await act(async () => { await t.renderOnce(); }); + expect(header()).toContain("+2 −1 [ ]"); + expect(frame()).toContain("0/1 viewed"); + const y = lines().findIndex(l => l.includes("│▌")), box = lines()[y].lastIndexOf("[ ]"); + await act(async () => { await t.mockMouse.moveTo(box + 1, y); await t.renderOnce(); }); + await t.waitFor(() => header().includes("Mark as viewed · V")); + expect(header().lastIndexOf("[ ]")).toBe(box); + await act(async () => { await t.mockMouse.click(box + 1, y); await t.renderOnce(); }); + await t.waitFor(() => frame().includes("1/1 viewed")); + expect(header()).toContain("[✓]"); + expect(header()).not.toContain("+2"); + expect(frame()).not.toContain('send("new")'); + expect(header()).toContain("▤ ✓ demo.ts"); + await act(async () => { await t.mockMouse.moveTo(1, 0); t.mockInput.pressKey("V"); await t.renderOnce(); }); + await t.waitFor(() => frame().includes("0/1 viewed")); + expect(header()).toContain("+2 −1 [ ]"); + expect(frame()).toContain('send("new")'); + } finally { await act(async () => { t.renderer.destroy(); }); } +}); +test("a kitty-protocol terminal names the base key of a shifted symbol, but the TUI acts on what was typed", async () => { + const store = new DiffStore(), file = createTestDiffFile(); + store.accept(startFor([file])); store.accept(file); store.accept({type:"complete", succeeded:1, failed:0}); + const t = await testRender( {}} />, {width:100, height:20, kittyKeyboard: true}); + const frame = () => t.captureCharFrame(); + const status = () => frame().split("\n").at(-2) ?? ""; + // CSI key:shifted;modifiers u, as kitty sends shift+/ for ? and shift+9 for (. + const send = async (sequence: string) => { await act(async () => { t.renderer.stdin.emit("data", Buffer.from(sequence)); await t.renderOnce(); }); }; + try { + await act(async () => { await t.renderOnce(); }); + await send("\x1b[47:63;2u"); + await t.waitFor(() => frame().includes("Go to a file Ctrl-P")); + expect(status()).not.toContain("/▏"); + await send("\x1b[27u"); + await send("\x1b[47u"); + await send("\x1b[57:40;2u"); + await t.waitFor(() => status().includes("/(▏")); + // ⌘P, which kitty sends as p with the super modifier (8, so 9), opens the file picker. + await send("\x1b[27u"); + await send("\x1b[112;9u"); + await t.waitFor(() => frame().includes("changed files")); + } finally { await act(async () => { t.renderer.destroy(); }); } +}); +test("while lines are selected the status line is a bright bar naming them; its button copies them for an agent, Escape clears it", async () => { + const store = new DiffStore(), file = createTestDiffFile(); + store.accept(startFor([file])); store.accept(file); store.accept({type:"complete", succeeded:1, failed:0}); + const t = await testRender( {}} />, {width:150, height:20}); + const status = () => t.captureCharFrame().split("\n").at(-2) ?? ""; + try { + await act(async () => { await t.renderOnce(); }); + await t.waitForFrame((frame) => frame.includes('send("old")')); + expect(status()).not.toContain("Copy for agent"); + await act(async () => { await t.mockMouse.drag(40, 3, 40, 6); }); + await t.waitFor(() => status().includes("Copy for agent")); + const y = t.captureCharFrame().split("\n").length - 2; + const clipboard = spyOn(t.renderer, "copyToClipboardOSC52").mockReturnValue(true); + await act(async () => { await t.mockMouse.click(status().indexOf("Copy for agent"), y); await t.renderOnce(); }); + expect(clipboard).toHaveBeenCalledTimes(1); + expect(clipboard.mock.calls[0]![0]).toContain("@@ -1,2 +1 @@\n start();\n-send(\"old\");\n"); + clipboard.mockRestore(); + await t.waitFor(() => status().trim().startsWith("Copied for agent · demo.ts:L1-3")); + // A lone escape is only recognised once the parser's escape-sequence timeout passes. + await act(async () => { t.mockInput.pressKey("ESCAPE"); await new Promise((resolve) => setTimeout(resolve, 100)); }); + await t.waitFor(() => !status().includes("Copy for agent")); + } finally { await act(async () => { t.renderer.destroy(); }); } +}); diff --git a/diffr/tui/packages/hunk/src/ui/Settings.test.tsx b/diffr/packages/tui/src/ui/Settings.test.tsx similarity index 100% rename from diffr/tui/packages/hunk/src/ui/Settings.test.tsx rename to diffr/packages/tui/src/ui/Settings.test.tsx diff --git a/diffr/tui/packages/hunk/src/ui/Settings.tsx b/diffr/packages/tui/src/ui/Settings.tsx similarity index 99% rename from diffr/tui/packages/hunk/src/ui/Settings.tsx rename to diffr/packages/tui/src/ui/Settings.tsx index 6b59b0b23..7371e26e2 100644 --- a/diffr/tui/packages/hunk/src/ui/Settings.tsx +++ b/diffr/packages/tui/src/ui/Settings.tsx @@ -11,8 +11,8 @@ import { type ConfigClient, type Setting, } from "../diffr/config"; -import { dark } from "../diffr/rows"; -import { sliceTextByWidth } from "./lib/text"; +import { dark } from "@diffr/viewer/theme/themes"; +import { sliceTextByWidth } from "@diffr/viewer/terminal/text"; const fit = (text: string, width: number) => sliceTextByWidth(text, 0, Math.max(0, width)).text; const pad = (text: string, width: number) => fit(text, width).padEnd(Math.max(0, width)); diff --git a/diffr/packages/tui/src/ui/diff/CodeRowView.tsx b/diffr/packages/tui/src/ui/diff/CodeRowView.tsx new file mode 100644 index 000000000..4fe77f91b --- /dev/null +++ b/diffr/packages/tui/src/ui/diff/CodeRowView.tsx @@ -0,0 +1,116 @@ +/** Paint measured Hunk-style code cells; source identity and viewport geometry stay outside React. */ +import { memo } from "react"; +import { StyledText, TextAttributes, parseColor, type MouseEvent } from "@opentui/core"; +import type { RenderSpan, SplitLineCell, UnifiedLineCell } from "@diffr/viewer/document/rows"; +import type { Geometry, MeasuredRow } from "@diffr/viewer/viewport/geometry"; +import { planCell, targetAt, type PaintRun, type ScopeFocus } from "@diffr/viewer/viewport/cell"; +import type { Palette } from "@diffr/viewer/theme/palette"; +const colors = new Map>(); +function color(value: string) { + let c = colors.get(value); + if (!c) { + c = parseColor(value); + colors.set(value, c); + } + return c; +} +/** A run may ask for bold, as the picker's matched letters do; `attributes` applies to the rest. */ +export const styled = (runs: (PaintRun & { bold?: boolean })[], attributes?: number) => + new StyledText(runs.map((run) => ({ __isChunk: true as const, text: run.text, fg: color(run.fg), bg: color(run.bg), + attributes: run.bold ? TextAttributes.BOLD : attributes }))); +export const CodeRowView = memo(function CodeRowView({ + measured, + visualLine, + geometry, + theme, + selectedLeft, + selectedRight, + onSelect, + onExtend, + onFold, + focus, + onHover, + read, +}: { + measured: MeasuredRow; + visualLine: number; + geometry: Geometry; + theme: Palette; + /** Which halves of the row the selection covers; a unified row is either. Booleans, so the memo holds. */ + selectedLeft: boolean; + selectedRight: boolean; + onSelect: (side: "left" | "right") => void; + onExtend: (side: "left" | "right") => void; + focus?: ScopeFocus; + onHover: (focus: ScopeFocus | undefined) => void; + onFold: (id: number, recursive: boolean) => void; + read: boolean; +}) { + const row = measured.row; + function cell( + value: SplitLineCell | UnifiedLineCell, + spans: RenderSpan[], + width: number, + side: "left" | "right", + unified = false, + ) { + const plan = planCell(value, spans, width, unified, { theme, geometry, visualLine, focus, selected: unified ? selectedLeft || selectedRight : side === "left" ? selectedLeft : selectedRight, read }); + const column = (event: MouseEvent) => event.x - (event.currentTarget?.x ?? 0); + // Pressing a fold target doesn't start a selection. + const fold = (event: MouseEvent) => targetAt(plan.hits, column(event)); + return ( + { + if (event.button !== 0) return; + if (fold(event) !== undefined) event.stopPropagation(); + else onSelect(side); + }} + onMouseUp={(event) => { + const id = event.button === 0 ? fold(event) : undefined; + if (id === undefined) return; + event.stopPropagation(); + onFold(id, event.modifiers.alt); + }} + onMouseMove={(event) => { onExtend(side); onHover(targetAt(plan.hovers, column(event))); }} + onMouseOut={() => onHover(undefined)} + > + + + ); + } + return ( + + {row.cell ? ( + cell( + row.cell, + measured.cell[visualLine] ?? [], + geometry.leftWidth + geometry.rightWidth + 1, + row.cell.newLineNumber === undefined ? "left" : "right", + true, + ) + ) : ( + <> + {cell( + row.left!, + measured.left[visualLine] ?? [], + geometry.leftWidth, + "left", + )} + + │ + + {cell( + row.right!, + measured.right[visualLine] ?? [], + geometry.rightWidth, + "right", + )} + + )} + + ); +}); diff --git a/diffr/tui/packages/hunk/src/ui/lib/keys.ts b/diffr/packages/tui/src/ui/lib/keys.ts similarity index 100% rename from diffr/tui/packages/hunk/src/ui/lib/keys.ts rename to diffr/packages/tui/src/ui/lib/keys.ts diff --git a/diffr/tui/packages/hunk/src/ui/lib/sidebar.ts b/diffr/packages/tui/src/ui/lib/sidebar.ts similarity index 100% rename from diffr/tui/packages/hunk/src/ui/lib/sidebar.ts rename to diffr/packages/tui/src/ui/lib/sidebar.ts diff --git a/diffr/tui/test/fixtures/comparison.ndjson b/diffr/packages/tui/test/fixtures/comparison.ndjson similarity index 100% rename from diffr/tui/test/fixtures/comparison.ndjson rename to diffr/packages/tui/test/fixtures/comparison.ndjson diff --git a/diffr/tui/test/review-smoke.tsx b/diffr/packages/tui/test/review-smoke.tsx similarity index 93% rename from diffr/tui/test/review-smoke.tsx rename to diffr/packages/tui/test/review-smoke.tsx index edf06a1cc..7558cc8c1 100644 --- a/diffr/tui/test/review-smoke.tsx +++ b/diffr/packages/tui/test/review-smoke.tsx @@ -5,10 +5,10 @@ import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { rgbToHex } from "@opentui/core"; import { act } from "react"; import { testRender } from "@opentui/react/test-utils"; -import { App } from "../packages/hunk/src/ui/App"; -import { DiffStore } from "../packages/hunk/src/diffr/store"; -import { eventSchema } from "../packages/hunk/src/diffr/wire"; -import { loadBundledTheme } from "../packages/hunk/src/diffr/theme"; +import { App } from "../src/ui/App"; +import { DiffStore } from "@diffr/viewer/protocol/store"; +import { eventSchema } from "@diffr/viewer/protocol/wire"; +import { loadBundledTheme } from "@diffr/viewer/theme/themes"; const [input, output = "/tmp/diffr-review"] = process.argv.slice(2); if (!input) throw new Error("Pass a saved NDJSON comparison"); mkdirSync(output, {recursive: true}); diff --git a/diffr/tui/tsconfig.json b/diffr/packages/tui/tsconfig.json similarity index 77% rename from diffr/tui/tsconfig.json rename to diffr/packages/tui/tsconfig.json index bcc1a77be..04f377732 100644 --- a/diffr/tui/tsconfig.json +++ b/diffr/packages/tui/tsconfig.json @@ -10,6 +10,6 @@ "noEmit": true, "types": ["bun", "react"] }, - "include": ["packages/**/*.ts", "packages/**/*.tsx", "test/**/*.ts", "test/**/*.tsx"], + "include": ["src/**/*.ts", "src/**/*.tsx", "test/**/*.ts", "test/**/*.tsx"], "exclude": ["test/fixtures"] } diff --git a/diffr/packages/viewer/package.json b/diffr/packages/viewer/package.json new file mode 100644 index 000000000..2260f12e4 --- /dev/null +++ b/diffr/packages/viewer/package.json @@ -0,0 +1,25 @@ +{ + "name": "@diffr/viewer", + "private": true, + "license": "MIT", + "type": "module", + "exports": { + "./*": "./src/*.ts" + }, + "scripts": { + "typecheck": "tsc --noEmit -p . && tsc --noEmit -p tsconfig.test.json", + "test": "vitest run" + }, + "dependencies": { + "fuzzysort": "4.0.2", + "get-east-asian-width": "1.4.0", + "smol-toml": "1.9.0", + "string-width": "8.1.0", + "zod": "4.6.5" + }, + "devDependencies": { + "typescript": "5.9.3", + "@types/node": "24.12.2", + "vitest": "4.1.11" + } +} diff --git a/diffr/tui/packages/hunk/src/assets.d.ts b/diffr/packages/viewer/src/assets.d.ts similarity index 100% rename from diffr/tui/packages/hunk/src/assets.d.ts rename to diffr/packages/viewer/src/assets.d.ts diff --git a/diffr/tui/packages/hunk/src/diffr/counts.test.ts b/diffr/packages/viewer/src/document/counts.test.ts similarity index 97% rename from diffr/tui/packages/hunk/src/diffr/counts.test.ts rename to diffr/packages/viewer/src/document/counts.test.ts index 0bad94f1c..ad13844d2 100644 --- a/diffr/tui/packages/hunk/src/diffr/counts.test.ts +++ b/diffr/packages/viewer/src/document/counts.test.ts @@ -1,4 +1,4 @@ -import { expect, test } from "bun:test"; +import { expect, test } from "vitest"; import { blockBar, comparisonLabel } from "./counts"; test("the block bar splits five blocks by share and keeps a block for any non-zero side", () => { expect(blockBar({ added: 0, removed: 0 })).toEqual(["neutral", "neutral", "neutral", "neutral", "neutral"]); diff --git a/diffr/tui/packages/hunk/src/diffr/counts.ts b/diffr/packages/viewer/src/document/counts.ts similarity index 79% rename from diffr/tui/packages/hunk/src/diffr/counts.ts rename to diffr/packages/viewer/src/document/counts.ts index 8244648fe..de0bc61c1 100644 --- a/diffr/tui/packages/hunk/src/diffr/counts.ts +++ b/diffr/packages/viewer/src/document/counts.ts @@ -1,6 +1,6 @@ /** Change counts for the headers. The numbers are diffr's `stats`, shown verbatim: folding * changes what is on screen, never the count. */ -import type { LineCounts } from "./wire"; +import type { ComparisonEnd, LineCounts } from "../protocol/wire"; export type { LineCounts }; export const zero: LineCounts = { added: 0, removed: 0 }; export const add = (a: LineCounts, b: LineCounts): LineCounts => ({ added: a.added + b.added, removed: a.removed + b.removed }); @@ -22,13 +22,7 @@ export function blockBar(counts: LineCounts, blocks = 5): ("added" | "removed" | ...Array.from({ length: blocks - green - red }, () => "neutral" as const), ]; } -export type Snapshot = - | { type: "revision"; rev: string } - | { type: "index" } - | { type: "working_tree" } - | { type: "empty_tree" } - | { type: "path"; path: string }; -export function snapshotLabel(snapshot: Snapshot): string { +export function snapshotLabel(snapshot: ComparisonEnd): string { switch (snapshot.type) { case "revision": return /^[0-9a-f]{40}$/.test(snapshot.rev) ? snapshot.rev.slice(0, 7) : snapshot.rev; @@ -42,4 +36,4 @@ export function snapshotLabel(snapshot: Snapshot): string { return snapshot.path; } } -export const comparisonLabel = (lhs: Snapshot, rhs: Snapshot) => `${snapshotLabel(lhs)}…${snapshotLabel(rhs)}`; +export const comparisonLabel = (lhs: ComparisonEnd, rhs: ComparisonEnd) => `${snapshotLabel(lhs)}…${snapshotLabel(rhs)}`; diff --git a/diffr/tui/packages/hunk/src/diffr/fileTree.test.ts b/diffr/packages/viewer/src/document/fileTree.test.ts similarity index 92% rename from diffr/tui/packages/hunk/src/diffr/fileTree.test.ts rename to diffr/packages/viewer/src/document/fileTree.test.ts index fc92c6458..de17389a0 100644 --- a/diffr/tui/packages/hunk/src/diffr/fileTree.test.ts +++ b/diffr/packages/viewer/src/document/fileTree.test.ts @@ -1,6 +1,6 @@ -import { expect, test } from "bun:test"; +import { expect, test } from "vitest"; import { buildFileTree, flattenFileTree, parentDirectories, lineCounts } from "./fileTree"; -import { createTestDiffFile } from "./fixture"; +import { createTestDiffFile } from "../protocol/fixture"; test("tree shares directories, retains file identities, and folds subtrees", () => { const files = ["src/ui/App.tsx", "README.md", "src/core.rs", "test/ui/App.tsx"].map(path => { const file = createTestDiffFile(); diff --git a/diffr/tui/packages/hunk/src/diffr/fileTree.ts b/diffr/packages/viewer/src/document/fileTree.ts similarity index 96% rename from diffr/tui/packages/hunk/src/diffr/fileTree.ts rename to diffr/packages/viewer/src/document/fileTree.ts index d16d1fc92..6dcbea3a7 100644 --- a/diffr/tui/packages/hunk/src/diffr/fileTree.ts +++ b/diffr/packages/viewer/src/document/fileTree.ts @@ -1,4 +1,4 @@ -import { filePath, type DiffFile, type FileChange } from "./wire"; +import { filePath, type DiffFile, type FileChange } from "../protocol/wire"; export interface TreeNode { key: string; diff --git a/diffr/tui/packages/hunk/src/diffr/guides.test.ts b/diffr/packages/viewer/src/document/guides.test.ts similarity index 96% rename from diffr/tui/packages/hunk/src/diffr/guides.test.ts rename to diffr/packages/viewer/src/document/guides.test.ts index 963db881d..6c527077c 100644 --- a/diffr/tui/packages/hunk/src/diffr/guides.test.ts +++ b/diffr/packages/viewer/src/document/guides.test.ts @@ -1,6 +1,7 @@ -import {expect, test} from "bun:test"; -import {createGuideDiffFile, createTestDiffFile, leaf, line, root} from "./fixture"; -import {dark, rowsForFile} from "./rows"; +import { expect, test } from "vitest"; +import {createGuideDiffFile, createTestDiffFile, leaf, line, root} from "../protocol/fixture"; +import {rowsForFile} from "./rows"; +import { dark } from "../theme/themes"; import {byteColumn, defaultCollapsed} from "./regions"; const sourceText = (cell: {spans: {text: string}[]} | undefined) => cell?.spans.map(s => s.text).join(""); test("Paper: enclosing guides cross blank lines and indented gap bands; syntax folds join opener and closer", () => { diff --git a/diffr/packages/viewer/src/document/pick.ts b/diffr/packages/viewer/src/document/pick.ts new file mode 100644 index 000000000..af1d3f2ba --- /dev/null +++ b/diffr/packages/viewer/src/document/pick.ts @@ -0,0 +1,30 @@ +import fuzzysort, { type Prepared } from "fuzzysort"; +import { filePath, type FileChange } from "../protocol/wire"; + +export interface Pick { + fileIndex: number; + path: string; + indexes: readonly number[]; +} + +const prepared = new WeakMap(); + +function preparedPaths(inventory: readonly FileChange[]) { + let paths = prepared.get(inventory); + if (!paths) { + paths = inventory.map((change) => fuzzysort.prepare(filePath(change.file))); + prepared.set(inventory, paths); + } + return paths; +} + +/** Viewed files sink, keeping order. */ +export function rankFiles(inventory: readonly FileChange[], order: readonly number[], query: string, + viewed: (index: number) => boolean): Pick[] { + const paths = preparedPaths(inventory); + const picks: Pick[] = query + ? fuzzysort.go(query, order, { key: (index) => paths[index], limit: 0, threshold: 0 }) + .map((result) => ({ fileIndex: result.obj, path: result.target, indexes: result.indexes })) + : order.map((index) => ({ fileIndex: index, path: paths[index]!.target, indexes: [] })); + return [...picks.filter((pick) => !viewed(pick.fileIndex)), ...picks.filter((pick) => viewed(pick.fileIndex))]; +} diff --git a/diffr/tui/packages/hunk/src/diffr/regions.test.ts b/diffr/packages/viewer/src/document/regions.test.ts similarity index 82% rename from diffr/tui/packages/hunk/src/diffr/regions.test.ts rename to diffr/packages/viewer/src/document/regions.test.ts index ce1b726a8..c53f5c762 100644 --- a/diffr/tui/packages/hunk/src/diffr/regions.test.ts +++ b/diffr/packages/viewer/src/document/regions.test.ts @@ -1,42 +1,10 @@ -import { expect, test } from "bun:test"; -import { createTestDiffFile, fold, leaf, line, root } from "./fixture"; +import { expect, test } from "vitest"; +import { createBundledDiffFile, createFoldedDiffFile, createTestDiffFile, fold, leaf, line, root } from "../protocol/fixture"; import { collapsedFolds, defaultCollapsed, flatten, foldHeaders, foldIds, foldTint, gapIds, hiddenLines, pairedIds } from "./regions"; -import { dark, rowsForFile } from "./rows"; -import type { DiffFile, Region } from "./wire"; -import { foldBackground } from "./theme"; -/** Rust-style body folds: each covers its body alone, so the lines that open and close a - * construct are leaves around it, like VS Code's rows. */ -export function createFoldedDiffFile(): DiffFile { - const file = createTestDiffFile(); - const lines = [ - "fn outer() {", // 0 - " inner(|| {", // 1 - " a();", // 2 - " b();", // 3 - " });", // 4 - " // trailing", // 5 - " // comment", // 6 - "}", // 7 - ]; - const text = lines.join("\n") + "\n"; - // ids: 10 outer body (lines 1-6), 11 closure body (2-3), 12 comment (5-6); leaves tile the file. - const regions = (changed: boolean): Region[] => [ - leaf(1, 0, 1), - fold(10, [1, 0], [7, 0], [ - leaf(7, 1, 2), - fold(11, [2, 0], [4, 0], [leaf(2, 2, 3), leaf(3, 3, 4, changed ? [line(3, 8, 12)] : [])]), - leaf(4, 4, 5), - // A whole-node fold ending at end of line covers its last line too. - fold(12, [5, 4], [6, 14], [leaf(5, 5, 7)], "Comment", ["summarize:docstring"]), - ]), - leaf(6, 7, 8), - ]; - if (file.diff.type !== "text") throw new Error("fixture is not a text diff"); - file.diff.lhs = { text, syntax: [], root: root(regions(false))}; - file.diff.rhs = { text, syntax: [], root: root(regions(true))}; - file.diff.stats = { textual: { added: 1, removed: 0 }, visible: { added: 1, removed: 0 } }; - return file; -} +import { rowsForFile } from "./rows"; +import { dark } from "../theme/themes"; +import type { Region } from "../protocol/wire"; +import { foldBackground } from "./rows"; test("a fold covers its body, so collapsing it hides every line it holds", () => { const file = createFoldedDiffFile(); if (file.diff.type !== "text") throw new Error(); @@ -121,20 +89,6 @@ test("a multi-line label uses the declared enclosing indent inside the fold tint const unified = rowsForFile(file, 0, "unified", dark, new Set([11])); expect(unified.filter((r) => r.cell?.foldLabel)).toHaveLength(3); }); -test("a syntax body with a multi-line label is quoted between its opener and closer", () => { - const file = createFoldedDiffFile(); - if (file.diff.type !== "text") throw new Error(); - for (const source of [file.diff.lhs!, file.diff.rhs!]) { - const closure = source.root.children[1].children[1]; - if (closure.kind !== "fold") throw new Error(); - closure.visibility = { collapsed: true, label: "call a\ncall b" }; - closure.syntax = { start: { line: 1, column: 13 }, end: { line: 4, column: 4 } }; - } - const rows = rowsForFile(file, 0, "split", dark, new Set([11])).filter((r) => r.right); - const text = (r: (typeof rows)[number]) => r.right!.spans.map((s) => s.text).join(""); - expect(rows.slice(1, 5).map(text)).toEqual([" inner(|| {", " > call a · 1 line changed", " > call b", " });"]); - expect(rows[2].right!.fold?.id).toBe(11); -}); test("a collapsed leaf is one fold row with the chevron, its label, and no line number", () => { const file = createTestDiffFile(); if (file.diff.type !== "text") throw new Error(); @@ -196,29 +150,6 @@ test("regions sharing a fold_state_id collapse and expand as one bundle", () => expect(rows.map((r) => r.right!.lineNumber)).toEqual([undefined, 3, undefined, 5]); }); -/** - * The shape diffr emits for a new, summarized function (src/tags/mod.rs, crates/diffr-core/src/protocol/project.rs - * `syntax_spans`): a one-line docstring region tagged `summarize:docstring`, collapsed - * with an empty label, sharing fold state 4 with the collapsed function fold after it, right side only. - */ -export function createBundledDiffFile(): DiffFile { - const file = createTestDiffFile(); - if (file.diff.type !== "text") throw new Error("fixture is not a text diff"); - const text = [ - "];", // 0 - "/// The built-in rule for a path.", // 1 - "pub(crate) fn from_path(path: &str) -> Option<&str> {", // 2 - " None", // 3 - "}", // 4 - ].join("\n") + "\n"; - const docstring: Region = { - ...leaf(38, 1, 2), fold_state_id: 4, tags: ["summarize:docstring"], visibility: { collapsed: true, label: "" }, - }; - const body = fold(4, [3, 0], [4, 0], [leaf(5, 3, 4)], "look up path\nreturn None", ["summarize:function"], true); - file.diff.rhs = { text, syntax: [], root: root([leaf(3, 0, 1), docstring, leaf(7, 2, 3), body, leaf(6, 4, 5)])}; - file.diff.lhs = undefined; - return file; -} test("a collapsed docstring leaf bundled with its function renders as a bare ⋯ row", () => { const file = createBundledDiffFile(); if (file.diff.type !== "text") throw new Error("fixture is not a text diff"); diff --git a/diffr/tui/packages/hunk/src/diffr/regions.ts b/diffr/packages/viewer/src/document/regions.ts similarity index 77% rename from diffr/tui/packages/hunk/src/diffr/regions.ts rename to diffr/packages/viewer/src/document/regions.ts index d1e2586ec..3071fafc3 100644 --- a/diffr/tui/packages/hunk/src/diffr/regions.ts +++ b/diffr/packages/viewer/src/document/regions.ts @@ -1,6 +1,6 @@ /** Flatten diffr's per-side region trees; collapsed state belongs to the viewer. */ -import type { FoldRegion, Region, Source, Span, TextDiff } from "./wire"; -import { measureTextWidth } from "../ui/lib/text"; +import type { FoldRegion, Region, Source, Span, TextDiff } from "../protocol/wire"; +import { measureTextWidth } from "../terminal/text"; export type Side = 0 | 1; /** A leaf tiles its side; the same alignmentId on the other side is the leaf its rows line up with. */ export interface Leaf { @@ -122,6 +122,77 @@ export function flatten(diff: TextDiff) { const rhs = diff.rhs ? flattenSide(diff.rhs, 1) : { leaves: [], folds: [] }; return { leaves: [lhs.leaves, rhs.leaves] as const, folds: [lhs.folds, rhs.folds] as const }; } +/** diffr sends paired leaves in the same order on both sides. */ +export function zipLeaves(leaves: readonly [Leaf[], Leaf[]], visit: (left: Leaf | null, right: Leaf | null) => void) { + const rightIndex = new Map(leaves[1].map((leaf, index) => [leaf.alignmentId, index])); + let cursor = 0; + const flushRight = (until: number) => { + for (; cursor < until; cursor++) visit(null, leaves[1][cursor]!); + }; + for (const left of leaves[0]) { + const partner = rightIndex.get(left.alignmentId); + if (partner === undefined) { + visit(left, null); + continue; + } + flushRight(partner); + visit(left, leaves[1][partner]!); + cursor = partner + 1; + } + flushRight(leaves[1].length); +} +export function changedLine(leaves: readonly [Leaf[], Leaf[]]): (leaf: Leaf, line: number) => boolean { + const alignments = leaves.map(side => new Set(side.map(leaf => leaf.alignmentId))); + return (leaf, line) => leaf.changed.has(line) || !alignments[leaf.side ? 0 : 1]!.has(leaf.alignmentId); +} +export interface DiffLine { + old?: number; + new?: number; +} +export interface DiffOrder { + lines: DiffLine[]; + oldAt: Map; + newAt: Map; + oldBefore: number[]; + newBefore: number[]; +} +/** Independent of folds, so a patch numbered from it is exact whatever is folded. */ +export function diffOrder(diff: TextDiff): DiffOrder { + const { leaves } = flatten(diff); + const isChanged = changedLine(leaves); + const order: DiffOrder = { lines: [], oldAt: new Map(), newAt: new Map(), oldBefore: [], newBefore: [] }; + let pendingOld: DiffLine[] = [], pendingNew: DiffLine[] = []; + let olds = 0, news = 0; + const push = (line: DiffLine) => { + const index = order.lines.length; + order.lines.push(line); + order.oldBefore.push(olds); + order.newBefore.push(news); + if (line.old !== undefined) { order.oldAt.set(line.old, index); olds++; } + if (line.new !== undefined) { order.newAt.set(line.new, index); news++; } + }; + const flush = () => { + for (const line of pendingOld) push(line); + for (const line of pendingNew) push(line); + pendingOld = []; + pendingNew = []; + }; + zipLeaves(leaves, (left, right) => { + const leftLines = left ? left.endLine - left.startLine : 0, rightLines = right ? right.endLine - right.startLine : 0; + for (let i = 0; i < Math.max(leftLines, rightLines); i++) { + const l = i < leftLines ? left!.startLine + i : null, r = i < rightLines ? right!.startLine + i : null; + if (l !== null && r !== null && !isChanged(left!, l) && !isChanged(right!, r)) { + flush(); + push({ old: l + 1, new: r + 1 }); + continue; + } + if (l !== null) pendingOld.push({ old: l + 1 }); + if (r !== null) pendingNew.push({ new: r + 1 }); + } + }); + flush(); + return order; +} /** Fold-state ids diffr asks to start collapsed: context gaps and folded bodies, on either side. */ export function defaultCollapsed(diff: TextDiff): Set { const ids = new Set(); @@ -178,6 +249,19 @@ export function gapIds(diff: TextDiff): number[] { }); return [...gaps]; } +export function hidingIds(diff: TextDiff, side: Side, line: number, collapsed: ReadonlySet): number[] { + const { leaves, folds } = flatten(diff); + const ids = new Set(); + for (const fold of folds[side]) { + // A syntax fold with a one-line label collapses onto its opener, so it hides its closer's line too. + const inline = fold.syntax && !fold.label.includes("\n"); + const last = inline ? Math.max(fold.lastHidden, fold.syntax!.end.line) : fold.lastHidden; + if (collapsed.has(fold.foldStateId) && fold.startLine <= line && line <= last) ids.add(fold.foldStateId); + } + for (const leaf of leaves[side]) + if (collapsed.has(leaf.foldStateId) && leaf.startLine <= line && line < leaf.endLine) ids.add(leaf.foldStateId); + return [...ids]; +} /** Source lines hidden on one side: a collapsed fold hides every line it covers. */ export function hiddenLines(folds: Fold[], collapsed: ReadonlySet): Set { const hidden = new Set(); diff --git a/diffr/tui/packages/hunk/src/diffr/rows.test.ts b/diffr/packages/viewer/src/document/rows.test.ts similarity index 88% rename from diffr/tui/packages/hunk/src/diffr/rows.test.ts rename to diffr/packages/viewer/src/document/rows.test.ts index 38917ddc6..bdc13216a 100644 --- a/diffr/tui/packages/hunk/src/diffr/rows.test.ts +++ b/diffr/packages/viewer/src/document/rows.test.ts @@ -1,9 +1,10 @@ -import { expect, test } from "bun:test"; -import { createTestDiffFile, fold, leaf, line, withIdenticalLines, root } from "./fixture"; -import type { Region, Span } from "./wire"; -import { captureColor, dark, light, lineSpans, rowsForFile } from "./rows"; +import { expect, test } from "vitest"; +import { createTestDiffFile, fold, leaf, line, withIdenticalLines, root } from "../protocol/fixture"; +import type { Region, Span } from "../protocol/wire"; +import { captureColor, lineSpans, rowsForFile } from "./rows"; +import { dark, light } from "../theme/themes"; import { pairedIds } from "./regions"; -import { measureRows, visibleRows } from "./geometry"; +import { measureRows, visibleRows } from "../viewport/geometry"; import { copySelection } from "./selection"; test("split zips leaves on their alignment ids, unified groups old before new", () => { const file = createTestDiffFile(); @@ -130,14 +131,15 @@ test("wrapping adds equal split heights and windowing mounts only intersecting r ); expect(visibleRows(geometry, 0, 2).length).toBe(2); }); -test("selection copies one source side and excludes padding and added lines", () => { +test("a split selection kept to the left column copies the old source, without padding or added lines", () => { const file = createTestDiffFile(), rows = rowsForFile(file, 0, "split", dark); expect( copySelection([file], rows, { anchor: rows[1].key, + anchorSide: "left", end: rows.at(-1)!.key, - side: "left", + endSide: "left", }), ).toBe('start();\nsend("old");\nfinish();'); }); @@ -181,10 +183,9 @@ test("unified trusts diffr's changed spans despite different source indentation" expect(rows).toHaveLength(2); expect(rows[1].cell).toMatchObject({kind: "context", sign: " ", oldLineNumber: 1, newLineNumber: 1}); expect(rows[1].cell!.spans.map(s => s.text).join("")).toBe(" call();"); - for (const side of ["left", "right"] as const) { - expect(copySelection([file], rows, {anchor: rows[1].key, end: rows[1].key, side})) - .toBe(side === "left" ? " call();" : " call();"); - } + // An unchanged unified row is one line on both sides; a copy takes the new version's. + expect(copySelection([file], rows, {anchor: rows[1].key, anchorSide: "left", end: rows[1].key, endSide: "left"})) + .toBe(" call();"); // 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)])]; @@ -220,3 +221,16 @@ test("binary and one-sided files render without a second side", () => { const rows = rowsForFile(added, 0, "split", dark).filter((r) => r.left); expect(rows.map((r) => [r.left!.kind, r.right!.lineNumber])).toEqual([["empty", 1]]); }); + +test("a file of a million added lines lays out, as a vendored parser does", () => { + // Past the stack's limit on arguments, were the rows ever spread into one call. + const count = 1_000_000; + const file = createTestDiffFile(); + if (file.diff.type !== "text") throw new Error("fixture is not a text diff"); + file.diff.lhs = { text: "", syntax: [], root: root([]) }; + file.diff.rhs = { text: Array.from({ length: count }, (_, i) => `line ${i}`).join("\n"), syntax: [], + root: root([leaf(1, 0, count, Array.from({ length: count }, (_, i) => line(i, 0, 4)))], 1001) }; + for (const layout of ["unified", "split"] as const) + expect(rowsForFile(file, 0, layout, dark).filter((r) => r.cell?.kind === "addition" || r.right?.kind === "addition")).toHaveLength(count); + // A million rows in each of two layouts takes seconds. +}, 30_000); diff --git a/diffr/tui/packages/hunk/src/diffr/rows.ts b/diffr/packages/viewer/src/document/rows.ts similarity index 79% rename from diffr/tui/packages/hunk/src/diffr/rows.ts rename to diffr/packages/viewer/src/document/rows.ts index c7f64b44a..fa2a711ba 100644 --- a/diffr/tui/packages/hunk/src/diffr/rows.ts +++ b/diffr/packages/viewer/src/document/rows.ts @@ -1,13 +1,53 @@ /** Project diffr's per-side region trees into terminal cells by zipping leaves on their ids. */ -import type { DiffFile, Span, SyntaxSpan } from "./wire"; -import { filePath } from "./wire"; -import type { RenderSpan, SplitLineCell, UnifiedLineCell } from "../ui/diff/diffRowModel"; -import { sliceSpansWindow } from "../ui/diff/styledSpanLayout"; -import { measureTextWidth } from "../ui/lib/text"; -import { byteColumn, collapsedFolds, flatten, foldHeaders, foldTint, hiddenLines, pairedIds, sourceLines, type Fold, type FoldTint, type Leaf, type Side } from "./regions"; -import { foldBackground, loadBundledTheme, type Palette } from "./theme"; -export { sourceLines }; +import type { DiffFile, Span, SyntaxSpan } from "../protocol/wire"; +import { filePath } from "../protocol/wire"; +import { sliceSpansWindow } from "../terminal/spans"; +import { measureTextWidth } from "../terminal/text"; +import { byteColumn, changedLine, collapsedFolds, flatten, foldHeaders, foldTint, hiddenLines, pairedIds, sourceLines, zipLeaves, type Fold, type FoldTint, type Leaf, type RowFold, type Side } from "./regions"; +import type { Palette } from "../theme/palette"; export type Layout = "split" | "unified"; +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; + lit?: boolean; +} + +export interface SplitLineCell { + kind: "context" | "addition" | "deletion" | "empty"; + sign: string; + lineNumber?: number; + /** This cell starts a fold region; the chevron and placeholder come from here. */ + fold?: RowFold; + /** A line of a collapsed fold's label, painted in the fold tint without a line number. */ + foldLabel?: boolean; + labelOf?: number; + /** 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[]; +} + +export interface UnifiedLineCell { + kind: "context" | "addition" | "deletion"; + sign: string; + oldLineNumber?: number; + newLineNumber?: number; + fold?: RowFold; + foldLabel?: boolean; + labelOf?: number; + band?: FoldTint; + scope?: number; + body?: number[]; + spans: RenderSpan[]; +} export interface ViewerRow { key: string; fileIndex: number; @@ -21,10 +61,11 @@ export interface ViewerRow { right?: SplitLineCell; cell?: UnifiedLineCell; } -export type { Palette } from "./theme"; -/** The bundled defaults, for tests and the settings screen. */ -export const dark: Palette = loadBundledTheme("default-dark"); -export const light: Palette = loadBundledTheme("default-light"); +/** 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.bg; +} /** Foreground for a tree-sitter capture: the theme's scope, its parents, else plain text. */ export function captureColor(capture: string, theme: Palette): string { return theme.syntax(capture) ?? theme.fg; @@ -107,6 +148,15 @@ export function rowsForFile( // with a one-line label instead joins its opener, label and closing suffix on the opener's // line, so its closer is masked too. const bands = folds.map(side => collapsedFolds(side, collapsed)); + // A syntax fold with a multi-line label shows it between its opener and closer lines. + const quoted = bands.map((sideBands) => { + const result = new Map(); + for (const fold of sideBands.values()) + if (fold.syntax && fold.label.includes("\n") && fold.syntax.start.line < fold.startLine) + result.set(fold.syntax.start.line, fold as SyntaxFold); + return result; + }); + const isQuoted = (region: Leaf | Fold): region is SyntaxFold => quoted[region.side].get((region as Fold).syntax?.start.line ?? -1) === region; const inline = bands.map((sideBands, side) => { const result = new Map(); for (const fold of sideBands.values()) { @@ -154,9 +204,16 @@ export function rowsForFile( 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. + // Quoted folds keep their brackets on the visible opener and closer. + const quotedEnds = quoted.map((side) => { + const lines = new Map(); + for (const fold of side.values()) + for (const line of [fold.syntax.start.line, fold.syntax.end.line]) lines.set(line, [...(lines.get(line) ?? []), fold]); + return lines; + }); const withBraces = (spans: RenderSpan[], side: Side, line: number) => { let result = spans; - for (const fold of scopes[side].get(line) ?? []) { + for (const fold of [...(scopes[side].get(line) ?? []), ...(quotedEnds[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); @@ -168,9 +225,7 @@ export function rowsForFile( // fold no plugin labelled still reads `{ ⋯ 3 lines }` rather than a bare `{ ⋯ }`. const labelOf = (region: Leaf | Fold) => region.label || lineCount("lastHidden" in region ? region.lastHidden - region.startLine + 1 : region.endLine - region.startLine); - const alignments = leaves.map(side => new Set(side.map(leaf => leaf.alignmentId))); - const isChanged = (leaf: Leaf, line: number) => - leaf.changed.has(line) || !alignments[leaf.side ? 0 : 1].has(leaf.alignmentId); + const isChanged = changedLine(leaves); // A paired leaf is changed when its counterpart has change spans. const spanned = new Set(leaves.flat().filter(leaf => leaf.changed.size).map(leaf => leaf.alignmentId)); // A collapsed paired region that hides a change is modified, and counts its side's changed lines. @@ -202,6 +257,8 @@ export function rowsForFile( if (line === null || leaf === null) return empty; let spans = withBraces(spansOf(side, line, leaf), side, line); let fold = headers[side].get(line); + const opens = quoted[side].get(line); + if (opens) fold = { id: opens.foldStateId, label: opens.label, collapsed: true, tint: collapsedTint(opens).tint }; const folded = inline[side].get(line); if (folded) { const { start, end } = folded.syntax; @@ -226,34 +283,36 @@ export function rowsForFile( }; let pendingOld: ViewerRow[] = [], pendingNew: ViewerRow[] = []; const flush = () => { - rows.push(...pendingOld, ...pendingNew); + for (const row of pendingOld) rows.push(row); + for (const row of pendingNew) rows.push(row); pendingOld = []; pendingNew = []; }; // A collapsed region is one row: chevron and label, no line number, whether the region is a // fold or a leaf the context plugin cut out. It starts at its parent's indent; a multi-line // label (pseudocode) hangs under it at that indent, one row per line. A syntax body with a - // multi-line label is instead quoted between its opener and closer, at the body's indent. + // multi-line label is instead quoted between its opener and closer, at the body's indent beside + // the scope's rail. const band = (region: Leaf | Fold) => { const { tint, note } = collapsedTint(region); const multiline = region.label.includes("\n"); - const quoted = "syntax" in region && region.syntax !== undefined && multiline; const body = texts[region.side][region.startLine]!; - const column = quoted ? byteColumn(body, body.length - body.trimStart().length) : region.parentColumn; - const lead = (text: string) => withGuides([{ text: " ".repeat(column) }, placeholder(text, tint)], - guides[region.side].get(region.startLine) ?? [], theme); - 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 }, ...scopeOf(region.side, region.startLine) }; - return { header, labels: rest.map(text => ({ ...header, foldLabel: true, spans: lead(`> ${text}`), fold: undefined })) }; + const enclosing = guides[region.side].get(region.startLine) ?? []; + const label = { kind: "context" as const, sign: " ", band: tint, foldLabel: true, labelOf: region.foldStateId, + ...scopeOf(region.side, region.startLine) }; + if (isQuoted(region)) { + const opener = texts[region.side][region.syntax.start.line]!; + const rail = { column: byteColumn(opener, opener.length - opener.trimStart().length), id: region.foldStateId }; + const lead = (text: string) => withGuides([{ text: " ".repeat(byteColumn(body, body.length - body.trimStart().length)) }, + placeholder(text, tint)], [...enclosing, rail], theme); + const [header, ...labels] = region.label.split("\n").map((text, i) => ({ ...label, spans: lead(i ? text : `${text}${note}`) })); + return { header: header!, labels }; } + const lead = (text: string) => withGuides([{ text: " ".repeat(region.parentColumn) }, placeholder(text, tint)], enclosing, theme); const header = { kind: "context" as const, sign: " ", band: tint, spans: lead(`⋯${multiline ? "" : " " + labelOf(region)}${note}`), 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 })) - : []; + const labels = multiline ? region.label.split("\n").map(text => ({ ...label, spans: lead(text) })) : []; return { header, labels }; }; const collapsedRow = (kind: string, leftRegion: Leaf | Fold | null, rightRegion: Leaf | Fold | null) => { @@ -321,24 +380,7 @@ export function rowsForFile( for (let i = 0; i < Math.max(leftLines, rightLines); i++) emit(i < leftLines ? left!.startLine + i : null, i < rightLines ? right!.startLine + i : null, left, right); }; - // Zip: walk the left leaves; a partner ahead on the right flushes what precedes it as right-only. - // diffr sends paired leaves in the same order on both sides. - const rightIndex = new Map(leaves[1].map((leaf, index) => [leaf.alignmentId, index])); - let cursor = 0; - const flushRight = (until: number) => { - for (; cursor < until; cursor++) leafRows(null, leaves[1][cursor]); - }; - for (const left of leaves[0]) { - const partner = rightIndex.get(left.alignmentId); - if (partner === undefined) { - leafRows(left, null); - continue; - } - flushRight(partner); - leafRows(left, leaves[1][partner]); - cursor = partner + 1; - } - flushRight(leaves[1].length); + zipLeaves(leaves, leafRows); flush(); return markHunks(rows); } diff --git a/diffr/packages/viewer/src/document/search.ts b/diffr/packages/viewer/src/document/search.ts new file mode 100644 index 000000000..42cce5dd0 --- /dev/null +++ b/diffr/packages/viewer/src/document/search.ts @@ -0,0 +1,22 @@ +/** Vim's `/`: plain text, case-blind until the pattern holds a capital, over file paths and code. */ + +export interface Match { + fileIndex: number; + key: string; + side: "left" | "right"; + line?: number; + nth: number; +} + +/** Vim's smartcase: a capital in the pattern makes it match case. */ +const caseSensitive = (pattern: string) => pattern !== pattern.toLowerCase(); + +export function occurrences(text: string, pattern: string): [number, number][] { + if (!pattern) return []; + const exact = caseSensitive(pattern); + const haystack = exact ? text : text.toLowerCase(), needle = exact ? pattern : pattern.toLowerCase(); + const ranges: [number, number][] = []; + for (let at = haystack.indexOf(needle); at >= 0; at = haystack.indexOf(needle, at + needle.length)) + ranges.push([at, at + needle.length]); + return ranges; +} diff --git a/diffr/packages/viewer/src/document/selection.test.ts b/diffr/packages/viewer/src/document/selection.test.ts new file mode 100644 index 000000000..f2e7c122c --- /dev/null +++ b/diffr/packages/viewer/src/document/selection.test.ts @@ -0,0 +1,202 @@ +import { expect, test } from "vitest"; +import { spawnSync } from "node:child_process"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createTestDiffFile, leaf, root } from "../protocol/fixture"; +import { rowsForFile, type ViewerRow } from "./rows"; +import { agentReference, copySelection, selectedRanges, selectionLead, type Side, type SourceSelection } from "./selection"; +import { dark } from "../theme/themes"; +import type { DiffFile } from "../protocol/wire"; + +const againstMain = { lhs: { type: "revision" as const, rev: "main" }, rhs: { type: "working_tree" as const } }; + +/* + * The fixture, demo.ts: + * old: 1 start(); 2 send("old"); 3 finish(); + * new: 1 start(); 2 send("new"); 3 extra(); 4 finish(); + */ +type Point = [(row: ViewerRow) => boolean, Side]; +function drag(rows: ViewerRow[], from: Point, to: Point): SourceSelection { + const key = ([at]: Point) => rows.find(at)!.key; + return { anchor: key(from), anchorSide: from[1], end: key(to), endSide: to[1] }; +} +const unifiedOld = (line: number) => (row: ViewerRow) => row.cell?.oldLineNumber === line && row.cell.newLineNumber === undefined; +const unifiedNew = (line: number) => (row: ViewerRow) => row.cell?.newLineNumber === line; +const left = (line: number) => (row: ViewerRow) => row.left?.lineNumber === line; +const right = (line: number) => (row: ViewerRow) => row.right?.lineNumber === line; + +test("a unified drag from a removed line to an added one takes both sides, as a Git patch with base and head sides", () => { + const file = createTestDiffFile(); + const rows = rowsForFile(file, 0, "unified", dark); + const selection = drag(rows, [unifiedOld(2), "left"], [unifiedNew(3), "right"]); + expect(agentReference([file], againstMain, rows, selection)).toBe([ + "Base: main", + "Head: working tree (uncommitted)", + "", + "diff --git a/demo.ts b/demo.ts", + "index 1111111..2222222 100644", + "--- a/demo.ts", + "+++ b/demo.ts", + "@@ -2 +2,2 @@", + '-send("old");', + '+send("new");', + "+extra();", + "", + ].join("\n")); + expect(copySelection([file], rows, selection)).toBe('send("new");\nextra();'); +}); + +test("unchanged lines are both old and new; added lines alone are all R, at the old line they follow", () => { + const file = createTestDiffFile(); + const rows = rowsForFile(file, 0, "unified", dark); + const withContext = agentReference([file], againstMain, rows, drag(rows, [unifiedNew(1), "right"], [unifiedNew(3), "right"])); + expect(withContext).toContain("@@ -1,2 +1,3 @@\n start();\n-send(\"old\");\n+send(\"new\");\n+extra();\n"); + const added = agentReference([file], againstMain, rows, drag(rows, [unifiedNew(2), "right"], [unifiedNew(3), "right"])); + expect(added).toContain("@@ -2,0 +2,2 @@\n+send(\"new\");\n+extra();\n"); +}); + +test("a split drag from the left column to the right takes everything between, removed lines first", () => { + const file = createTestDiffFile(); + const unified = rowsForFile(file, 0, "unified", dark); + const rows = rowsForFile(file, 0, "split", dark); + expect(agentReference([file], againstMain, rows, drag(rows, [left(2), "left"], [right(3), "right"]))) + .toBe(agentReference([file], againstMain, unified, drag(unified, [unifiedOld(2), "left"], [unifiedNew(3), "right"]))); + const [range] = selectedRanges([file], rows, drag(rows, [right(1), "right"], [left(2), "left"])); + expect(range!.lines).toEqual([{ old: 1, new: 1 }, { old: 2 }]); +}); + +test("a split drag kept to one column emits only that side's selected changes", () => { + const file = createTestDiffFile(); + const rows = rowsForFile(file, 0, "split", dark); + const added = agentReference([file], againstMain, rows, drag(rows, [right(3), "right"], [right(2), "right"])); + expect(added).toContain('@@ -2,0 +2,2 @@\n+send("new");\n+extra();\n'); + expect(added).not.toContain('-send("old");'); + const removed = agentReference([file], againstMain, rows, drag(rows, [left(2), "left"], [left(2), "left"])); + expect(removed).toContain('@@ -2 +1,0 @@\n-send("old");\n'); + expect(removed).not.toContain('+send("new");'); + const staged = { lhs: { type: "index" as const }, rhs: { type: "working_tree" as const } }; + expect(agentReference([file], staged, rows, drag(rows, [left(1), "left"], [left(1), "left"]))) + .toContain("@@ -1 +1 @@\n start();\n"); +}); + +test("mutable snapshots are identified without inventing commit SHAs", () => { + const file = createTestDiffFile(); + file.file.rhs!.oid = "0".repeat(40); + const rows = rowsForFile(file, 0, "unified", dark); + const staged = { lhs: { type: "index" as const }, rhs: { type: "working_tree" as const } }; + const context = agentReference([file], staged, rows, drag(rows, [unifiedOld(2), "left"], [unifiedNew(3), "right"])); + expect(context).toContain("Base: index (staged)\nHead: working tree (uncommitted)\n\n"); + expect(context).toContain(`index ${file.file.lhs!.oid}..${file.file.rhs!.oid} 100644\n`); +}); + +test("a folded stretch inside the selection ends one hunk and starts the next", () => { + const file = createTestDiffFile(); + if (file.diff.type !== "text") throw new Error("The fixture is a text diff"); + const text = Array.from({ length: 10 }, (_, i) => `line ${i + 1}`).join("\n"); + const regions = () => { + const gap = leaf(2, 2, 7); + gap.visibility = { collapsed: true, label: "5 unchanged lines" }; + return [leaf(1, 0, 2), gap, leaf(3, 7, 10)]; + }; + file.diff.lhs = { text, syntax: [], root: root(regions()) }; + file.diff.rhs = { text, syntax: [], root: root(regions(), 1001) }; + const rows = rowsForFile(file, 0, "unified", dark, new Set([2])); + const selection = drag(rows, [unifiedNew(1), "right"], [unifiedNew(10), "right"]); + expect(agentReference([file], againstMain, rows, selection)).toBe([ + "Base: main", + "Head: working tree (uncommitted)", + "", + "diff --git a/demo.ts b/demo.ts", + "index 1111111..2222222 100644", + "--- a/demo.ts", + "+++ b/demo.ts", + "@@ -1,2 +1,2 @@", + " line 1", + " line 2", + "@@ -8,3 +8,3 @@", + " line 8", + " line 9", + " line 10", + "\\ No newline at end of file", + "", + ].join("\n")); + expect(selectionLead("", selectedRanges([file], rows, selection), 80)).toBe(" demo.ts:R1-10 · 5 lines "); +}); + +test("backticks in selected code remain literal patch content", () => { + const file = createTestDiffFile(); + if (file.diff.type !== "text" || !file.diff.rhs) throw new Error("The fixture is a text diff"); + file.diff.rhs = { ...file.diff.rhs, text: 'start();\nsend("new");\n```\nfinish();\n', syntax: [] }; + const rows = rowsForFile(file, 0, "split", dark); + expect(agentReference([file], againstMain, rows, drag(rows, [right(3), "right"], [right(3), "right"]))) + .toContain("@@ -2,0 +3 @@\n+```\n"); +}); + +test("the selection bar names the range with its path, or just its file's name when the path won't fit", () => { + const file = (path: string) => { + const each = createTestDiffFile(); + each.file.lhs!.path = each.file.rhs!.path = path; + return each; + }; + const files: DiffFile[] = [file("diffr/packages/claude-code/plugin/hooks/diffr.test.ts"), file("b.ts")]; + const rows = [...rowsForFile(files[0]!, 0, "split", dark), ...rowsForFile(files[1]!, 1, "split", dark)]; + const one = selectedRanges(files, rows, drag(rows, [right(1), "right"], [right(3), "right"])); + expect(selectionLead("", one, 80)).toBe(" diffr/packages/claude-code/plugin/hooks/diffr.test.ts:R1-3 · 3 lines "); + expect(selectionLead("", one, 40)).toBe(" diffr.test.ts:R1-3 · 3 lines "); + const across = drag(rows, [right(1), "right"], [(row) => row.fileIndex === 1 && row.right?.lineNumber === 2, "right"]); + expect(selectionLead("Copied for agent", selectedRanges(files, rows, across), 80)).toBe(" Copied for agent · 2 files · 6 lines "); +}); + +test("a line diffr pairs as only reformatted is removed and added in the patch, which needs unchanged lines to match", () => { + const file = createTestDiffFile(); + if (file.diff.type !== "text") throw new Error("The fixture is a text diff"); + file.diff.lhs = { text: " call();\n", syntax: [], root: root([leaf(1, 0, 1)]) }; + file.diff.rhs = { text: " call();\n", syntax: [], root: root([leaf(1, 0, 1)], 1001) }; + const rows = rowsForFile(file, 0, "unified", dark); + const selection = drag(rows, [unifiedNew(1), "right"], [unifiedNew(1), "right"]); + expect(agentReference([file], againstMain, rows, selection)).toContain("@@ -1 +1 @@\n- call();\n+ call();\n"); +}); + +test("a hunk's place on a side it has no lines on is counted from the whole diff, lines folded away included", () => { + // old: a, x, y new: a, c x and y are removed and folded away; c is added after them. + const file = createTestDiffFile(); + if (file.diff.type !== "text") throw new Error("The fixture is a text diff"); + const removed = leaf(2, 1, 3); + removed.visibility = { collapsed: true, label: "2 removed lines" }; + file.diff.lhs = { text: "a\nx\ny", syntax: [], root: root([leaf(1, 0, 1), removed]) }; + file.diff.rhs = { text: "a\nc", syntax: [], root: root([leaf(1, 0, 1), leaf(3, 1, 2)], 1001) }; + const rows = rowsForFile(file, 0, "unified", dark, new Set([2])); + expect(rows.some((row) => row.cell?.oldLineNumber === 2)).toBe(false); + const reference = agentReference([file], againstMain, rows, drag(rows, [unifiedNew(1), "right"], [unifiedNew(2), "right"])); + expect(reference).toContain("@@ -1 +1 @@\n a\n@@ -3,0 +2 @@\n+c\n\\ No newline at end of file\n"); + const open = rowsForFile(file, 0, "unified", dark); + expect(agentReference([file], againstMain, open, drag(open, [unifiedNew(1), "right"], [unifiedNew(2), "right"]))) + .toContain("@@ -1,3 +1,2 @@\n a\n-x\n-y\n\\ No newline at end of file\n+c\n\\ No newline at end of file\n"); +}); + +// Exercise Git's parser and application, including final-newline and quoted-path handling. +test("Git can apply the selected patch to its base and recover the head", () => { + const directory = mkdtempSync(join(tmpdir(), "diffr-patch-")); + try { + for (const ending of ["\n", ""]) { + const file = createTestDiffFile(); + if (file.diff.type !== "text" || !file.diff.lhs || !file.diff.rhs) throw new Error("Text fixture required"); + const path = 'a file"name.ts'; + file.file.lhs!.path = file.file.rhs!.path = path; + file.diff.rhs.text = file.diff.rhs.text.trimEnd() + ending; + const rows = rowsForFile(file, 0, "unified", dark); + const comparison = { lhs: { type: "revision" as const, rev: "a".repeat(40) }, rhs: { type: "revision" as const, rev: "b".repeat(40) } }; + const patch = agentReference([file], comparison, rows, drag(rows, [unifiedNew(1), "right"], [unifiedNew(4), "right"])); + expect(patch).toContain(`Base: ${comparison.lhs.rev}\nHead: ${comparison.rhs.rev}\n\n`); + expect(patch).toContain(`index ${file.file.lhs!.oid}..${file.file.rhs!.oid} 100644\n`); + writeFileSync(join(directory, path), file.diff.lhs.text); + const applied = spawnSync("git", ["apply", "--unidiff-zero", "-"], { cwd: directory, input: patch }); + expect(applied.stderr.toString()).toBe(""); + expect(applied.status).toBe(0); + expect(readFileSync(join(directory, path), "utf8")).toBe(file.diff.rhs.text); + } + } finally { + rmSync(directory, { recursive: true, force: true }); + } +}); diff --git a/diffr/packages/viewer/src/document/selection.ts b/diffr/packages/viewer/src/document/selection.ts new file mode 100644 index 000000000..a6f8a7890 --- /dev/null +++ b/diffr/packages/viewer/src/document/selection.ts @@ -0,0 +1,257 @@ +/** Positioned by old/new line numbers, so a selection can mix removed, added and unchanged lines. */ +import type { DiffFile, TextDiff } from "../protocol/wire"; +import type { ViewerRow } from "./rows"; +import type { Snapshot } from "../protocol/store"; +import { diffOrder, sourceLines, type DiffLine, type DiffOrder } from "./regions"; +import { measureTextWidth } from "../terminal/text"; + +export type Side = "left" | "right"; +export interface SourceSelection { + anchor: string; + anchorSide: Side; + end: string; + endSide: Side; +} +export function selectionBounds( + rows: ViewerRow[], + selection: SourceSelection | null, +): [number, number] { + if (!selection) return [-1, -1]; + const a = rows.findIndex((r) => r.key === selection.anchor), + b = rows.findIndex((r) => r.key === selection.end); + return a < 0 || b < 0 ? [-1, -1] : [Math.min(a, b), Math.max(a, b)]; +} +interface Cover { + left: boolean; + right: boolean; +} +/** In split, a one-column drag takes that column; a crossing drag takes everything between its ends. */ +export function selectionCover(rows: ViewerRow[], selection: SourceSelection | null): (index: number) => Cover | undefined { + const [a, b] = selectionBounds(rows, selection); + if (!selection || a < 0) return () => undefined; + const anchor = rows.findIndex((r) => r.key === selection.anchor), end = rows.findIndex((r) => r.key === selection.end); + const position = (index: number, side: Side) => index * 2 + (side === "right" ? 1 : 0); + const ends = [position(anchor, selection.anchorSide), position(end, selection.endSide)]; + const [first, last] = [Math.min(...ends), Math.max(...ends)]; + const oneColumn = selection.anchorSide === selection.endSide ? selection.anchorSide : undefined; + return (index) => { + if (index < a || index > b) return undefined; + if (rows[index]!.cell) return { left: true, right: true }; + if (oneColumn) return { left: oneColumn === "left", right: oneColumn === "right" }; + return { left: position(index, "left") >= first && position(index, "left") <= last, + right: position(index, "right") >= first && position(index, "right") <= last }; + }; +} + +export type { DiffLine }; +interface SelectedRange { + fileIndex: number; + oldPath?: string; + newPath?: string; + start: DiffLine; + end: DiffLine; + /** Set when a split drag stays in one column: that version's lines, folded ones included. */ + side?: "old" | "new"; + lines: DiffLine[]; +} +/** Each diff's order, worked out once: the selection bar asks for it every frame. */ +const orders = new WeakMap(); +function orderOf(diff: TextDiff): DiffOrder { + let order = orders.get(diff); + if (!order) { + order = diffOrder(diff); + orders.set(diff, order); + } + return order; +} +const textDiff = (files: (DiffFile | undefined)[], fileIndex: number): TextDiff => { + const diff = files[fileIndex]?.diff; + if (diff?.type !== "text") throw new Error(`File ${fileIndex} has no text to select`); + return diff; +}; +/** An unchanged line counts once, whichever half the drag covered. */ +export function selectedRanges( + files: (DiffFile | undefined)[], + rows: ViewerRow[], + selection: SourceSelection, +): SelectedRange[] { + const [a, b] = selectionBounds(rows, selection); + if (a < 0) return []; + const cover = selectionCover(rows, selection); + const oneColumn = selection.anchorSide === selection.endSide && !rows[a]!.cell + ? (selection.anchorSide === "left" ? "old" : "new") : undefined; + const touched = new Map; new: Set }>(); + for (let index = a; index <= b; index++) { + const row = rows[index]!, covered = cover(index); + if (!covered) continue; + const old = row.cell ? row.cell.oldLineNumber : covered.left ? row.left?.lineNumber : undefined; + const neu = row.cell ? row.cell.newLineNumber : covered.right ? row.right?.lineNumber : undefined; + if (old === undefined && neu === undefined) continue; + let numbers = touched.get(row.fileIndex); + if (!numbers) touched.set(row.fileIndex, numbers = { old: new Set(), new: new Set() }); + if (old !== undefined) numbers.old.add(old); + if (neu !== undefined) numbers.new.add(neu); + } + const ranges: SelectedRange[] = []; + for (const [fileIndex, numbers] of touched) { + let lines: DiffLine[]; + if (oneColumn) lines = [...numbers[oneColumn]].sort((x, y) => x - y).map((n) => (oneColumn === "old" ? { old: n } : { new: n })); + else { + const order = orderOf(textDiff(files, fileIndex)); + const at = (map: Map, n: number) => { + const index = map.get(n); + if (index === undefined) throw new Error(`Line ${n} of file ${fileIndex} is missing from its diff's order`); + return index; + }; + const indices = new Set([...[...numbers.old].map((n) => at(order.oldAt, n)), ...[...numbers.new].map((n) => at(order.newAt, n))]); + lines = [...indices].sort((x, y) => x - y).map((index) => order.lines[index]!); + } + if (!lines.length) continue; + const file = files[fileIndex]; + ranges.push({ fileIndex, oldPath: file?.file.lhs?.path, newPath: file?.file.rhs?.path, + start: lines[0]!, end: lines.at(-1)!, side: oneColumn, lines }); + } + return ranges; +} + +function span(range: SelectedRange, side: "old" | "new"): [number, number] | undefined { + const numbers = range.lines.flatMap((line) => line[side] ?? []); + return numbers.length ? [numbers[0]!, numbers.at(-1)!] : undefined; +} +const removed = (line: DiffLine) => line.new === undefined; +const added = (line: DiffLine) => line.old === undefined; + +/** + * `src/a.rs:L13-R15`, as GitHub names a diff range: L for old line numbers, R for new, an + * unchanged line counting as R. A range with no removed lines is all R, one with no added lines all L. + */ +export function rangeName(range: SelectedRange): string { + const both = !range.side && range.lines.some(removed) && range.lines.some(added); + const side = range.side ?? (both ? undefined : range.lines.some(removed) ? "old" : "new"); + const path = (side === "old" ? range.oldPath : range.newPath ?? range.oldPath)!; + if (side) { + const [first, last] = span(range, side)!; + const letter = side === "old" ? "L" : "R"; + return `${path}:${letter}${first}${first === last ? "" : `-${last}`}`; + } + return `${path}:L${span(range, "old")![0]}-R${span(range, "new")![1]}`; +} +function lineCount(range: SelectedRange): number { + if (!range.side) return range.lines.length; + const [first, last] = span(range, range.side)!; + return last - first + 1; +} +/** A path too long for `width` gives way to its file name. */ +export function selectionLead(message: string, ranges: SelectedRange[], width: number): string { + const count = ranges.reduce((sum, range) => sum + lineCount(range), 0); + const lead = (what: string) => ` ${message ? `${message} · ` : ""}${what} · ${count} ${count === 1 ? "line" : "lines"} `; + if (ranges.length !== 1) return lead(`${ranges.length} files`); + const name = rangeName(ranges[0]!); + const whole = lead(name); + if (measureTextWidth(whole) <= width) return whole; + const colon = name.lastIndexOf(":"); + return lead(`${name.slice(0, colon).split("/").at(-1)}${name.slice(colon)}`); +} + +function sourcesOf(files: (DiffFile | undefined)[], range: SelectedRange) { + const diff = files[range.fileIndex]?.diff; + if (diff?.type !== "text") throw new Error(`File ${range.fileIndex} has no text to copy`); + return { old: diff.lhs ? sourceLines(diff.lhs.text) : [], new: diff.rhs ? sourceLines(diff.rhs.text) : [], + oldTerminated: diff.lhs?.text.endsWith("\n") ?? true, newTerminated: diff.rhs?.text.endsWith("\n") ?? true }; +} +/** Lines left out between selected ones, as by a fold, start a new hunk. */ +function patch(range: SelectedRange, order: DiffOrder, sources: ReturnType): string { + const oldPath = `a/${range.oldPath ?? range.newPath}`; + const newPath = `b/${range.newPath ?? range.oldPath}`; + const quote = (path: string) => /["\\\t\r\n]/.test(path) ? JSON.stringify(path) : path; + const out = [`diff --git ${quote(oldPath)} ${quote(newPath)}`, `--- ${range.oldPath === undefined ? "/dev/null" : quote(oldPath)}`, + `+++ ${range.newPath === undefined ? "/dev/null" : quote(newPath)}`]; + const indexOf = (line: DiffLine) => (line.old !== undefined ? order.oldAt.get(line.old) : order.newAt.get(line.new!))!; + const hunks: number[][] = []; + // A one-column selection includes folded lines within that version's selected span. + const indices = range.side + ? (() => { + const [first, last] = span(range, range.side)!; + const at = range.side === "old" ? order.oldAt : order.newAt; + return Array.from({ length: last - first + 1 }, (_, i) => at.get(first + i)!); + })() + : range.lines.map(indexOf); + for (const index of indices) { + const hunk = hunks.at(-1); + if (hunk && hunk.at(-1) === index - 1) hunk.push(index); + else hunks.push([index]); + } + for (const hunk of hunks) { + const lines = hunk.map((index) => order.lines[index]!); + // As git writes it: a count of 1 left out, and a side with no lines at the line before them. + const side = (key: "old" | "new") => { + const numbers = lines.flatMap((line) => line[key] ?? []); + const start = numbers[0] ?? (key === "old" ? order.oldBefore : order.newBefore)[hunk[0]!]!; + return numbers.length === 1 ? `${start}` : `${start},${numbers.length}`; + }; + out.push(`@@ -${side("old")} +${side("new")} @@`); + for (const line of lines) { + const [before, after] = [line.old === undefined ? undefined : sources.old[line.old - 1]!, + line.new === undefined ? undefined : sources.new[line.new - 1]!]; + const emit = (prefix: string, text: string, side: "old" | "new") => { + out.push(`${prefix}${text}`); + if (line[side] === sources[side].length && !sources[`${side}Terminated`]) out.push("\\ No newline at end of file"); + }; + // A paired line can still differ in formatting or its final newline. + const sameEnding = (line.old === sources.old.length && !sources.oldTerminated) + === (line.new === sources.new.length && !sources.newTerminated); + if (before !== undefined && before === after && sameEnding) emit(" ", after, "new"); + else { + if (before !== undefined) emit("-", before, "old"); + if (after !== undefined) emit("+", after, "new"); + } + } + } + return out.join("\n") + "\n"; +} +function snapshotIdentity(snapshot: NonNullable["lhs"]): string { + switch (snapshot.type) { + case "revision": return snapshot.rev; + case "index": return "index (staged)"; + case "working_tree": return "working tree (uncommitted)"; + case "path": return `path ${JSON.stringify(snapshot.path)}`; + case "empty_tree": return "empty tree"; + } +} +export function rangeReference( + files: (DiffFile | undefined)[], + comparison: NonNullable, + range: SelectedRange, +): string { + const body = patch(range, orderOf(textDiff(files, range.fileIndex)), sourcesOf(files, range)); + const { lhs, rhs } = files[range.fileIndex]!.file; + // Blob IDs; null means unstored working-tree content. + const oid = /^[0-9a-f]+$/i; + const old = lhs?.oid ?? "0".repeat(rhs?.oid.length ?? 40); + const neu = rhs?.oid ?? "0".repeat(lhs?.oid.length ?? 40); + const mode = lhs?.mode === rhs?.mode ? ` ${lhs!.mode}` : ""; + const headers = oid.test(old) && oid.test(neu) ? `index ${old}..${neu}${mode}\n` : ""; + const firstLine = body.indexOf("\n") + 1; + return `Base: ${snapshotIdentity(comparison.lhs)}\nHead: ${snapshotIdentity(comparison.rhs)}\n\n` + + body.slice(0, firstLine) + headers + body.slice(firstLine); +} +export function agentReference( + files: (DiffFile | undefined)[], + comparison: NonNullable, + rows: ViewerRow[], + selection: SourceSelection, +): string { + return selectedRanges(files, rows, selection).map((range) => rangeReference(files, comparison, range)).join("\n"); +} +/** The new version's lines, or the old's when none are new. */ +export function copySelection( + files: (DiffFile | undefined)[], + rows: ViewerRow[], + selection: SourceSelection, +): string { + return selectedRanges(files, rows, selection).flatMap((range) => { + const sources = sourcesOf(files, range); + const side = range.lines.some((line) => line.new !== undefined) ? "new" : "old"; + return range.lines.flatMap((line) => line[side] === undefined ? [] : [sources[side][line[side]! - 1]!]); + }).join("\n"); +} diff --git a/diffr/packages/viewer/src/env.d.ts b/diffr/packages/viewer/src/env.d.ts new file mode 100644 index 000000000..b4b70acc7 --- /dev/null +++ b/diffr/packages/viewer/src/env.d.ts @@ -0,0 +1,9 @@ +/** Globals common to Bun, browsers and Claude Code; no Node APIs. */ +declare class TextEncoder { + encode(input?: string): Uint8Array; +} +declare class TextDecoder { + constructor(label?: string, options?: { fatal?: boolean }); + decode(input?: Uint8Array, options?: { stream?: boolean }): string; +} +declare function setTimeout(handler: () => void, timeout?: number): unknown; diff --git a/diffr/packages/viewer/src/pick.test.ts b/diffr/packages/viewer/src/pick.test.ts new file mode 100644 index 000000000..95ba5bcbd --- /dev/null +++ b/diffr/packages/viewer/src/pick.test.ts @@ -0,0 +1,89 @@ +import { expect, test } from "vitest"; +import { createTestDiffFile, startFor, withIdenticalLines } from "./protocol/fixture"; +import { DiffStore } from "./protocol/store"; +import { visibleRows } from "./viewport/geometry"; +import type { DiffFile } from "./protocol/wire"; +import { dark } from "./theme/themes"; +import { Viewer, type KeyPress } from "./viewer"; + +const named = (path: string) => { + const file = withIdenticalLines(createTestDiffFile(), 30); + file.file.lhs!.path = file.file.rhs!.path = path; + return file; +}; + +function open(files: DiffFile[]) { + const store = new DiffStore(); + store.accept(startFor(files)); + files.forEach((file) => store.accept(file)); + const viewer = new Viewer(store, dark, 100); + viewer.lay({ columns: 160, rows: 6 }); + const press = (...keys: (string | KeyPress)[]) => + keys.forEach((key) => viewer.press(typeof key === "string" ? { key } : key)); + const topFile = () => { + const at = viewer.lay({ columns: 160, rows: 6 }); + return visibleRows(at.geometry, at.top, 1)[0]!.row; + }; + return { viewer, press, topFile }; +} + +const files = () => [named("src/model/store.ts"), named("src/model/selection.ts"), named("src/protocol/wire.ts"), named("docs/notes.md")]; + +test("Ctrl-P lists every changed file, and letters in order narrow it, best match first", () => { + const { viewer, press } = open(files()); + press({ key: "p", ctrl: true }); + expect(viewer.picking).toBe(true); + expect(viewer.pickerState()!.picks.map((pick) => pick.path)).toEqual( + ["docs/notes.md", "src/model/selection.ts", "src/model/store.ts", "src/protocol/wire.ts"]); + press("s", "t", "o"); + const picks = viewer.pickerState()!.picks; + expect(picks[0]!.path).toBe("src/model/store.ts"); + expect(picks.map((pick) => pick.path)).not.toContain("docs/notes.md"); + expect(picks[0]!.indexes.map((index) => picks[0]!.path[index]).join("")).toBe("sto"); +}); + +test("Enter goes to the file under the cursor and closes the picker; Escape closes it where it was", () => { + const { viewer, press, topFile } = open(files()); + press({ key: "p", ctrl: true }, "w", "i", "r", "e", "return"); + expect(viewer.picking).toBe(false); + expect(topFile().key).toBe(`${2}:header`); + press({ key: "p", ctrl: true }, "down", { key: "n", ctrl: true }, { key: "p", ctrl: true }); + expect(viewer.pickerState()!.cursor).toBe(1); + press("escape"); + expect(viewer.picking).toBe(false); + expect(topFile().key).toBe(`${2}:header`); +}); + +test("viewed files sink below the rest of the matches", () => { + const { viewer, press } = open(files()); + viewer.toggleViewedFile(0); + press({ key: "p", ctrl: true }, "s"); + expect(viewer.pickerState()!.picks.at(-1)!.path).toBe("src/model/store.ts"); +}); + +test("⌘P opens the picker too, where the terminal passes Cmd through", () => { + const { viewer, press } = open(files()); + press({ key: "p", meta: true }); + expect(viewer.picking).toBe(true); +}); + +test("]c and [c go to the next and previous change, as in vim; ] or [ with anything else does nothing", () => { + const changed = ["a.ts", "b.ts", "c.ts", "d.ts"].map((path) => { + const file = createTestDiffFile(); + file.file.lhs!.path = file.file.rhs!.path = path; + return file; + }); + const { viewer, press, topFile } = open(changed); + press("]", "c"); + const first = topFile(); + expect(first.hunkStart).toBe(true); + press("]", "c"); + const second = topFile(); + expect(second.hunkStart).toBe(true); + expect(second.key).not.toBe(first.key); + press("[", "c"); + expect(topFile().key).toBe(first.key); + press("]", "j"); + expect(topFile().key).toBe(first.key); + expect(viewer.chording).toBe(false); +}); diff --git a/diffr/tui/packages/hunk/src/diffr/stream.ts b/diffr/packages/viewer/src/protocol/events.ts similarity index 63% rename from diffr/tui/packages/hunk/src/diffr/stream.ts rename to diffr/packages/viewer/src/protocol/events.ts index bd0eaa1b9..d7bb98283 100644 --- a/diffr/tui/packages/hunk/src/diffr/stream.ts +++ b/diffr/packages/viewer/src/protocol/events.ts @@ -1,10 +1,7 @@ -/** Decode stdout incrementally. diffr's records are trusted as sent: only malformed JSON, an - * unknown protocol version, and a stream that ends before `complete` are errors. */ +/** Parse diffr's NDJSON text incrementally. diffr's records are trusted as sent: only malformed + * JSON, an unknown protocol version, and a stream that ends before `complete` are errors. */ import { eventSchema, type DiffEvent } from "./wire"; -export async function* readDiffStream( - chunks: AsyncIterable, -): AsyncGenerator { - const decoder = new TextDecoder("utf-8", { fatal: true }); +export async function* parseDiffEvents(texts: AsyncIterable): AsyncGenerator { let buffer = "", complete = false; function parse(line: string): DiffEvent { @@ -15,8 +12,8 @@ export async function* readDiffStream( if (event.type === "complete") complete = true; return event; } - for await (const chunk of chunks) { - buffer += decoder.decode(chunk, { stream: true }); + for await (const text of texts) { + buffer += text; let end: number; while ((end = buffer.indexOf("\n")) >= 0) { const line = buffer.slice(0, end); @@ -26,7 +23,6 @@ export async function* readDiffStream( if (buffer.length > 128 * 1024 * 1024) throw new Error("diffr event exceeds 128 MiB"); } - buffer += decoder.decode(); if (buffer.trim()) yield parse(buffer); if (!complete) throw new Error("Incomplete diffr stream"); } diff --git a/diffr/tui/packages/hunk/src/diffr/fixture.ts b/diffr/packages/viewer/src/protocol/fixture.ts similarity index 68% rename from diffr/tui/packages/hunk/src/diffr/fixture.ts rename to diffr/packages/viewer/src/protocol/fixture.ts index 60c13de48..e6d94b5a8 100644 --- a/diffr/tui/packages/hunk/src/diffr/fixture.ts +++ b/diffr/packages/viewer/src/protocol/fixture.ts @@ -104,3 +104,59 @@ export function createGuideDiffFile(): DiffFile { } return file; } +/** Rust-style body folds: each covers its body alone, so the lines that open and close a + * construct are leaves around it, like VS Code's rows. */ +export function createFoldedDiffFile(): DiffFile { + const file = createTestDiffFile(); + const lines = [ + "fn outer() {", // 0 + " inner(|| {", // 1 + " a();", // 2 + " b();", // 3 + " });", // 4 + " // trailing", // 5 + " // comment", // 6 + "}", // 7 + ]; + const text = lines.join("\n") + "\n"; + // ids: 10 outer body (lines 1-6), 11 closure body (2-3), 12 comment (5-6); leaves tile the file. + const regions = (changed: boolean): Region[] => [ + leaf(1, 0, 1), + fold(10, [1, 0], [7, 0], [ + leaf(7, 1, 2), + fold(11, [2, 0], [4, 0], [leaf(2, 2, 3), leaf(3, 3, 4, changed ? [line(3, 8, 12)] : [])]), + leaf(4, 4, 5), + // A whole-node fold ending at end of line covers its last line too. + fold(12, [5, 4], [6, 14], [leaf(5, 5, 7)], "Comment", ["summarize:docstring"]), + ]), + leaf(6, 7, 8), + ]; + if (file.diff.type !== "text") throw new Error("fixture is not a text diff"); + file.diff.lhs = { text, syntax: [], root: root(regions(false))}; + file.diff.rhs = { text, syntax: [], root: root(regions(true))}; + file.diff.stats = { textual: { added: 1, removed: 0 }, visible: { added: 1, removed: 0 } }; + return file; +} +/** + * The shape diffr emits for a new, summarized function (src/tags/mod.rs, crates/diffr-core/src/protocol/project.rs + * `syntax_spans`): a one-line docstring region tagged `summarize:docstring`, collapsed + * with an empty label, sharing fold state 4 with the collapsed function fold after it, right side only. + */ +export function createBundledDiffFile(): DiffFile { + const file = createTestDiffFile(); + if (file.diff.type !== "text") throw new Error("fixture is not a text diff"); + const text = [ + "];", // 0 + "/// The built-in rule for a path.", // 1 + "pub(crate) fn from_path(path: &str) -> Option<&str> {", // 2 + " None", // 3 + "}", // 4 + ].join("\n") + "\n"; + const docstring: Region = { + ...leaf(38, 1, 2), fold_state_id: 4, tags: ["summarize:docstring"], visibility: { collapsed: true, label: "" }, + }; + const body = fold(4, [3, 0], [4, 0], [leaf(5, 3, 4)], "look up path\nreturn None", ["summarize:function"], true); + file.diff.rhs = { text, syntax: [], root: root([leaf(3, 0, 1), docstring, leaf(7, 2, 3), body, leaf(6, 4, 5)])}; + file.diff.lhs = undefined; + return file; +} diff --git a/diffr/tui/packages/hunk/src/diffr/store.test.ts b/diffr/packages/viewer/src/protocol/store.test.ts similarity index 94% rename from diffr/tui/packages/hunk/src/diffr/store.test.ts rename to diffr/packages/viewer/src/protocol/store.test.ts index 692de89db..3ffc437d5 100644 --- a/diffr/tui/packages/hunk/src/diffr/store.test.ts +++ b/diffr/packages/viewer/src/protocol/store.test.ts @@ -1,4 +1,4 @@ -import { expect, test } from "bun:test"; +import { expect, test } from "vitest"; import { DiffStore } from "./store"; import { createTestDiffFile, startFor } from "./fixture"; @@ -40,6 +40,6 @@ test("an unsubscribed viewer is not notified by a pending batch", async () => { const unsubscribe = store.subscribe(() => calls++); store.fail("stopped"); unsubscribe(); - await Bun.sleep(30); + await new Promise(resolve => setTimeout(resolve, 30)); expect(calls).toBe(0); }); diff --git a/diffr/tui/packages/hunk/src/diffr/store.ts b/diffr/packages/viewer/src/protocol/store.ts similarity index 93% rename from diffr/tui/packages/hunk/src/diffr/store.ts rename to diffr/packages/viewer/src/protocol/store.ts index 32d5f2f89..1a21884ca 100644 --- a/diffr/tui/packages/hunk/src/diffr/store.ts +++ b/diffr/packages/viewer/src/protocol/store.ts @@ -1,9 +1,8 @@ /** Hold streamed files separately from presentation state and notify React in batches. */ -import { fileIdentity, filePath, type FileChange, type DiffEvent, type DiffFile } from "./wire"; -type StartEvent = Extract; +import { fileIdentity, filePath, type ComparisonEnd, type FileChange, type DiffEvent, type DiffFile } from "./wire"; export interface Snapshot { /** The two ends of the comparison, from the start event. */ - comparison: { lhs: StartEvent["lhs"]; rhs: StartEvent["rhs"] } | null; + comparison: { lhs: ComparisonEnd; rhs: ComparisonEnd } | null; /** The manifest, in comparison order. `files` and `failures` are indexed like it. */ inventory: FileChange[]; /** Each manifest file's diff, once it arrives. */ diff --git a/diffr/tui/packages/hunk/src/diffr/wire.ts b/diffr/packages/viewer/src/protocol/wire.ts similarity index 99% rename from diffr/tui/packages/hunk/src/diffr/wire.ts rename to diffr/packages/viewer/src/protocol/wire.ts index 85b68b510..cc899b93c 100644 --- a/diffr/tui/packages/hunk/src/diffr/wire.ts +++ b/diffr/packages/viewer/src/protocol/wire.ts @@ -124,6 +124,7 @@ export type Span = z.infer; export type SyntaxSpan = z.infer; export type Stats = z.infer; export type LineCounts = z.infer; +export type ComparisonEnd = z.infer; export type DiffEvent = z.infer; export type FileEvent = Extract; /** A file record that carries a diff; failures are kept separately by the store. */ diff --git a/diffr/packages/viewer/src/search.test.ts b/diffr/packages/viewer/src/search.test.ts new file mode 100644 index 000000000..2a1e31d97 --- /dev/null +++ b/diffr/packages/viewer/src/search.test.ts @@ -0,0 +1,86 @@ +import { expect, test } from "vitest"; +import { createGuideDiffFile, startFor } from "./protocol/fixture"; +import { DiffStore } from "./protocol/store"; +import { visibleRows } from "./viewport/geometry"; +import { dark } from "./theme/themes"; +import { Viewer, type KeyPress } from "./viewer"; + +/** The guide file: `log();` and `keep();` sit in a folded gap, `one();` and `two();` in a folded body. */ +function open() { + const store = new DiffStore(), file = createGuideDiffFile(); + store.accept(startFor([file])); + store.accept(file); + const viewer = new Viewer(store, dark, 100); + viewer.lay({ columns: 160, rows: 4 }); + const type = (...keys: string[]) => keys.forEach((key) => viewer.press({ key } satisfies KeyPress)); + const top = () => { + const at = viewer.lay({ columns: 160, rows: 4 }); + return visibleRows(at.geometry, at.top, 1)[0]!.row; + }; + const shown = () => viewer.lay({ columns: 160, rows: 4 }).rows; + return { viewer, type, top, shown }; +} + +test("/ opens a prompt that takes every key, counts as it goes, and Enter lands on the first match", () => { + const { viewer, type, top } = open(); + type("/", "k", "e", "e", "p"); + expect(viewer.prompting).toBe(true); + expect(viewer.searchState()).toEqual({ prompt: "keep", count: 2 }); + type("return"); + expect(viewer.prompting).toBe(false); + expect(viewer.searchState()).toEqual({ pattern: "keep", at: 1, total: 2, files: 1 }); + expect(top().left?.lineNumber).toBe(7); + const litBg = (spans: { text: string; bg?: string }[]) => spans.filter((span) => span.text === "keep").map((span) => span.bg); + expect(litBg(top().left!.spans)).toEqual([dark.searchCurrent]); + expect(litBg(top().right!.spans)).toEqual([dark.searchMatch]); +}); + +test("of several matches on one line, only the one the view is on is solid, in a different hue from the rest", () => { + const { viewer, type, top } = open(); + expect(dark.searchCurrent).not.toBe(dark.searchMatch); + type("/", "e", "v", "e", "n", "t", "return"); + type("/", "e", "return"); + const line = () => top().left!.spans; + while (!line().map((span) => span.text).join("").includes("if event.open")) type("n"); + const lit = () => line().filter((span) => span.lit).map((span) => span.bg); + expect(lit()).toEqual([dark.searchCurrent, dark.searchMatch, dark.searchMatch]); + type("n"); + expect(lit()).toEqual([dark.searchMatch, dark.searchCurrent, dark.searchMatch]); +}); + +test("n opens the fold hiding the next match, and N walks back, wrapping at either end", () => { + const { viewer, type, top, shown } = open(); + expect(shown().some((row) => row.right?.spans.map((span) => span.text).join("").includes("two();"))).toBe(false); + type("/", "t", "w", "o", "(", "return"); + expect(viewer.searchState()).toMatchObject({ at: 1, total: 2 }); + expect(top().right?.lineNumber).toBe(12); + expect(shown().some((row) => row.right?.spans.map((span) => span.text).join("").includes("two();"))).toBe(true); + type("n"); + expect(viewer.searchState()).toMatchObject({ at: 2 }); + type("n"); + expect(viewer.searchState()).toMatchObject({ at: 1 }); + type("N"); + expect(viewer.searchState()).toMatchObject({ at: 2 }); +}); + +test("file paths are searched too, and a capital makes the pattern match case", () => { + const { viewer, type, top } = open(); + type("/", "d", "e", "m", "o", "return"); + expect(viewer.searchState()).toEqual({ pattern: "demo", at: 1, total: 1, files: 1 }); + expect(top().key).toBe("0:header"); + type("/", "K", "e", "e", "p", "return"); + expect(viewer.searchState()).toEqual({ pattern: "Keep", at: 0, total: 0, files: 0 }); +}); + +test("Escape leaves the prompt without searching, and Backspace on an empty prompt leaves it too", () => { + const { viewer, type } = open(); + type("/", "o", "escape"); + expect(viewer.prompting).toBe(false); + expect(viewer.searchState()).toBeUndefined(); + type("/", "o", "backspace"); + expect(viewer.searchState()).toEqual({ prompt: "", count: 0 }); + type("backspace"); + expect(viewer.prompting).toBe(false); + type("/", "q"); + expect(viewer.searchState()).toEqual({ prompt: "q", count: 0 }); +}); diff --git a/diffr/packages/viewer/src/terminal/sanitize.test.ts b/diffr/packages/viewer/src/terminal/sanitize.test.ts new file mode 100644 index 000000000..0574f91be --- /dev/null +++ b/diffr/packages/viewer/src/terminal/sanitize.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, test } from "vitest"; +import { sanitizeTerminalLine, sanitizeTerminalSpans } from "./sanitize"; + +const OSC52_CLIPBOARD = "\x1b]52;c;SGVsbG8=\x07"; +const OSC_ST = "\x1b]8;;https://example.test\x1b\\"; +const CSI_CLEAR_SCREEN = "\x1b[2J"; +const DCS_PAYLOAD = "\x1bPqpayload\x1b\\"; +const APC_PAYLOAD = "\x1b_payload\x1b\\"; +const PM_PAYLOAD = "\x1b^payload\x1b\\"; +const SOS_PAYLOAD = "\x1bXpayload\x1b\\"; +const C1_OSC = "\x9d52;c;SGVsbG8=\x07"; +const C1_CSI = "\x9b2J"; +const C1_DCS = "\x90payload\x9c"; + +function expectNoUnsafeTerminalControls(text: string) { + expect(text).not.toContain("\x1b"); + expect(text).not.toContain("\x07"); + expect(text).not.toContain("\x08"); + expect(text).not.toContain("\x0b"); + expect(text).not.toContain("\x0c"); + expect(text).not.toContain("\x0d"); + expect(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]/.test(text)).toBe(false); +} + +describe("sanitize", () => { + test("removes terminal control strings without dropping surrounding text", () => { + const input = [ + "before", + OSC52_CLIPBOARD, + OSC_ST, + CSI_CLEAR_SCREEN, + DCS_PAYLOAD, + APC_PAYLOAD, + PM_PAYLOAD, + SOS_PAYLOAD, + C1_OSC, + C1_CSI, + C1_DCS, + "after", + ].join(""); + + const output = sanitizeTerminalLine(input); + + expect(output).toContain("before"); + expect(output).toContain("after"); + expectNoUnsafeTerminalControls(output); + }); + + test("neutralizes visual spoofing controls", () => { + const output = sanitizeTerminalLine("safe\rOVERWRITE\bhidden\x1b"); + + expect(output).toContain("safe"); + expect(output).toContain("OVERWRITE"); + expect(output).toContain("hidden"); + expectNoUnsafeTerminalControls(output); + }); + + test("returns an already-safe mutable span array unchanged", () => { + const spans = [{ text: "safe", fg: "#fff" }]; + + expect(sanitizeTerminalSpans(spans)).toBe(spans); + }); + + test("sanitizes span text while preserving styling metadata", () => { + const spans = [ + { text: `before${OSC52_CLIPBOARD}`, fg: "#fff" }, + { text: "\x1b[2J" }, + { text: "after", bg: "#000" }, + ]; + + const output = sanitizeTerminalSpans(spans); + + expect(output).toEqual([ + { text: "before", fg: "#fff" }, + { text: "after", bg: "#000" }, + ]); + }); +}); diff --git a/diffr/packages/viewer/src/terminal/sanitize.ts b/diffr/packages/viewer/src/terminal/sanitize.ts new file mode 100644 index 000000000..420e9681f --- /dev/null +++ b/diffr/packages/viewer/src/terminal/sanitize.ts @@ -0,0 +1,44 @@ +const controlCodeRegex = /[\x00-\x1f\x7f-\x9f]/; +// Keep these global regexes private and use them only with String#replace below. +// Calling test/exec on shared /g regexes would make lastIndex stateful between calls. +const sevenBitControlStrings = + /\x1b(?:\][\s\S]*?(?:\x07|\x1b\\|\x9c)|[PX^_][\s\S]*?(?:\x1b\\|\x9c)|\[[0-?]*[ -/]*[@-~])/g; +const c1ControlStrings = /[\x90\x98\x9d\x9e\x9f][\s\S]*?(?:\x07|\x1b\\|\x9c)/g; +const c1Csi = /\x9b[0-?]*[ -/]*[@-~]/g; +/** Every control character but tab. */ +const controlCharacters = /[\x00-\x08\x0a-\x1f\x7f-\x9f]/g; + +/** Sanitize a single terminal row or cell where newlines must never be preserved. */ +export function sanitizeTerminalLine(text: string) { + if (!controlCodeRegex.test(text)) { + return text; + } + return text + .replace(sevenBitControlStrings, "") + .replace(c1ControlStrings, "") + .replace(c1Csi, "") + .replace(controlCharacters, ""); +} + +/** Sanitize render spans while preserving their non-text styling metadata. */ +export function sanitizeTerminalSpans( + spans: T[], +): T[] { + let sanitized: T[] | null = null; + for (let index = 0; index < spans.length; index += 1) { + const span = spans[index]!; + const text = sanitizeTerminalLine(span.text); + if (text === span.text && text.length > 0) { + sanitized?.push(span); + continue; + } + + // Delay the output copy until the first actual change so already-safe immutable span arrays can + // flow through hot rendering paths without allocating either an array or replacement objects. + sanitized ??= spans.slice(0, index); + if (text.length > 0) { + sanitized.push({ ...span, text } as T); + } + } + return sanitized ?? spans; +} diff --git a/diffr/tui/packages/hunk/src/ui/diff/styledSpanLayout.ts b/diffr/packages/viewer/src/terminal/spans.ts similarity index 60% rename from diffr/tui/packages/hunk/src/ui/diff/styledSpanLayout.ts rename to diffr/packages/viewer/src/terminal/spans.ts index 4261736f3..a36c41c44 100644 --- a/diffr/tui/packages/hunk/src/ui/diff/styledSpanLayout.ts +++ b/diffr/packages/viewer/src/terminal/spans.ts @@ -1,5 +1,4 @@ -// Plans terminal-cell slicing and wrapping for styled diff spans. -import { sanitizeTerminalSpans } from "../../lib/terminalText"; +import { sanitizeTerminalSpans } from "./sanitize"; import { isPrintableAsciiText, measureSanitizedTextWidth, @@ -7,58 +6,32 @@ import { sliceSanitizedTextByWidth, textClusters, wrapSanitizedTextByWidth, -} from "../lib/text"; -import type { RenderSpan } from "./diffRowModel"; +} from "./text"; +interface StyledSpan { + text: string; +} + +/** Spans whose every field but `text` matches join into one; a missing field equals undefined. */ +function sameStyle(a: StyledSpan, b: StyledSpan) { + const left = a as unknown as Record, right = b as unknown as Record; + for (const key in left) if (key !== "text" && left[key] !== right[key]) return false; + for (const key in right) if (key !== "text" && left[key] !== right[key]) return false; + return true; +} /** Append a styled span while preserving color-run coalescing. */ -function appendRenderSpan(target: RenderSpan[], span: RenderSpan) { +function appendSpan(target: S[], span: S) { const previous = target.at(-1); - if ( - previous && - previous.guide === span.guide && - previous.brace === span.brace && - previous.fg === span.fg && - previous.bg === span.bg && - previous.transformFg === span.transformFg - ) { + if (previous && sameStyle(previous, span)) { previous.text += span.text; } else { target.push(span); } } -/** Return the first or last scalar in one non-empty string. */ -function boundaryScalar(text: string, first: boolean) { - if (first) { - const codePoint = text.codePointAt(0); - return codePoint === undefined ? "" : String.fromCodePoint(codePoint); - } - - let scalar = ""; - for (const candidate of text) { - scalar = candidate; - } - return scalar; -} - -/** Return whether a styled-span boundary may divide one grapheme cluster. */ -function spansMaySplitGrapheme(spans: RenderSpan[]) { - for (let index = 1; index < spans.length; index += 1) { - const left = boundaryScalar(spans[index - 1]?.text ?? "", false); - const right = boundaryScalar(spans[index]?.text ?? "", true); - if ( - (left && measureSimpleSanitizedTextWidth(left) === null) || - (right && measureSimpleSanitizedTextWidth(right) === null) - ) { - return true; - } - } - return false; -} - /** Merge indivisible graphemes while preserving the style where each cluster starts. */ -function mergeCrossSpanGraphemes(spans: RenderSpan[]) { - const normalized: RenderSpan[] = []; +function mergeCrossSpanGraphemes(spans: S[]) { + const normalized: S[] = []; const text = spans.map((span) => span.text).join(""); let sourceIndex = 0; let sourceEnd = spans[0]?.text.length ?? 0; @@ -71,32 +44,27 @@ function mergeCrossSpanGraphemes(spans: RenderSpan[]) { } const source = spans[sourceIndex]; if (source) { - appendRenderSpan(normalized, { ...source, text: cluster }); + appendSpan(normalized, { ...source, text: cluster }); } cursor += cluster.length; } return normalized; } -/** Merge only indivisible graphemes that may cross styled-span boundaries. */ -function preserveCrossSpanGraphemes(spans: RenderSpan[]) { - return spansMaySplitGrapheme(spans) ? mergeCrossSpanGraphemes(spans) : spans; -} - /** Slice styled spans to one visible window while preserving color runs. */ -export function sliceSpansWindow( - spans: RenderSpan[], +export function sliceSpansWindow( + spans: S[], offset: number, width: number, ) { if (width <= 0) { return { - spans: [] as RenderSpan[], + spans: [] as S[], usedWidth: 0, }; } - const sliced: RenderSpan[] = []; + const sliced: S[] = []; let remainingOffset = Math.max(0, offset); let remaining = width; let usedWidth = 0; @@ -108,7 +76,7 @@ export function sliceSpansWindow( const spanWidth = measureSanitizedTextWidth(span.text); if (spanWidth === 0) { - appendRenderSpan(sliced, { ...span }); + appendSpan(sliced, { ...span }); continue; } @@ -119,8 +87,8 @@ export function sliceSpansWindow( if (remainingOffset === 0 && spanWidth <= remaining) { // Preserve the full safe span without re-slicing its graphemes. Clone before coalescing so - // appendRenderSpan can never mutate the caller-owned highlighted span object. - appendRenderSpan(sliced, { ...span }); + // appendSpan can never mutate the caller-owned highlighted span object. + appendSpan(sliced, { ...span }); remaining -= spanWidth; usedWidth += spanWidth; continue; @@ -142,7 +110,7 @@ export function sliceSpansWindow( text: visible.text, }; - appendRenderSpan(sliced, nextSpan); + appendSpan(sliced, nextSpan); remaining -= visible.width; usedWidth += visible.width; @@ -160,12 +128,12 @@ export function sliceSpansWindow( const SINGLE_PASS_WRAP_LINE_THRESHOLD = 8; /** Wrap styled spans into visual lines while preserving color runs across splits. */ -export function wrapSpans(spans: RenderSpan[], width: number) { +export function wrapSpans(spans: S[], width: number) { if (width <= 0) { - return [[]] as RenderSpan[][]; + return [[]] as S[][]; } - const lines: RenderSpan[][] = [[]]; + const lines: S[][] = [[]]; let current = lines[0]!; let remaining = width; const safeSpans = sanitizeTerminalSpans(spans); @@ -189,7 +157,7 @@ export function wrapSpans(spans: RenderSpan[], width: number) { const simpleSpanWidth = simpleSpanWidths[spanIndex] ?? null; const spanWidth = simpleSpanWidth ?? measureSanitizedTextWidth(span.text); if (spanWidth === 0) { - appendRenderSpan(current, { ...span }); + appendSpan(current, { ...span }); continue; } @@ -210,7 +178,7 @@ export function wrapSpans(spans: RenderSpan[], width: number) { remaining = width; } if (chunk.text.length > 0) { - appendRenderSpan(current, { ...span, text: chunk.text }); + appendSpan(current, { ...span, text: chunk.text }); } remaining -= chunk.width; } @@ -253,7 +221,7 @@ export function wrapSpans(spans: RenderSpan[], width: number) { ...span, text: visible.text, }; - appendRenderSpan(current, nextSpan); + appendSpan(current, nextSpan); offset += visible.width; remaining -= visible.width; @@ -263,36 +231,3 @@ export function wrapSpans(spans: RenderSpan[], width: number) { return lines; } -/** Count wrapped visual lines without allocating the styled line arrays used by rendering. */ -export function measureWrappedSpansLineCount( - spans: RenderSpan[], - width: number, -) { - if (width <= 0) { - return 1; - } - - let lineCount = 1; - let remaining = width; - let currentLineHasContent = false; - const safeSpans = preserveCrossSpanGraphemes(sanitizeTerminalSpans(spans)); - for (const span of safeSpans) { - // Preserve zero-width span presence across styled runs so a later over-wide grapheme makes the - // same continuation decision as wrapSpans' concrete line arrays. - for (const chunk of wrapSanitizedTextByWidth( - span.text, - width, - remaining, - currentLineHasContent, - )) { - if (chunk.startsNewLine) { - lineCount += 1; - remaining = width; - currentLineHasContent = false; - } - remaining -= chunk.width; - currentLineHasContent ||= chunk.text.length > 0; - } - } - return lineCount; -} diff --git a/diffr/tui/packages/hunk/src/ui/lib/text.ts b/diffr/packages/viewer/src/terminal/text.ts similarity index 73% rename from diffr/tui/packages/hunk/src/ui/lib/text.ts rename to diffr/packages/viewer/src/terminal/text.ts index 95cd1f9b8..d47e81ee5 100644 --- a/diffr/tui/packages/hunk/src/ui/lib/text.ts +++ b/diffr/packages/viewer/src/terminal/text.ts @@ -1,6 +1,6 @@ import { eastAsianWidth } from "get-east-asian-width"; import stringWidth from "string-width"; -import { sanitizeTerminalLine } from "../../lib/terminalText"; +import { sanitizeTerminalLine } from "./sanitize"; const printableAsciiRegex = /^[\u0020-\u007E]*$/; @@ -254,98 +254,12 @@ export function measureTextWidth(text: string) { return measureSanitizedTextWidth(sanitizeTerminalLine(text)); } -/** Wrap plain prose to terminal-cell width, preferring word boundaries. */ -export function wrapText(text: string, width: number) { - if (width <= 0) { - return [""]; - } - - const normalized = sanitizeTerminalLine(text).trim().replace(/\s+/g, " "); - if (normalized.length === 0) { - return [""]; - } - - const words = normalized.split(" "); - const lines: string[] = []; - let current = ""; - let currentWidth = 0; - - /** Commit the current prose row before starting another one. */ - const pushCurrent = () => { - if (current.length > 0) { - lines.push(current); - current = ""; - currentWidth = 0; - } - }; - - for (const word of words) { - const wordWidth = measureTextWidth(word); - - if (wordWidth > width) { - pushCurrent(); - let offset = 0; - while (offset < wordWidth) { - const chunk = sliceTextByWidth(word, offset, width); - if (chunk.width <= 0) { - // Width is narrower than one cluster; keep the remainder on one - // line (fitText clamps at render time) instead of dropping it. - const rest = sliceTextByWidth(word, offset, Number.MAX_SAFE_INTEGER); - if (rest.text.length > 0) { - lines.push(rest.text); - } - break; - } - lines.push(chunk.text); - offset += chunk.width; - } - continue; - } - - const nextWidth = - current.length === 0 ? wordWidth : currentWidth + 1 + wordWidth; - if (nextWidth <= width) { - current = current.length === 0 ? word : `${current} ${word}`; - currentWidth = nextWidth; - continue; - } - - pushCurrent(); - current = word; - currentWidth = wordWidth; - } - - pushCurrent(); - return lines.length > 0 ? lines : [""]; -} - export interface WrappedTextChunk { text: string; width: number; startsNewLine: boolean; } -/** - * Split text into line-sized terminal-cell chunks in one grapheme traversal. - * - * The first chunk may use the remaining cells on an existing line. Later chunks use the full - * line width. `startsNewLine` lets styled-span callers preserve style while sharing line state - * across adjacent spans. - */ -export function wrapTextByWidth( - text: string, - lineWidth: number, - firstLineWidth = lineWidth, - firstLineHasContent = false, -): WrappedTextChunk[] { - return wrapSanitizedTextByWidth( - sanitizeTerminalLine(text), - lineWidth, - firstLineWidth, - firstLineHasContent, - ); -} - /** Split already-sanitized text by terminal width without rescanning for control sequences. */ export function wrapSanitizedTextByWidth( safeText: string, @@ -551,88 +465,3 @@ export function sliceSanitizedTextByWidth( return { text: visibleText, width: usedWidth }; } -/** - * Convert an inclusive terminal-cell range into string slice bounds (`endIndex` exclusive). - * - * Mouse selection coordinates are terminal cells while `String#slice` counts UTF-16 code units, - * and the two drift apart on non-ASCII lines: a full-width CJK character is one code unit but - * two cells, and an emoji cluster can be many code units but two cells. Clusters only partially - * covered by the cell range are included in full so a selection can never split a wide - * character, and zero-width clusters at the range start are kept so invisible characters - * round-trip through the clipboard. Indices refer to `text` as given; callers pass rendered - * lines that are already sanitized (sanitizing here could shift the returned indices off the - * caller's string). Callers must pass a normalized range (`startCell <= endCell`); inverted - * ranges are unspecified. - */ -export function cellRangeToCharRange( - text: string, - startCell: number, - endCell: number, -) { - const safeStartCell = Math.max(0, startCell); - - if (printableAsciiRegex.test(text)) { - const startIndex = Math.min(text.length, safeStartCell); - return { - startIndex, - endIndex: Math.min(text.length, Math.max(startIndex, endCell + 1)), - }; - } - - let cellCursor = 0; - let unitCursor = 0; - let startIndex = -1; - let endIndex = text.length; - - for (const cluster of textClusters(text)) { - // The current cluster starts past the end cell, so everything before it is the slice end. - if (cellCursor > endCell) { - endIndex = unitCursor; - break; - } - - const clusterWidth = measureClusterWidth(cluster); - // Zero-width clusters (e.g. U+200B) occupy no cell, so they never satisfy the covering - // check; attach them to a range that starts at their position instead of dropping them. - const coversStart = - clusterWidth > 0 - ? cellCursor + clusterWidth > safeStartCell - : cellCursor >= safeStartCell; - if (startIndex < 0 && coversStart) { - startIndex = unitCursor; - } - - cellCursor += clusterWidth; - unitCursor += cluster.length; - } - - // A range starting past the end of the line resolves to an empty slice at the text end. - if (startIndex < 0) { - startIndex = text.length; - } - - return { startIndex, endIndex: Math.max(startIndex, endIndex) }; -} - -/** Clamp text to a fixed width using a cell-aware overflow marker. */ -export function fitText(text: string, width: number, overflowMarker = ".") { - const safeText = sanitizeTerminalLine(text); - if (width <= 0) { - return ""; - } - - if (measureTextWidth(safeText) <= width) { - return safeText; - } - - const safeMarker = sanitizeTerminalLine(overflowMarker); - const marker = sliceTextByWidth(safeMarker, 0, width); - const textWidth = Math.max(0, width - marker.width); - return `${sliceTextByWidth(safeText, 0, textWidth).text}${marker.text}`; -} - -/** Clamp and then right-pad text to an exact width. */ -export function padText(text: string, width: number) { - const trimmed = fitText(text, width); - return `${trimmed}${" ".repeat(Math.max(0, width - measureTextWidth(trimmed)))}`; -} diff --git a/diffr/tui/packages/hunk/src/diffr/theme.ts b/diffr/packages/viewer/src/theme/palette.ts similarity index 78% rename from diffr/tui/packages/hunk/src/diffr/theme.ts rename to diffr/packages/viewer/src/theme/palette.ts index da7daff1c..24fca259a 100644 --- a/diffr/tui/packages/hunk/src/diffr/theme.ts +++ b/diffr/packages/viewer/src/theme/palette.ts @@ -1,10 +1,3 @@ -/** Load Helix themes (TOML keyed by tree-sitter capture names) into the painter's palette. */ -import type { FoldTint } from "./regions"; -import { readFileSync } from "node:fs"; -import onedark from "../../../../themes/onedark.toml" with { type: "text" }; -import onelight from "../../../../themes/onelight.toml" with { type: "text" }; -import gruvbox from "../../../../themes/gruvbox.toml" with { type: "text" }; -import solarized_light from "../../../../themes/solarized_light.toml" with { type: "text" }; export interface Style { fg?: string; bg?: string; @@ -30,9 +23,18 @@ export interface Palette { fileHeaderDir: string; /** Sidebar highlight for the active file. */ highlight: string; + /** Selected lines: blue, so a selection reads apart from additions and deletions. */ + selection: string; addition: string; deletion: string; modification: string; + /** A viewed line's tints: halfway back to the background, so read code recedes. */ + readAddition: string; + readDeletion: string; + /** Every match of the last search, a wash of the accent; the one the view is on, solid in another hue. */ + searchMatch: string; + searchCurrent: string; + searchCurrentText: string; addWord: string; deleteWord: string; addedText: string; @@ -62,8 +64,7 @@ interface RawStyle { bg?: string; modifiers?: string[]; } -export function parseHelixTheme(text: string, name: string): HelixTheme { - const raw = Bun.TOML.parse(text) as Record; +export function helixTheme(raw: Record, name: string): HelixTheme { const palette = new Map(); const rawPalette = raw.palette; if (rawPalette && typeof rawPalette === "object") @@ -145,9 +146,15 @@ export function paletteFromHelix(theme: HelixTheme): Palette { fileHeader, fileHeaderDir: mix(fg, fileHeader, 0.3), highlight: selection, + selection: mix(bg, ansi["light-blue"], 0.35), addition: mix(bg, plus, 0.12), deletion: mix(bg, minus, 0.12), modification: mix(bg, delta, 0.12), + readAddition: mix(bg, plus, 0.06), + readDeletion: mix(bg, minus, 0.06), + searchMatch: mix(bg, accent, 0.4), + searchCurrent: scopeFg(theme, "warning") ?? delta, + searchCurrentText: isLight ? fg : bg, addWord: mix(bg, plus, 0.28), deleteWord: mix(bg, minus, 0.28), addedText: plus, @@ -160,7 +167,6 @@ export function paletteFromHelix(theme: HelixTheme): Palette { syntax: (capture) => scopeFg(theme, capture), }; } -/** Bundled Helix themes under tui/themes; the two defaults are aliases. */ export const bundledThemes: Record = { "default-dark": "onedark", "default-light": "onelight", @@ -169,35 +175,9 @@ export const bundledThemes: Record = { gruvbox: "gruvbox", solarized_light: "solarized_light", }; -const themeSources: Record = { onedark, onelight, gruvbox, solarized_light }; -export function loadBundledTheme(name: string): Palette { - const file = bundledThemes[name]; - if (!file) throw new Error(`Unknown theme ${name}; bundled themes: ${Object.keys(bundledThemes).join(", ")}`); - return paletteFromHelix(parseHelixTheme(themeSources[file], name)); -} -export function loadThemeFile(path: string): Palette { - return paletteFromHelix(parseHelixTheme(readFileSync(path, "utf8"), path)); -} -export interface ThemeSet { - initial: Palette; - dark: Palette; - light: Palette; -} -/** Resolve the theme diffr's config names, plus the two defaults the `t` key toggles between. */ -export function themesFromConfig(config: { name: string; path: string | null }): ThemeSet { - const dark = loadBundledTheme("default-dark"), light = loadBundledTheme("default-light"); - const initial = config.path ? loadThemeFile(config.path) : loadBundledTheme(config.name); - return { initial, dark, light }; -} export function themeConfig(show: unknown): { name: string; path: string | null } { const theme = (show as { theme?: { name?: unknown; path?: unknown } }).theme; if (!theme || typeof theme.name !== "string" || (theme.path !== null && theme.path !== undefined && typeof theme.path !== "string")) throw new Error("diffr config show did not include a theme section"); return { name: theme.name, path: theme.path ?? null }; } - -/** 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.bg; -} diff --git a/diffr/tui/packages/hunk/src/diffr/theme.test.ts b/diffr/packages/viewer/src/theme/themes.test.ts similarity index 79% rename from diffr/tui/packages/hunk/src/diffr/theme.test.ts rename to diffr/packages/viewer/src/theme/themes.test.ts index 956419ab0..cee628ec9 100644 --- a/diffr/tui/packages/hunk/src/diffr/theme.test.ts +++ b/diffr/packages/viewer/src/theme/themes.test.ts @@ -1,5 +1,6 @@ -import { expect, test } from "bun:test"; -import { bundledThemes, colorOf, foldBackground, loadBundledTheme, paletteFromHelix, parseHelixTheme, scopeStyle, themeConfig, themesFromConfig } from "./theme"; +import { expect, test } from "vitest"; +import { bundledThemes, colorOf, paletteFromHelix, scopeStyle } from "./palette"; +import { loadBundledTheme, parseHelixTheme } from "./themes"; const sample = ` "keyword" = { fg = "red", modifiers = ["bold"] } "keyword.control" = { fg = "purple" } @@ -55,16 +56,6 @@ test("bundled themes load, the defaults are aliases, and captures are visibly di expect(loadBundledTheme("default-dark").bg).toBe(loadBundledTheme("onedark").bg); expect(() => loadBundledTheme("dracula")).toThrow("Unknown theme dracula"); }); -test("the theme set follows diffr's config and errors on a missing section", () => { - expect(themeConfig({ theme: { name: "gruvbox", path: null } })).toEqual({ name: "gruvbox", path: null }); - expect(() => themeConfig({ folds: {} })).toThrow("theme section"); - const set = themesFromConfig({ name: "gruvbox", path: null }); - expect(set.initial.name).toBe("gruvbox"); - expect(set.dark.name).toBe("default-dark"); - expect(set.light.name).toBe("default-light"); - 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; @@ -78,8 +69,15 @@ test("in every bundled theme a file header stands apart from code and folds, and 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); } }); +test("in every bundled theme selected lines read blue, unlike any change tint", () => { + const rgb = (hex: string) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16)); + for (const name of Object.keys(bundledThemes)) { + const palette = loadBundledTheme(name); + const [r, g, b] = rgb(palette.selection); + expect(b - Math.max(r, g)).toBeGreaterThanOrEqual(0x10); + } +}); diff --git a/diffr/packages/viewer/src/theme/themes.ts b/diffr/packages/viewer/src/theme/themes.ts new file mode 100644 index 000000000..2e7a433c2 --- /dev/null +++ b/diffr/packages/viewer/src/theme/themes.ts @@ -0,0 +1,20 @@ +/** Load Helix themes (TOML keyed by tree-sitter capture names) into the painter's palette. */ +/// +import { parse } from "smol-toml"; +import { bundledThemes, helixTheme, paletteFromHelix, type HelixTheme, type Palette } from "./palette"; +import onedark from "../../themes/onedark.toml" with { type: "text" }; +import onelight from "../../themes/onelight.toml" with { type: "text" }; +import gruvbox from "../../themes/gruvbox.toml" with { type: "text" }; +import solarized_light from "../../themes/solarized_light.toml" with { type: "text" }; +export function parseHelixTheme(text: string, name: string): HelixTheme { + return helixTheme(parse(text), name); +} +const themeSources: Record = { onedark, onelight, gruvbox, solarized_light }; +export function loadBundledTheme(name: string): Palette { + const file = bundledThemes[name]; + if (!file) throw new Error(`Unknown theme ${name}; bundled themes: ${Object.keys(bundledThemes).join(", ")}`); + return paletteFromHelix(parseHelixTheme(themeSources[file]!, name)); +} +/** The bundled defaults, for tests and the settings screen. */ +export const dark: Palette = loadBundledTheme("default-dark"); +export const light: Palette = loadBundledTheme("default-light"); diff --git a/diffr/packages/viewer/src/viewer.ts b/diffr/packages/viewer/src/viewer.ts new file mode 100644 index 000000000..9203ecd22 --- /dev/null +++ b/diffr/packages/viewer/src/viewer.ts @@ -0,0 +1,627 @@ +import { buildFileTree, flattenFileTree } from "./document/fileTree"; +import { defaultCollapsed, foldIds, gapIds, hidingIds, nestedIds, sourceLines } from "./document/regions"; +import { occurrences, type Match } from "./document/search"; +import { rankFiles, type Pick } from "./document/pick"; +import { placeholderRows, rowsForFile, type Layout, type RenderSpan, type SplitLineCell, type UnifiedLineCell, type ViewerRow } from "./document/rows"; +import { lightMatches, type Lit } from "./viewport/cell"; +import type { DiffStore, Snapshot } from "./protocol/store"; +import { filePath, fileVisibility, type DiffFile, type TextDiff } from "./protocol/wire"; +import { measureRows, positionAt, positionTop, rowFold, visibleRows, type Geometry, type MeasuredRow, type ViewPosition } from "./viewport/geometry"; +import type { Palette } from "./theme/palette"; +import { dark, light } from "./theme/themes"; + +/** Excludes the frontend's scrollbar and sidebar. */ +export interface Size { + columns: number; + rows: number; +} + +interface ScopeHover { + file: number; + id: number; + armed: boolean; +} + +interface HeaderHover { + file: number; + header: true; +} + +export type Hover = ScopeHover | HeaderHover; + +/** A shifted letter arrives as its capital. */ +export interface KeyPress { + key: string; + ctrl?: boolean; + shift?: boolean; + meta?: boolean; +} + +interface Laid { + size: Size; + snapshot: Snapshot; + theme: Palette; + layout: Layout; + wrap: boolean; + horizontal: number; + hover: Hover | null; + rows: ViewerRow[]; + geometry: Geometry; + /** First visible visual row. */ + top: number; + maxScroll: number; + viewport: MeasuredRow[]; + currentFile: number; + sticky: boolean; + thumb: { top: number; height: number }; +} + +export class Viewer { + private mode: Layout | "auto" = "auto"; + private wrap = false; + private position: ViewPosition | null = null; + private horizontal = 0; + private hover: Hover | null = null; + private readonly closed = new Map(); + private readonly collapsed = new Map>(); + private pendingZ = false; + private pendingBracket: "]" | "[" | null = null; + private readonly viewed = new Set(); + private prompt: string | null = null; + private search: { pattern: string; matches: Match[]; at: number; snapshot: Snapshot } | null = null; + private promptCount: { pattern: string; snapshot: Snapshot; count: number } | undefined; + private picker: { query: string; cursor: number } | null = null; + private readonly openRows = new WeakMap(); + private readonly rowCache = new WeakMap(); + /** Bumped when rows change; keys the geometry cache. */ + private revision = 0; + private measured: { key: string; snapshot: Snapshot; rows: ViewerRow[]; geometry: Geometry } | undefined; + private size: Size | undefined; + private readonly listeners = new Set<() => void>(); + /** Bumped by every change, for `useSyncExternalStore`. */ + private version = 0; + + /** `splitColumns`: the narrowest diff column `auto` lays out split. */ + constructor(private readonly store: DiffStore, private theme: Palette, private readonly splitColumns: number) { + store.subscribe(() => this.emit()); + } + + subscribe = (listener: () => void) => { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + }; + + getVersion = () => this.version; + + private emit() { + this.version++; + this.listeners.forEach((listener) => listener()); + } + + private reshape() { + this.revision++; + this.emit(); + } + + private get snapshot(): Snapshot { + return this.store.getSnapshot(); + } + + get chording(): boolean { + return this.pendingZ || this.pendingBracket !== null; + } + + isClosed(index: number, file: DiffFile) { + return this.closed.get(index) ?? fileVisibility(file).collapsed; + } + + private foldsOf(index: number, diff: TextDiff): ReadonlySet { + return this.collapsed.get(index) ?? defaultCollapsed(diff); + } + + private fileOrder(snapshot: Snapshot) { + return flattenFileTree(buildFileTree(snapshot.inventory), new Set()).flatMap(({ node }) => + node.fileIndex === undefined ? [] : [node.fileIndex]); + } + + private documentRows(snapshot: Snapshot, layout: Layout): ViewerRow[] { + const { inventory, files, failures } = snapshot; + const perFile = this.fileOrder(snapshot).map((index): ViewerRow[] => { + const file = files[index]; + if (!file) { + const failure = failures[index]; + const status = failure ?? (snapshot.complete ? "File did not load" : "Computing diff…"); + return [{ key: `${index}:header`, fileIndex: index, label: filePath(inventory[index]!.file) }, + ...["", status, "", ""].map((label, line) => ({ key: `${index}:pending:${line}`, fileIndex: index, + label, pending: line === 1 && !failure && !snapshot.complete }))]; + } + const folds = file.diff.type === "text" ? this.foldsOf(index, file.diff) : new Set(); + const key = `${index}:${layout}:${this.theme.name}:${[...folds].sort((a, b) => a - b).join(",")}`; + let cached = this.rowCache.get(file); + if (cached?.key !== key) { + cached = { key, rows: rowsForFile(file, index, layout, this.theme, folds) }; + this.rowCache.set(file, cached); + } + if (!this.isClosed(index, file)) return cached.rows; + return fileVisibility(file).collapsed + ? [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: "" }); + for (const row of fileRows) all.push(row); + } + for (const [i, error] of snapshot.errors.entries()) + all.push({ key: `error:${i}`, fileIndex: -1, label: error }); + return all; + } + + lay(size: Size): Laid { + this.size = size; + const snapshot = this.snapshot; + const contentWidth = size.columns, viewportHeight = size.rows; + const layout = this.mode === "auto" ? (contentWidth >= this.splitColumns ? "split" : "unified") : this.mode; + // Search lights whole lines before wrap and pan cut them, so its colours travel with the text. + const lit = this.lit(); + const key = `${this.revision}:${layout}:${contentWidth}:${this.wrap}:${this.horizontal}:${lit ? JSON.stringify(lit) : ""}`; + if (this.measured?.key !== key || this.measured.snapshot !== snapshot) { + const rows = lit ? this.lightRows(this.documentRows(snapshot, layout), lit) : this.documentRows(snapshot, layout); + const maxLine = Math.max(1, ...snapshot.files.flatMap((file) => file?.diff.type === "text" + ? [file.diff.lhs, file.diff.rhs].map((source) => source ? sourceLines(source.text).length : 0) : [])); + this.measured = { key, snapshot, rows, geometry: measureRows(rows, contentWidth, this.wrap, this.horizontal, maxLine) }; + } + const { rows, geometry } = this.measured; + const lastFileTop = geometry.rows.findLast((r) => r.row.key.endsWith(":header"))?.top ?? 0; + // One past the end, since the sticky file header takes the viewport's first row. + const maxScroll = Math.max(lastFileTop, geometry.height - viewportHeight + 1); + const top = Math.min(positionTop(geometry, this.position), maxScroll); + const viewport = visibleRows(geometry, top, viewportHeight); + const currentFile = viewport[0]?.row.fileIndex ?? 0; + const sticky = !!viewport.length && !viewport[0]!.row.key.endsWith(":header"); + const thumbHeight = Math.max(1, Math.floor((viewportHeight * viewportHeight) / Math.max(viewportHeight, geometry.height))); + const thumbTop = maxScroll ? Math.round((top / maxScroll) * (viewportHeight - thumbHeight)) : 0; + return { size, snapshot, theme: this.theme, layout, wrap: this.wrap, horizontal: this.horizontal, hover: this.hover, + rows, geometry, top, maxScroll, viewport, currentFile, sticky, thumb: { top: thumbTop, height: thumbHeight } }; + } + + private current(): Laid { + if (!this.size) throw new Error("The viewer has not been laid out yet"); + return this.lay(this.size); + } + + scrollTo(value: number) { + const at = this.current(); + this.position = positionAt(at.geometry, Math.max(0, Math.min(at.maxScroll, value))); + this.emit(); + } + + /** Relative to the latest position, so key repeats within one frame add up. */ + move(amount: number) { + this.scrollTo(this.current().top + amount); + } + + scrub(row: number) { + const at = this.current(); + this.scrollTo(Math.round(Math.max(0, Math.min(1, row / Math.max(1, at.size.rows - 1))) * at.maxScroll)); + } + + jump(index: number) { + const row = this.current().geometry.rows.find((r) => r.row.fileIndex === index); + if (!row) throw new Error(`File ${index} has no rows`); + this.scrollTo(row.top); + } + + toggleFile(index: number) { + const file = this.snapshot.files[index]; + if (!file) return; + this.setClosed(index, !this.isClosed(index, file)); + this.reshape(); + } + + private setClosed(index: number, closed: boolean) { + const at = this.current(); + // Toggling the file being read moves to its header; the rows above it stay put. + const header = at.geometry.rows.find((r) => r.row.key === `${index}:header`)!; + if (header.top < at.top) this.position = { key: header.row.key, fileIndex: index, offset: 0, side: "right" }; + this.closed.set(index, closed); + } + + /** Undefined when the file shows no changed lines. */ + isViewed(index: number): boolean | undefined { + const diff = this.snapshot.files[index]?.diff; + const markable = diff?.type === "text" && diff.stats.visible.added + diff.stats.visible.removed > 0; + return markable ? this.viewed.has(index) : undefined; + } + + viewedFiles(): { viewed: number; total: number } { + let viewed = 0, total = 0; + this.snapshot.files.forEach((_, index) => { + const state = this.isViewed(index); + if (state === undefined) return; + total++; + if (state) viewed++; + }); + return { viewed, total }; + } + + toggleViewedFile(index: number) { + const state = this.isViewed(index); + if (state === undefined) return; + if (state) this.viewed.delete(index); + else this.viewed.add(index); + this.setClosed(index, !state); + this.reshape(); + } + + cancel() { + if (this.prompt === null && !this.picker && !this.pendingZ && !this.pendingBracket) return; + this.prompt = null; + this.picker = null; + this.pendingZ = false; + this.pendingBracket = null; + this.emit(); + } + + get picking(): boolean { + return this.picker !== null; + } + + pickerState(): { query: string; cursor: number; picks: Pick[]; total: number } | undefined { + if (!this.picker) return undefined; + const { inventory } = this.snapshot; + const picks = rankFiles(inventory, this.fileOrder(this.snapshot), this.picker.query, (index) => this.isViewed(index) === true); + return { query: this.picker.query, cursor: Math.min(this.picker.cursor, Math.max(0, picks.length - 1)), picks, total: inventory.length }; + } + + pickFile(index: number) { + this.picker = null; + const file = this.snapshot.files[index]; + if (file && this.isClosed(index, file)) { + this.closed.set(index, false); + this.revision++; + } + this.jump(index); + } + + private typePicker(key: KeyPress) { + const picker = this.picker!, name = key.key; + if (name === "escape" || (key.ctrl && (name === "c" || name === "g"))) this.picker = null; + else if (name === "return" || name === "enter") { + const state = this.pickerState()!, pick = state.picks[state.cursor]; + if (pick) return this.pickFile(pick.fileIndex); + this.picker = null; + } else if (name === "up" || (key.ctrl && name === "p")) picker.cursor = Math.max(0, picker.cursor - 1); + else if (name === "down" || (key.ctrl && name === "n")) picker.cursor += 1; + else if (name === "backspace") this.picker = { query: picker.query.slice(0, -1), cursor: 0 }; + else if (!key.ctrl && !key.meta) { + const text = name === "space" ? " " : name; + if ([...text].length !== 1) return; + this.picker = { query: picker.query + text, cursor: 0 }; + } else return; + this.emit(); + } + + get prompting(): boolean { + return this.prompt !== null; + } + + searchState(): { prompt: string; count: number } | { pattern: string; at: number; total: number; files: number } | undefined { + if (this.prompt !== null) { + const snapshot = this.snapshot; + if (this.promptCount?.pattern !== this.prompt || this.promptCount.snapshot !== snapshot) + this.promptCount = { pattern: this.prompt, snapshot, count: this.findMatches(this.prompt).length }; + return { prompt: this.prompt, count: this.promptCount.count }; + } + if (!this.search) return undefined; + const { pattern, matches, at } = this.search; + return { pattern, at: at + 1, total: matches.length, files: new Set(matches.map((match) => match.fileIndex)).size }; + } + + private lit(): { pattern: string; on?: Match } | undefined { + const pattern = this.prompt ?? this.search?.pattern; + if (!pattern) return undefined; + return { pattern, on: this.prompt === null && this.search ? this.search.matches[this.search.at] : undefined }; + } + + private litFor(lit: { pattern: string; on?: Match }, fileIndex: number, key: string, side: "left" | "right"): Lit { + const { on } = lit; + return { pattern: lit.pattern, current: on && on.fileIndex === fileIndex && on.key === key && on.side === side ? on.nth : undefined }; + } + + headerLit(fileIndex: number): Lit | undefined { + const lit = this.lit(); + return lit && this.litFor(lit, fileIndex, `${fileIndex}:header`, "right"); + } + + private lightRows(rows: ViewerRow[], lit: { pattern: string; on?: Match }): ViewerRow[] { + const light = (cell: Cell | undefined, fileIndex: number, key: string, side: "left" | "right") => { + if (!cell) return cell; + const spans = lightMatches(cell.spans, this.litFor(lit, fileIndex, key, side), this.theme); + return spans === cell.spans ? cell : { ...cell, spans }; + }; + return rows.map((row) => { + if (row.cell) { + const cell = light(row.cell, row.fileIndex, row.key, row.cell.newLineNumber === undefined ? "left" : "right"); + return cell === row.cell ? row : { ...row, cell }; + } + const left = light(row.left, row.fileIndex, row.key, "left"), right = light(row.right, row.fileIndex, row.key, "right"); + return left === row.left && right === row.right ? row : { ...row, left, right }; + }); + } + + private allOpenRows(index: number, file: DiffFile, layout: Layout): ViewerRow[] { + let cached = this.openRows.get(file); + if (cached?.layout !== layout) { + cached = { layout, rows: rowsForFile(file, index, layout, this.theme, new Set()) }; + this.openRows.set(file, cached); + } + return cached.rows; + } + + private findMatches(pattern: string): Match[] { + const { files, inventory } = this.snapshot; + const layout = this.current().layout; + const matches: Match[] = []; + for (const index of this.fileOrder(this.snapshot)) { + occurrences(filePath(inventory[index]!.file), pattern).forEach((_, nth) => + matches.push({ fileIndex: index, key: `${index}:header`, side: "right", nth })); + const file = files[index]; + if (!file) continue; + for (const row of this.allOpenRows(index, file, layout)) { + const cells: ["left" | "right", SplitLineCell | UnifiedLineCell | undefined, number | undefined][] = row.cell + ? [[row.cell.newLineNumber === undefined ? "left" : "right", row.cell, row.cell.newLineNumber ?? row.cell.oldLineNumber]] + : [["left", row.left, row.left?.lineNumber], ["right", row.right, row.right?.lineNumber]]; + for (const [side, cell, line] of cells) { + if (!cell || line === undefined) continue; + occurrences(cell.spans.map((span) => span.text).join(""), pattern).forEach((_, nth) => + matches.push({ fileIndex: index, key: row.key, side, line, nth })); + } + } + } + return matches; + } + + private placeOf(order: number[], layout: Layout, fileIndex: number, key: string, side: "left" | "right", line?: number): number { + const file = this.snapshot.files[fileIndex]; + const rows = file ? this.allOpenRows(fileIndex, file, layout) : []; + const lineOf = (row: ViewerRow) => side === "left" ? row.left?.lineNumber ?? row.cell?.oldLineNumber + : row.right?.lineNumber ?? row.cell?.newLineNumber; + let row = rows.findIndex((r) => r.key === key); + // A row that only exists folded, such as a collapsed fold's own row, sits where its line does. + if (row < 0 && line !== undefined) row = rows.findIndex((r) => (lineOf(r) ?? -1) >= line); + // No file has ten million rows, so a file's place times that leaves room for all of its rows. + return order.indexOf(fileIndex) * 1e7 + Math.max(0, row); + } + + private commitSearch(pattern: string) { + this.prompt = null; + if (!pattern) return this.emit(); + const snapshot = this.snapshot, matches = this.findMatches(pattern); + this.search = { pattern, matches, at: -1, snapshot }; + if (!matches.length) return this.emit(); + const at = this.current(), top = positionAt(at.geometry, at.top); + const order = this.fileOrder(snapshot); + const from = top ? this.placeOf(order, at.layout, top.fileIndex, top.key, top.side, top.line) : 0; + const next = matches.findIndex((match) => this.placeOf(order, at.layout, match.fileIndex, match.key, match.side, match.line) >= from); + this.search.at = next < 0 ? 0 : next; + this.goTo(matches[this.search.at]!); + } + + /** Also searches files that streamed in since the search. */ + private stepMatch(direction: 1 | -1) { + const search = this.search; + if (!search) return; + if (search.snapshot !== this.snapshot) { + const on = search.matches[search.at]; + search.matches = this.findMatches(search.pattern); + search.snapshot = this.snapshot; + search.at = on ? search.matches.findIndex((m) => m.fileIndex === on.fileIndex && m.key === on.key && m.side === on.side && m.nth === on.nth) : -1; + } + const total = search.matches.length; + if (!total) return this.emit(); + search.at = (search.at + direction + total) % total; + this.goTo(search.matches[search.at]!); + } + + private goTo(match: Match) { + const file = this.snapshot.files[match.fileIndex]; + if (file && match.line !== undefined) { + if (this.isClosed(match.fileIndex, file)) this.closed.set(match.fileIndex, false); + if (file.diff.type === "text") { + const hiding = hidingIds(file.diff, match.side === "left" ? 0 : 1, match.line - 1, this.foldsOf(match.fileIndex, file.diff)); + if (hiding.length) this.setFolds(match.fileIndex, file.diff, hiding, false); + } + } + this.position = { key: match.key, fileIndex: match.fileIndex, offset: 0, side: match.side, line: match.line }; + this.reshape(); + } + + private type(key: KeyPress) { + const name = key.key, prompt = this.prompt!; + if (name === "return" || name === "enter") return this.commitSearch(prompt); + if (name === "escape" || (key.ctrl && (name === "c" || name === "g"))) this.prompt = null; + else if (name === "backspace") this.prompt = prompt ? prompt.slice(0, -1) : null; + else if (!key.ctrl && !key.meta) { + const text = name === "space" ? " " : name; + if ([...text].length !== 1) return; + this.prompt = prompt + text; + } else return; + this.emit(); + } + + /** "toggle" reads the first id's state, so quick repeated clicks alternate. */ + private setFolds(fileIndex: number, diff: TextDiff, ids: number[], collapse: boolean | "toggle") { + const next = new Set(this.foldsOf(fileIndex, diff)); + const close = collapse === "toggle" ? !next.has(ids[0]!) : collapse; + for (const id of ids) if (close) next.add(id); else next.delete(id); + this.collapsed.set(fileIndex, next); + } + + /** Recursive commands (Alt-click, zC, zO, zA) include every fold nested inside. */ + setFold(fileIndex: number, id: number, collapse: boolean | "toggle", recursive: boolean) { + const diff = this.snapshot.files[fileIndex]?.diff; + if (diff?.type !== "text") throw new Error(`File ${fileIndex} has no folds`); + this.setFolds(fileIndex, diff, recursive ? [id, ...nestedIds(diff, id)] : [id], collapse); + this.reshape(); + } + + private textDiffs() { + return this.snapshot.files.flatMap((file, index) => file?.diff.type === "text" ? [{ index, diff: file.diff }] : []); + } + + toggleContext() { + const textDiffs = this.textDiffs(); + const opened = textDiffs.some(({ index, diff }) => gapIds(diff).some((id) => !this.foldsOf(index, diff).has(id))); + textDiffs.forEach(({ index, diff }) => this.setFolds(index, diff, gapIds(diff), opened)); + this.reshape(); + } + + foldAll(collapse: boolean) { + this.textDiffs().forEach(({ index, diff }) => this.setFolds(index, diff, foldIds(diff), collapse)); + this.reshape(); + } + + private navigate(direction: number, matches: (row: ViewerRow) => boolean) { + const at = this.current(); + const headers = at.geometry.rows.filter((r) => matches(r.row)); + const target = direction > 0 ? headers.find((r) => r.top > at.top) : headers.findLast((r) => r.top < at.top); + if (target) this.scrollTo(target.top); + } + + navigateHunk(direction: number) { + this.navigate(direction, (row) => !!row.hunkStart); + } + + private navigateFold(direction: number) { + this.navigate(direction, (row) => !!rowFold(row)); + } + + private foldCommand(command: string) { + if (command === "R") return this.foldAll(false); + if (command === "M") return this.foldAll(true); + if (command === "j") return this.navigateFold(1); + if (command === "k") return this.navigateFold(-1); + const at = this.current(); + const current = visibleRows(at.geometry, at.top, 1)[0]?.row; + const fold = current && rowFold(current); + if (!current || !fold) return; + const recursive = command === command.toUpperCase(); + const letter = command.toLowerCase(); + if (letter === "a") this.setFold(current.fileIndex, fold.id, "toggle", recursive); + else if (letter === "o") this.setFold(current.fileIndex, fold.id, false, recursive); + else if (letter === "c") this.setFold(current.fileIndex, fold.id, true, recursive); + } + + toggleLayout() { + this.mode = this.current().layout === "split" ? "unified" : "split"; + this.reshape(); + } + + toggleWrap() { + this.wrap = !this.wrap; + this.emit(); + } + + setTheme(theme: Palette) { + this.theme = theme; + this.reshape(); + } + + /** `t` swaps between the two bundled defaults; a configured theme is left by the first press. */ + private toggleTheme() { + this.setTheme(this.theme.isLight ? dark : light); + } + + pan(columns: number) { + this.horizontal = Math.max(0, this.horizontal + columns); + this.emit(); + } + + setHover(hover: Hover | null) { + if (JSON.stringify(hover) === JSON.stringify(this.hover)) return; + this.hover = hover; + this.emit(); + } + + private toggleTopFile() { + const at = this.current(); + const current = visibleRows(at.geometry, at.top, 1)[0]; + if (current && current.row.fileIndex >= 0) this.toggleFile(current.row.fileIndex); + } + + /** Returns false for keys the frontend handles. */ + press(key: KeyPress): boolean { + const at = this.current(); + const page = at.size.rows, half = Math.max(1, Math.floor(at.size.rows / 2)); + const name = key.key; + if (this.picker) { + this.typePicker(key); + return true; + } + if (this.prompt !== null) { + this.type(key); + return true; + } + if (this.pendingZ) { + this.pendingZ = false; + if (!key.ctrl && !key.meta && name.length === 1 && "aocAOCRMjk".includes(name)) this.foldCommand(name); + return true; + } + if (this.pendingBracket) { + const bracket = this.pendingBracket; + this.pendingBracket = null; + if (!key.ctrl && !key.meta && name === "c") this.navigateHunk(bracket === "]" ? 1 : -1); + return true; + } + // ⌘P, as in VS Code, where the terminal passes Cmd through; Ctrl-P everywhere else. + if ((key.meta || key.ctrl) && name === "p") { + this.picker = { query: "", cursor: 0 }; + this.emit(); + return true; + } + if (key.meta) return false; + if (key.ctrl) { + if (name === "d") this.move(half); + else if (name === "u") this.move(-half); + else if (name === "f") this.move(page); + else if (name === "b") this.move(-page); + else return false; + return true; + } + switch (name) { + case "d": this.move(half); break; + case "u": this.move(-half); break; + case " ": case "space": this.move(key.shift ? -page : page); break; + case "pagedown": case "f": this.move(page); break; + case "pageup": case "b": this.move(-page); break; + case "down": case "j": this.move(1); break; + case "up": case "k": this.move(-1); break; + case "g": case "home": this.scrollTo(0); break; + case "G": case "end": this.scrollTo(at.maxScroll); break; + case "right": case "l": this.pan(key.shift ? 16 : 4); break; + case "L": this.pan(16); break; + case "left": case "h": this.pan(key.shift ? -16 : -4); break; + case "H": this.pan(-16); break; + case "]": case "[": this.pendingBracket = name; break; + case "s": this.toggleLayout(); break; + case "w": this.toggleWrap(); break; + case "c": this.toggleContext(); break; + case "t": this.toggleTheme(); break; + case "z": this.pendingZ = true; break; + case "return": case "enter": this.toggleTopFile(); break; + case "V": if (at.currentFile >= 0) this.toggleViewedFile(at.currentFile); break; + case "/": this.prompt = ""; this.emit(); break; + case "n": this.stepMatch(1); break; + case "N": this.stepMatch(-1); break; + default: return false; + } + return true; + } +} diff --git a/diffr/packages/viewer/src/viewport/cell.test.ts b/diffr/packages/viewer/src/viewport/cell.test.ts new file mode 100644 index 000000000..dea7e095c --- /dev/null +++ b/diffr/packages/viewer/src/viewport/cell.test.ts @@ -0,0 +1,39 @@ +import { expect, test } from "vitest"; +import { createGuideDiffFile } from "../protocol/fixture"; +import { rowsForFile } from "../document/rows"; +import { dark } from "../theme/themes"; +import { measureRows } from "./geometry"; +import { planCell, targetAt, type ScopeFocus } from "./cell"; + +test.each(["split", "unified"] as const)("%s fold targets include neighboring blanks and share their hover bounds", layout => { + const rows = rowsForFile(createGuideDiffFile(), 0, layout, dark, new Set()); + const geometry = measureRows(rows, 180, false, 0, 20); + const at = (line: number, focus?: ScopeFocus) => { + const value = rows.find(row => layout === "unified" ? row.cell?.newLineNumber === line : row.right?.lineNumber === line)!; + const cell = (layout === "unified" ? value.cell : value.right)!; + return planCell(cell, cell.spans, 80, layout === "unified", { theme: dark, geometry, visualLine: 0, focus }); + }; + const opener = at(3); + const caret = opener.runs.map(run => run.text).join("").indexOf("▾"); + const fold = targetAt(opener.hits, caret)!; + for (const x of [caret - 1, caret, caret + 1]) { + expect(targetAt(opener.hits, x)).toBe(fold); + expect(targetAt(opener.hovers, x)).toEqual({ id: fold, armed: true }); + } + const body = at(4); + const text = body.runs.map(run => run.text).join(""); + const rail = text.lastIndexOf("│"); + for (const x of [rail - 1, rail, rail + 1]) { + expect(targetAt(body.hits, x)).toBe(fold); + const focus = targetAt(body.hovers, x)!; + expect(focus).toEqual({ id: fold, armed: true }); + expect(at(4, focus).runs.map(run => run.text).join("")[rail]).toBe("┃"); + } + // Padding never steals the code or the actual rail of an outer scope. + const firstCode = text.search(/[A-Za-z]/); + expect(firstCode).toBeGreaterThan(rail); + expect(targetAt(body.hits, firstCode)).toBeUndefined(); + const outer = text.indexOf("│"); + expect(targetAt(body.hits, outer)).not.toBe(fold); + expect(targetAt(body.hovers, outer)?.id).toBe(targetAt(body.hits, outer)); +}); diff --git a/diffr/packages/viewer/src/viewport/cell.ts b/diffr/packages/viewer/src/viewport/cell.ts new file mode 100644 index 000000000..cc1a66468 --- /dev/null +++ b/diffr/packages/viewer/src/viewport/cell.ts @@ -0,0 +1,169 @@ +import type { Geometry } from "./geometry"; +import { foldBackground, type RenderSpan, type SplitLineCell, type UnifiedLineCell } from "../document/rows"; +import { measureTextWidth, sliceTextByWidth } from "../terminal/text"; +import type { RowFold } from "../document/regions"; +import { occurrences } from "../document/search"; +import type { Palette } from "../theme/palette"; + +/** 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; +} + +export interface PaintRun { + text: string; + fg: string; + bg: string; +} + +export type Target = [from: number, to: number, value: T]; + +/** Targets are listed most specific first. */ +export const targetAt = (targets: readonly Target[], x: number): T | undefined => + targets.find(([from, to]) => x >= from && x < to)?.[2]; + +interface CellPlan { + bg: string; + runs: PaintRun[]; + hits: Target[]; + hovers: Target[]; +} + +export interface CellOptions { + theme: Palette; + geometry: Geometry; + visualLine: number; + focus?: ScopeFocus; + selected?: boolean; + read?: boolean; +} + +export interface Lit { + pattern: string; + current?: number; +} + +/** Lit spans skip the viewed-line fade. */ +export function lightMatches(spans: RenderSpan[], lit: Lit, theme: Palette): RenderSpan[] { + const ranges = occurrences(spans.map((span) => span.text).join(""), lit.pattern); + if (!ranges.length) return spans; + const result: RenderSpan[] = []; + let offset = 0; + for (const span of spans) { + const end = offset + span.text.length; + let cut = offset; + for (const [nth, [from, to]] of ranges.entries()) { + if (to <= cut || from >= end) continue; + const start = Math.max(from, cut), stop = Math.min(to, end), current = nth === lit.current; + if (start > cut) result.push({ ...span, text: span.text.slice(cut - offset, start - offset) }); + result.push({ ...span, text: span.text.slice(start - offset, stop - offset), lit: true, + bg: current ? theme.searchCurrent : theme.searchMatch, fg: current ? theme.searchCurrentText : span.fg }); + cut = stop; + } + if (cut < end) result.push({ ...span, text: span.text.slice(cut - offset) }); + offset = end; + } + return result; +} + +export function litRuns(text: string, fg: string, bg: string, lit: Lit | undefined, theme: Palette): PaintRun[] { + const spans = lit ? lightMatches([{ text, fg, bg }], lit, theme) : [{ text, fg, bg }]; + return spans.map((span) => ({ text: span.text, fg: span.fg ?? fg, bg: span.bg ?? bg })); +} + +/** A file header's viewed box, one cell wide in every terminal. */ +export const viewedBox = (viewed: boolean) => (viewed ? "[✓]" : "[ ]"); + +export const viewedHint = (viewed: boolean) => ` ${viewed ? "Unmark viewed" : "Mark as viewed"} · V `; + +function chevron(fold: RowFold | undefined) { + if (!fold) return " "; + return fold.collapsed ? "▸" : "▾"; +} + +export function planCell( + value: SplitLineCell | UnifiedLineCell, + spans: RenderSpan[], + width: number, + unified: boolean, + { theme, geometry, visualLine, focus, selected = false, read = false }: CellOptions, +): CellPlan { + const fold = value.fold; + const washed = focus?.armed && value.body?.includes(focus.id); + const changed = value.kind === "addition" || value.kind === "deletion"; + const bg = selected ? theme.selection + : value.kind === "addition" ? (read ? theme.readAddition : theme.addition) + : value.kind === "deletion" ? (read ? theme.readDeletion : theme.deletion) + : washed ? theme.focusWash : theme.bg; + // A viewed line keeps its shape but drops its colours: muted ink, no word emphasis. Search matches keep theirs. + if (read && changed) spans = spans.map((span) => (span.lit ? span : { ...span, fg: theme.muted, bg: undefined })); + // 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); + const numbers = unified + ? ` ${number((value as UnifiedLineCell).oldLineNumber)} ${number((value as UnifiedLineCell).newLineNumber)} ` + : ` ${number("lineNumber" in value ? value.lineNumber : undefined)} `; + 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)); + // 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; + // 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, open or not. + const chevronFg = fold && fold.id === focus?.id ? theme.accent + : fold?.collapsed ? theme.muted : theme.guide; + const chevronColumn = numbers.length, codeColumn = chevronColumn + 2; + const runs: PaintRun[] = [ + { text: numbers, fg: theme.muted, bg }, + { text: visualLine ? " " : chevron(fold), fg: chevronFg, bg }, + { text: " ", fg: theme.fg, bg }, + ]; + const hits: Target[] = [], hovers: Target[] = []; + // A rail or chevron arms its scope to fold; a collapsed fold's chevron or label arms it to open. + if (fold && !visualLine) { + hits.push([chevronColumn, chevronColumn + 1, fold.id]); + hovers.push([chevronColumn, chevronColumn + 1, { id: fold.id, armed: true }]); + } + let column = codeColumn; + for (const span of painted) { + const rail = span.guide !== undefined && span.guide === focus?.id; + const brace = span.brace !== undefined && span.brace === focus?.id; + const text = rail && focus!.armed ? span.text.replace(/│/g, "┃") : span.text; + const cells = measureTextWidth(text); + runs.push({ + text, + fg: rail || brace ? theme.accent : span.fg ?? theme.fg, + bg: brace && focus!.armed ? theme.focusBrace : selected ? bg : span.bg ?? bg, + }); + if (span.guide !== undefined) { + hits.push([column, column + cells, span.guide]); + hovers.push([column, column + cells, { id: span.guide, armed: true }]); + } + column += cells; + } + if (column < width) runs.push({ text: " ".repeat(width - column), fg: theme.fg, bg }); + // A terminal glyph is a narrow target. Include the blank cell beside either + // edge of a rail or chevron, without taking source text or line numbers away + // from selection. Exact targets win when neighboring scopes are close. + const text = runs.map(run => run.text).join(""); + const padding = hits.map(([from, to, id]): Target => [ + from > 0 && sliceTextByWidth(text, from - 1, 1).text === " " ? from - 1 : from, + to < width && sliceTextByWidth(text, to, 1).text === " " ? to + 1 : to, + id, + ]); + hits.push(...padding); + hovers.push(...padding.map(([from, to, id]): Target => [from, to, { id, armed: true }])); + // A collapsed fold's row, or a line of its label, opens it. + const opens = fold?.collapsed ? fold.id : value.labelOf; + if (opens !== undefined) { + hits.push([codeColumn, width, opens]); + hovers.push([codeColumn, width, { id: opens, armed: true }]); + } + if (value.scope !== undefined) hovers.push([0, width, { id: value.scope, armed: false }]); + return { bg, runs, hits, hovers }; +} diff --git a/diffr/tui/packages/hunk/src/diffr/geometry.ts b/diffr/packages/viewer/src/viewport/geometry.ts similarity index 87% rename from diffr/tui/packages/hunk/src/diffr/geometry.ts rename to diffr/packages/viewer/src/viewport/geometry.ts index aa890df07..2dc040389 100644 --- a/diffr/tui/packages/hunk/src/diffr/geometry.ts +++ b/diffr/packages/viewer/src/viewport/geometry.ts @@ -1,8 +1,5 @@ -/** Measure once per layout change, then window measured rows using Hunk's binary search. */ -import { sliceSpansWindow, wrapSpans } from "../ui/diff/styledSpanLayout"; -import { resolveVisibleRowIndexWindow } from "../ui/diff/rowWindowing"; -import type { RenderSpan } from "../ui/diff/diffRowModel"; -import type { ViewerRow } from "./rows"; +import { sliceSpansWindow, wrapSpans } from "../terminal/spans"; +import type { RenderSpan, ViewerRow } from "../document/rows"; export interface MeasuredRow { row: ViewerRow; top: number; @@ -55,12 +52,16 @@ export function measureRows( return { rows: measured, height: top, leftWidth, rightWidth, gutter, unifiedGutter }; } export function visibleRows(geometry: Geometry, top: number, height: number) { - const window = resolveVisibleRowIndexWindow({ - bodyHeight: geometry.height, - rowBounds: geometry.rows, - visibleBodyBounds: { top, height }, - }); - return geometry.rows.slice(window.startIndex, window.endIndex); + const rows = geometry.rows, bottom = top + Math.max(0, height); + let start = 0, end = rows.length; + while (start < end) { + const mid = (start + end) >>> 1; + if (rows[mid]!.top + rows[mid]!.height > top) end = mid; + else start = mid + 1; + } + end = start; + while (end < rows.length && rows[end]!.top < bottom) end++; + return rows.slice(start, end); } /** The source line a row shows on one side. */ diff --git a/diffr/packages/viewer/src/viewport/picker.ts b/diffr/packages/viewer/src/viewport/picker.ts new file mode 100644 index 000000000..1db369066 --- /dev/null +++ b/diffr/packages/viewer/src/viewport/picker.ts @@ -0,0 +1,81 @@ +import type { LineCounts } from "../document/counts"; +import type { Pick } from "../document/pick"; +import { measureTextWidth, sliceTextByWidth } from "../terminal/text"; +import { sanitizeTerminalLine } from "../terminal/sanitize"; +import type { Palette } from "../theme/palette"; +import type { PaintRun } from "./cell"; + +interface PickerRun extends PaintRun { + bold?: boolean; +} + +interface PickerLine { + runs: PickerRun[]; + pick?: number; +} + +interface PickerState { + query: string; + cursor: number; + picks: Pick[]; + total: number; +} + +function exactly(runs: PickerRun[], width: number, bg: string): PickerRun[] { + const result: PickerRun[] = []; + let room = width; + for (const run of runs) { + if (room <= 0) break; + const cells = measureTextWidth(run.text); + result.push(cells <= room ? run : { ...run, text: sliceTextByWidth(run.text, 0, room).text }); + room -= Math.min(cells, room); + } + if (room > 0) result.push({ text: " ".repeat(room), fg: bg, bg }); + return result; +} + +/** Long paths lose their start so the file name shows. */ +export function pickerLines(state: PickerState, width: number, height: number, theme: Palette, + counts: (index: number) => LineCounts | undefined, viewed: (index: number) => boolean, closeHint: string): PickerLine[] { + const bg = theme.chrome; + const room = Math.max(1, Math.min(state.picks.length, height - 2)); + const first = Math.max(0, Math.min(state.cursor - room + 1, state.picks.length - room)); + const count = `${state.picks.length} of ${state.total} changed files `; + const lines: PickerLine[] = [{ runs: exactly([{ text: " ".repeat(Math.max(0, width - measureTextWidth(count))), fg: bg, bg }, + { text: count, fg: theme.muted, bg }], width, bg) }]; + for (const [offset, pick] of state.picks.slice(first, first + room).entries()) { + const cursor = first + offset === state.cursor, rowBg = cursor ? theme.highlight : bg; + const read = viewed(pick.fileIndex), tally = counts(pick.fileIndex); + const right: PickerRun[] = read ? [{ text: "✓ ", fg: theme.accent, bg: rowBg }] + : tally ? [{ text: `+${tally.added} `, fg: theme.addedText, bg: rowBg }, { text: `−${tally.removed} `, fg: theme.removedText, bg: rowBg }] + : [{ text: " ", fg: rowBg, bg: rowBg }]; + const rightWidth = right.reduce((n, run) => n + measureTextWidth(run.text), 0); + const room = Math.max(1, width - 2 - rightWidth - 1); + let path = sanitizeTerminalLine(pick.path), shift = 0; + if (measureTextWidth(path) > room) { + shift = path.length - (room - 1); + path = `…${path.slice(shift)}`; + shift -= 1; + } + const matched = new Set(pick.indexes.map((index) => index - shift)); + const nameStart = path.lastIndexOf("/") + 1; + const runs: PickerRun[] = [{ text: cursor ? "▸ " : " ", fg: theme.accent, bg: rowBg }]; + [...path].forEach((char, index) => { + const fg = matched.has(index) ? theme.accent : read ? theme.muted : index < nameStart ? theme.fileHeaderDir : theme.fg; + const bold = matched.has(index) && !read; + const last = runs.at(-1)!; + if (last.fg === fg && !!last.bold === bold && runs.length > 1) last.text += char; + else runs.push({ text: char, fg, bg: rowBg, bold }); + }); + const used = runs.reduce((n, run) => n + measureTextWidth(run.text), 0); + runs.push({ text: " ".repeat(Math.max(0, width - used - rightWidth)), fg: rowBg, bg: rowBg }, ...right); + lines.push({ runs: exactly(runs, width, rowBg), pick: pick.fileIndex }); + } + const hint = `enter open · ↑↓ or ctrl-n/p move · ${closeHint} `; + const typed: PickerRun[] = [{ text: "› ", fg: theme.accent, bg }, { text: state.query, fg: theme.fg, bg }, + { text: " ", fg: theme.bg, bg: theme.fg }]; + const typedWidth = typed.reduce((n, run) => n + measureTextWidth(run.text), 0); + lines.push({ runs: exactly([...typed, { text: " ".repeat(Math.max(1, width - typedWidth - measureTextWidth(hint))), fg: bg, bg }, + { text: hint, fg: theme.muted, bg }], width, bg) }); + return lines; +} diff --git a/diffr/tui/themes/LICENSE b/diffr/packages/viewer/themes/LICENSE similarity index 100% rename from diffr/tui/themes/LICENSE rename to diffr/packages/viewer/themes/LICENSE diff --git a/diffr/tui/themes/README.md b/diffr/packages/viewer/themes/README.md similarity index 100% rename from diffr/tui/themes/README.md rename to diffr/packages/viewer/themes/README.md diff --git a/diffr/tui/themes/gruvbox.toml b/diffr/packages/viewer/themes/gruvbox.toml similarity index 100% rename from diffr/tui/themes/gruvbox.toml rename to diffr/packages/viewer/themes/gruvbox.toml diff --git a/diffr/tui/themes/onedark.toml b/diffr/packages/viewer/themes/onedark.toml similarity index 100% rename from diffr/tui/themes/onedark.toml rename to diffr/packages/viewer/themes/onedark.toml diff --git a/diffr/tui/themes/onelight.toml b/diffr/packages/viewer/themes/onelight.toml similarity index 100% rename from diffr/tui/themes/onelight.toml rename to diffr/packages/viewer/themes/onelight.toml diff --git a/diffr/tui/themes/solarized_light.toml b/diffr/packages/viewer/themes/solarized_light.toml similarity index 100% rename from diffr/tui/themes/solarized_light.toml rename to diffr/packages/viewer/themes/solarized_light.toml diff --git a/diffr/packages/viewer/tsconfig.json b/diffr/packages/viewer/tsconfig.json new file mode 100644 index 000000000..6d4d6a92b --- /dev/null +++ b/diffr/packages/viewer/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "es2023", + "lib": ["es2023"], + "types": [], + "module": "esnext", + "moduleResolution": "bundler", + "strict": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["src"], + "exclude": ["src/**/*.test.ts"] +} diff --git a/diffr/packages/viewer/tsconfig.test.json b/diffr/packages/viewer/tsconfig.test.json new file mode 100644 index 000000000..4a50a7d41 --- /dev/null +++ b/diffr/packages/viewer/tsconfig.test.json @@ -0,0 +1,6 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { "types": ["node"] }, + "include": ["src"], + "exclude": [] +} diff --git a/diffr/packages/viewer/vitest.config.ts b/diffr/packages/viewer/vitest.config.ts new file mode 100644 index 000000000..1beae8a66 --- /dev/null +++ b/diffr/packages/viewer/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config"; + +// Bun and esbuild read `with { type: "text" }` imports; Vite needs this to load the themes. +export default defineConfig({ + plugins: [{ name: "toml-as-text", transform: (code, id) => (id.endsWith(".toml") ? `export default ${JSON.stringify(code)};` : undefined) }], + test: { environment: "node" }, +}); diff --git a/diffr/scripts/release.py b/diffr/scripts/release.py index 2ae63189f..e28754a9f 100644 --- a/diffr/scripts/release.py +++ b/diffr/scripts/release.py @@ -35,7 +35,7 @@ def pack(version, target, install, output): binaries = ("diffr", "diffr-tui") if artifact == "diffr" else ("diffr",) for name in binaries: archive.add(install / "bin" / f"{name}{exe}", arcname=f"{name}{exe}") - for name in ("LICENSE", "NOTICE", "tui/LICENSE", "tui/themes/LICENSE"): + for name in ("LICENSE", "NOTICE", "packages/tui/LICENSE", "packages/viewer/themes/LICENSE"): archive.add(ROOT / name, arcname=name) @@ -111,8 +111,8 @@ def install bin.install "diffr", "diffr-tui" doc.install "NOTICE" (pkgshare/"licenses").install "LICENSE" - (pkgshare/"licenses/tui").install "tui/LICENSE" - (pkgshare/"licenses/themes").install "tui/themes/LICENSE" + (pkgshare/"licenses/tui").install "packages/tui/LICENSE" + (pkgshare/"licenses/themes").install "packages/viewer/themes/LICENSE" end test do diff --git a/diffr/tui/bun.lock b/diffr/tui/bun.lock deleted file mode 100644 index f306a15ff..000000000 --- a/diffr/tui/bun.lock +++ /dev/null @@ -1,97 +0,0 @@ -{ - "lockfileVersion": 1, - "configVersion": 1, - "workspaces": { - "": { - "name": "@diffr/tui", - "dependencies": { - "@opentui/core": "0.5.6", - "@opentui/react": "0.5.6", - "get-east-asian-width": "1.4.0", - "react": "19.2.0", - "string-width": "8.1.0", - "zod": "4.1.12", - }, - "devDependencies": { - "@types/bun": "1.3.14", - "@types/react": "19.2.0", - "typescript": "5.9.3", - }, - }, - }, - "packages": { - "@opentui/core": ["@opentui/core@0.5.6", "", { "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.5.6", "@opentui/core-darwin-x64": "0.5.6", "@opentui/core-linux-arm64": "0.5.6", "@opentui/core-linux-arm64-musl": "0.5.6", "@opentui/core-linux-x64": "0.5.6", "@opentui/core-linux-x64-musl": "0.5.6", "@opentui/core-win32-arm64": "0.5.6", "@opentui/core-win32-x64": "0.5.6" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-e38H1VceXoSSOEYmhUeHDE3F3LHXn/sFEZ6NhbXkbYdrkdV+1MMTgEkGZJ9I5fHhBlJdGJ1LDTPw8V0ZSUXSyQ=="], - - "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.5.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-g/OOSSXuFlpLOhLHm8w8MsMh1oGVYlQ7QuW8l6oqG9KIkOg4hkYN5eFnpe6Px1p6cNCw5NOC+198WwdcKhQncg=="], - - "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.5.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-4+z7/CaV27u9JpdooMa6oRIpLwsaA5j1a2xz7Xzm8oWhakeKGZnW0wUJAfStn/sks3NK5tUpF+eKHa9oawGiEw=="], - - "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.5.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-RAiRmosbg7DH4+ZuXRMjEmQmDAWhcRpuQKQ6bGL76nUd6wl/CufBiD7il/FQMQyUGzmQQH0Hu+pVQLz+kAkNJw=="], - - "@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.5.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-cMdxhADkuJW0Nkn7pTB43q5Djg4Ch5i3hlC4iLOVkbOgvckKqz8xJHdl7fuqVs8g5LaadGBmZqdlFKjXIMKl2g=="], - - "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.5.6", "", { "os": "linux", "cpu": "x64" }, "sha512-gzlNauPg5UT5UnWiSIlONRwKnkNA6fjN8SyRGlT3SGvcAox+vT2WeP6TwoJSM6pq5H5MF/tlY+REIqc/ZMGJtQ=="], - - "@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.5.6", "", { "os": "linux", "cpu": "x64" }, "sha512-vb5mn1i1s8/3lp03O6lX3190eN+iVCZxXOl9qCO5BTCXWtFztyIkp5YDad3/20SptcOHcLPKDiRGWNmSkYBrkQ=="], - - "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.5.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-FTSpFrNyuh4k2Z3+KeLVLlryFpe89ZyQcqBE0XcmtgiU3RlXuoMPSj2TtRekukR/FaWw9toDBfWokiQjOJGjrA=="], - - "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.5.6", "", { "os": "win32", "cpu": "x64" }, "sha512-rWplkCuHX0OxVi7bO8dAVPttzPpCgmz6Bsu1XCm4ErPAhZOofRd5FbeW2+pC71AbLOKicSwI7lQ4mhGCNaweaA=="], - - "@opentui/react": ["@opentui/react@0.5.6", "", { "dependencies": { "@opentui/core": "0.5.6", "react-reconciler": "^0.33.0" }, "peerDependencies": { "react": ">=19.2.0", "react-devtools-core": "^7.0.1", "ws": "^8.18.0" } }, "sha512-dMQTlcD1zlDTQC8r9rZKlHD7CORkY2M5D7tqhgoccoOK9p6q05FqrdRCTYHu1+VZYafqIV0k2IpMY2e34sngiQ=="], - - "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], - - "@types/node": ["@types/node@22.20.2", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-xlvWf4Vs9n1PEVYwP1n4vvG07M6y8WgvJ2t0vbrWTmijsIHp1cS+uJ2kMIRdY3nHZK0nCYKrPeD171+SzF4/zw=="], - - "@types/react": ["@types/react@19.2.0", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-1LOH8xovvsKsCBq1wnT4ntDUdCJKmnEakhsuoUSy6ExlHCkGP2hqnatagYTgFk6oeL0VU31u7SNjunPN+GchtA=="], - - "ansi-regex": ["ansi-regex@6.3.0", "", {}, "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ=="], - - "bun-ffi-structs": ["bun-ffi-structs@0.3.1", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-3gM7PpVWLyrwxWjcilSiGuhWanhZivvo6l0u573NziPH6f/gwk6McbaYgn7oJWov6pKGRTDbrg94W5DcJsKTtQ=="], - - "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], - - "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], - - "diff": ["diff@9.0.0", "", {}, "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw=="], - - "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], - - "get-east-asian-width": ["get-east-asian-width@1.4.0", "", {}, "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q=="], - - "marked": ["marked@17.0.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg=="], - - "react": ["react@19.2.0", "", {}, "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ=="], - - "react-devtools-core": ["react-devtools-core@7.0.1", "", { "dependencies": { "shell-quote": "^1.6.1", "ws": "^7" } }, "sha512-C3yNvRHaizlpiASzy7b9vbnBGLrhvdhl1CbdU6EnZgxPNbai60szdLtl+VL76UNOt5bOoVTOz5rNWZxgGt+Gsw=="], - - "react-reconciler": ["react-reconciler@0.33.0", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.0" } }, "sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA=="], - - "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], - - "shell-quote": ["shell-quote@1.10.0", "", {}, "sha512-w1aiOKwKuRgtwAReIIj89puqg+I7GvX4IbLrvmhXbzQsj1+Zwi4VO3+fa6ZF91TWSjIxoEkKnMeHcLEODK5ZXA=="], - - "string-width": ["string-width@8.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.0", "strip-ansi": "^7.1.0" } }, "sha512-Kxl3KJGb/gxkaUMOjRsQ8IrXiGW75O4E3RPjFIINOVH8AMl2SQ/yWdTzWwF3FevIX9LcMAjJW+GRwAlAbTSXdg=="], - - "strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="], - - "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], - - "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], - - "web-tree-sitter": ["web-tree-sitter@0.25.10", "", { "peerDependencies": { "@types/emscripten": "^1.40.0" }, "optionalPeers": ["@types/emscripten"] }, "sha512-Y09sF44/13XvgVKgO2cNDw5rGk6s26MgoZPXLESvMXeefBf7i6/73eFurre0IsTW6E14Y0ArIzhUMmjoc7xyzA=="], - - "ws": ["ws@8.21.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw=="], - - "zod": ["zod@4.1.12", "", {}, "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ=="], - - "@opentui/core/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], - - "react-devtools-core/ws": ["ws@7.5.13", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA=="], - - "string-width/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], - - "@opentui/core/string-width/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], - } -} diff --git a/diffr/tui/packages/hunk/src/diffr/selection.ts b/diffr/tui/packages/hunk/src/diffr/selection.ts deleted file mode 100644 index a48986007..000000000 --- a/diffr/tui/packages/hunk/src/diffr/selection.ts +++ /dev/null @@ -1,48 +0,0 @@ -/** Copy original source lines from one side, excluding gutters, padding, and wrapped duplicates. */ -import type { DiffFile } from "./wire"; -import type { ViewerRow } from "./rows"; -export interface SourceSelection { - anchor: string; - end: string; - side: "left" | "right"; -} -export function selectionBounds( - rows: ViewerRow[], - selection: SourceSelection | null, -): [number, number] { - if (!selection) return [-1, -1]; - const a = rows.findIndex((r) => r.key === selection.anchor), - b = rows.findIndex((r) => r.key === selection.end); - return a < 0 || b < 0 ? [-1, -1] : [Math.min(a, b), Math.max(a, b)]; -} -export function copySelection( - files: (DiffFile | undefined)[], - rows: ViewerRow[], - selection: SourceSelection, -): string { - const [a, b] = selectionBounds(rows, selection), - seen = new Set(), - result: string[] = []; - const sources = new Map(); - for (const row of rows.slice(a < 0 ? rows.length : a, b + 1)) { - const n = - selection.side === "left" - ? (row.left?.lineNumber ?? row.cell?.oldLineNumber) - : (row.right?.lineNumber ?? row.cell?.newLineNumber); - if (n === undefined) continue; - const key = `${row.fileIndex}:${n}`; - if (seen.has(key)) continue; - seen.add(key); - const diff = files[row.fileIndex]?.diff; - if (!diff || diff.type !== "text") continue; - const source = selection.side === "left" ? diff.lhs : diff.rhs; - if (!source) continue; - let lines = sources.get(row.fileIndex); - if (!lines) { - lines = source.text.split("\n"); - sources.set(row.fileIndex, lines); - } - result.push(lines[n - 1]); - } - return result.join("\n"); -} diff --git a/diffr/tui/packages/hunk/src/lib/terminalText.test.ts b/diffr/tui/packages/hunk/src/lib/terminalText.test.ts deleted file mode 100644 index aa99429ae..000000000 --- a/diffr/tui/packages/hunk/src/lib/terminalText.test.ts +++ /dev/null @@ -1,145 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { - formatTerminalPath, - sanitizeTerminalSpans, - sanitizeTerminalText, -} from "./terminalText"; - -const OSC52_CLIPBOARD = "\x1b]52;c;SGVsbG8=\x07"; -const OSC_ST = "\x1b]8;;https://example.test\x1b\\"; -const CSI_CLEAR_SCREEN = "\x1b[2J"; -const DCS_PAYLOAD = "\x1bPqpayload\x1b\\"; -const APC_PAYLOAD = "\x1b_payload\x1b\\"; -const PM_PAYLOAD = "\x1b^payload\x1b\\"; -const SOS_PAYLOAD = "\x1bXpayload\x1b\\"; -const C1_OSC = "\x9d52;c;SGVsbG8=\x07"; -const C1_CSI = "\x9b2J"; -const C1_DCS = "\x90payload\x9c"; - -function expectNoUnsafeTerminalControls(text: string) { - expect(text).not.toContain("\x1b"); - expect(text).not.toContain("\x07"); - expect(text).not.toContain("\x08"); - expect(text).not.toContain("\x0b"); - expect(text).not.toContain("\x0c"); - expect(text).not.toContain("\x0d"); - expect(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]/.test(text)).toBe(false); -} - -describe("sanitizeTerminalText", () => { - test("removes terminal control strings without dropping surrounding text", () => { - const input = [ - "before", - OSC52_CLIPBOARD, - OSC_ST, - CSI_CLEAR_SCREEN, - DCS_PAYLOAD, - APC_PAYLOAD, - PM_PAYLOAD, - SOS_PAYLOAD, - C1_OSC, - C1_CSI, - C1_DCS, - "after", - ].join(""); - - const output = sanitizeTerminalText(input); - - expect(output).toContain("before"); - expect(output).toContain("after"); - expectNoUnsafeTerminalControls(output); - }); - - test("neutralizes visual spoofing controls", () => { - const output = sanitizeTerminalText("safe\rOVERWRITE\bhidden\x1b"); - - expect(output).toContain("safe"); - expect(output).toContain("OVERWRITE"); - expect(output).toContain("hidden"); - expectNoUnsafeTerminalControls(output); - }); - - test("preserves ordinary multiline text and tabs when allowed", () => { - expect(sanitizeTerminalText("alpha\n\tbeta")).toBe("alpha\n\tbeta"); - }); - - test("can preserve ANSI SGR styling while removing unsafe controls", () => { - const output = sanitizeTerminalText( - `plain\x1b[1;34mblue\x1b[m${OSC52_CLIPBOARD}${CSI_CLEAR_SCREEN}\x1b[2Kdone`, - { preserveAnsiStyle: true }, - ); - - expect(output).toBe("plain\x1b[1;34mblue\x1b[mdone"); - }); - - test("does not preserve non-SGR CSI sequences as ANSI styling", () => { - const output = sanitizeTerminalText("safe\x1b[2J\x1b[H\x1b[?25ltext", { - preserveAnsiStyle: true, - }); - - expect(output).toBe("safetext"); - }); - - test("removes crafted style placeholder delimiters before restoring ANSI styling", () => { - const output = sanitizeTerminalText( - "safe\u{f0000}0\u{f0001}\x1b[31mred\x1b[m", - { - preserveAnsiStyle: true, - }, - ); - - expect(output).toBe("safe0\x1b[31mred\x1b[m"); - }); - - test("restores dense ANSI styling in linear time", () => { - // `hunk pager` receives whole `git log --graph --color=always` streams from hosts like - // LazyGit: a few megabytes carrying ~170k SGR sequences. Restoring styles one at a time - // rescanned the entire document per sequence, so this input pegged a core for minutes and - // grew to gigabytes. Assert both the styled output and a wall-clock budget that only - // quadratic restoration can exceed. - const commitCount = 5_000; - const input = Array.from( - { length: commitCount }, - (_, index) => - `\x1b[33m* commit ${index}\x1b[m \x1b[1;36m(\x1b[1;32mHEAD\x1b[1;36m)\x1b[m\n` + - `\x1b[32m| Author: someone\x1b[m\n`, - ).join(""); - const sequenceCount = input.match(/\x1b\[[0-9;:]*m/g)?.length ?? 0; - expect(sequenceCount).toBeGreaterThan(25_000); - - const startedAt = performance.now(); - const output = sanitizeTerminalText(input, { preserveAnsiStyle: true }); - const elapsedMs = performance.now() - startedAt; - - expect(output).toBe(input); - expect(elapsedMs).toBeLessThan(2_000); - }); - - test("renders path controls as visible escapes without confusing literal backslashes", () => { - const output = formatTerminalPath("dir/literal\\t-tab\tline\nescape\x1b"); - - expect(output).toBe("dir/literal\\\\t-tab\\tline\\nescape\\x1b"); - expectNoUnsafeTerminalControls(output); - }); - - test("returns an already-safe mutable span array unchanged", () => { - const spans = [{ text: "safe", fg: "#fff" }]; - - expect(sanitizeTerminalSpans(spans)).toBe(spans); - }); - - test("sanitizes span text while preserving styling metadata", () => { - const spans = [ - { text: `before${OSC52_CLIPBOARD}`, fg: "#fff" }, - { text: "\x1b[2J" }, - { text: "after", bg: "#000" }, - ]; - - const output = sanitizeTerminalSpans(spans); - - expect(output).toEqual([ - { text: "before", fg: "#fff" }, - { text: "after", bg: "#000" }, - ]); - }); -}); diff --git a/diffr/tui/packages/hunk/src/lib/terminalText.ts b/diffr/tui/packages/hunk/src/lib/terminalText.ts deleted file mode 100644 index 3910c349a..000000000 --- a/diffr/tui/packages/hunk/src/lib/terminalText.ts +++ /dev/null @@ -1,129 +0,0 @@ -export interface SanitizeTerminalTextOptions { - /** Preserve line feeds for multiline text fields. Defaults to true. */ - preserveNewlines?: boolean; - /** Preserve horizontal tabs for text fields that intentionally support them. Defaults to true. */ - preserveTabs?: boolean; - /** Preserve ANSI SGR style sequences such as Git color output. Defaults to false. */ - preserveAnsiStyle?: boolean; -} - -const controlCodeRegex = /[\x00-\x1f\x7f-\x9f]/; -// Keep these global regexes private and use them only with String#replace below. -// Calling test/exec on shared /g regexes would make lastIndex stateful between calls. -const sevenBitControlStrings = - /\x1b(?:\][\s\S]*?(?:\x07|\x1b\\|\x9c)|[PX^_][\s\S]*?(?:\x1b\\|\x9c)|\[[0-?]*[ -/]*[@-~])/g; -const c1ControlStrings = /[\x90\x98\x9d\x9e\x9f][\s\S]*?(?:\x07|\x1b\\|\x9c)/g; -const c1Csi = /\x9b[0-?]*[ -/]*[@-~]/g; -const preservedStyleTokenDelimiters = /[\u{f0000}\u{f0001}]/gu; -const preservedStyleTokens = /\u{f0000}(\d+)\u{f0001}/gu; - -/** Normalize untrusted terminal-bound text before rendering it in Hunk UI surfaces. */ -export function sanitizeTerminalText( - text: string, - { - preserveNewlines = true, - preserveTabs = true, - preserveAnsiStyle = false, - }: SanitizeTerminalTextOptions = {}, -) { - if (!controlCodeRegex.test(text)) { - return text; - } - - const controlCharacters = preserveNewlines - ? preserveTabs - ? /[\x00-\x08\x0b-\x1f\x7f-\x9f]/g - : /[\x00-\x09\x0b-\x1f\x7f-\x9f]/g - : preserveTabs - ? /[\x00-\x08\x0a-\x1f\x7f-\x9f]/g - : /[\x00-\x1f\x7f-\x9f]/g; - const preservedStyles: string[] = []; - const preserveStyle = (sequence: string) => { - if (!preserveAnsiStyle || !/^\x1b\[[0-9;:]*m$/.test(sequence)) { - return ""; - } - - const token = `\u{f0000}${preservedStyles.length}\u{f0001}`; - preservedStyles.push(sequence); - return token; - }; - - // Strip placeholder delimiters from untrusted input so authored text cannot spoof - // an internal token that later restores an ANSI sequence at the wrong location. - const tokenSafeText = preserveAnsiStyle - ? text.replace(preservedStyleTokenDelimiters, "") - : text; - - const sanitized = tokenSafeText - .replace(sevenBitControlStrings, preserveStyle) - .replace(c1ControlStrings, "") - .replace(c1Csi, "") - .replace(controlCharacters, ""); - - if (preservedStyles.length === 0) { - return sanitized; - } - - // Restore every placeholder in a single pass. Replacing one style at a time rescans and - // reallocates the whole document per preserved sequence, which is quadratic in ANSI-dense - // input: a few megabytes of `git log --graph --color=always` piped through `hunk pager` - // carries ~170k sequences and would peg a core for minutes while churning gigabytes. - // Input delimiters were stripped above, so every surviving token indexes a captured style. - return sanitized.replace( - preservedStyleTokens, - (_token, index: string) => preservedStyles[Number(index)] ?? "", - ); -} - -/** Sanitize a single terminal row or cell where newlines must never be preserved. */ -export function sanitizeTerminalLine(text: string) { - return sanitizeTerminalText(text, { - preserveNewlines: false, - preserveTabs: true, - }); -} - -/** Render an exact filesystem path safely without letting controls alter terminal geometry. */ -export function formatTerminalPath(path: string) { - let formatted = ""; - for (const character of path) { - const codePoint = character.codePointAt(0)!; - if (character === "\\") { - formatted += "\\\\"; - } else if (character === "\t") { - formatted += "\\t"; - } else if (character === "\n") { - formatted += "\\n"; - } else if (character === "\r") { - formatted += "\\r"; - } else if (codePoint <= 0x1f || (codePoint >= 0x7f && codePoint <= 0x9f)) { - formatted += `\\x${codePoint.toString(16).padStart(2, "0")}`; - } else { - formatted += character; - } - } - return formatted; -} - -/** Sanitize render spans while preserving their non-text styling metadata. */ -export function sanitizeTerminalSpans( - spans: T[], -): T[] { - let sanitized: T[] | null = null; - for (let index = 0; index < spans.length; index += 1) { - const span = spans[index]!; - const text = sanitizeTerminalLine(span.text); - if (text === span.text && text.length > 0) { - sanitized?.push(span); - continue; - } - - // Delay the output copy until the first actual change so already-safe immutable span arrays can - // flow through hot rendering paths without allocating either an array or replacement objects. - sanitized ??= spans.slice(0, index); - if (text.length > 0) { - sanitized.push({ ...span, text } as T); - } - } - return sanitized ?? spans; -} diff --git a/diffr/tui/packages/hunk/src/ui/diff/CodeRowView.tsx b/diffr/tui/packages/hunk/src/ui/diff/CodeRowView.tsx deleted file mode 100644 index 29dc80659..000000000 --- a/diffr/tui/packages/hunk/src/ui/diff/CodeRowView.tsx +++ /dev/null @@ -1,219 +0,0 @@ -/** Paint measured Hunk-style code cells; source identity and viewport geometry stay outside React. */ -import { memo } from "react"; -import { StyledText, parseColor, type MouseEvent } from "@opentui/core"; -import type { - RenderSpan, - ScopeFocus, - SplitLineCell, - UnifiedLineCell, -} from "./diffRowModel"; -import type { Geometry, MeasuredRow } from "../../diffr/geometry"; -import { foldBackground, type Palette } from "../../diffr/theme"; -import type { RowFold } from "../../diffr/regions"; -import { measureTextWidth } from "../lib/text"; -const colors = new Map>(); -function color(value: string) { - let c = colors.get(value); - if (!c) { - c = parseColor(value); - colors.set(value, c); - } - return c; -} -/** 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) => { - 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), - }; - }), - ); -} -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, - geometry, - theme, - selectedSide, - onSelect, - onExtend, - onFold, - focus, - onHover, -}: { - measured: MeasuredRow; - visualLine: number; - geometry: Geometry; - theme: Palette; - selectedSide?: "left" | "right"; - onSelect: (side: "left" | "right") => void; - onExtend: () => void; - focus?: ScopeFocus; - onHover: (focus: ScopeFocus | undefined) => void; - onFold: (id: number, recursive: boolean) => void; -}) { - const row = measured.row; - function cell( - value: SplitLineCell | UnifiedLineCell, - spans: RenderSpan[], - width: number, - side: "left" | "right", - unified = false, - ) { - const fold = value.fold; - const washed = focus?.armed && value.body?.includes(focus.id); - const bg = - selectedSide === side - ? theme.highlight - : value.kind === "addition" - ? theme.addition - : value.kind === "deletion" - ? theme.deletion - : 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); - const numbers = unified - ? ` ${number((value as UnifiedLineCell).oldLineNumber)} ${number((value as UnifiedLineCell).newLineNumber)} ` - : ` ${number("lineNumber" in value ? value.lineNumber : undefined)} `; - 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)); - // 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) 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={(event) => { onExtend(); onHover(target(event)); }} - onMouseOut={() => onHover(undefined)} - > - - {numbers} - - { - if (fold && !visualLine) event.stopPropagation(); - }} - onMouseUp={(event) => { - if (fold && !visualLine && event.button === 0) { - event.stopPropagation(); - onFold(fold.id, event.modifiers.alt); - } - }} - > - {visualLine ? " " : chevron(fold)} - - - {" "} - - { if (fold?.collapsed) event.stopPropagation(); }} - onMouseUp={event => { - 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, - focus, - )} - selectable={false} - /> - - - ); - } - return ( - - {row.cell ? ( - cell( - row.cell, - measured.cell[visualLine] ?? [], - geometry.leftWidth + geometry.rightWidth + 1, - row.cell.newLineNumber === undefined ? "left" : "right", - true, - ) - ) : ( - <> - {cell( - row.left!, - measured.left[visualLine] ?? [], - geometry.leftWidth, - "left", - )} - - │ - - {cell( - row.right!, - measured.right[visualLine] ?? [], - geometry.rightWidth, - "right", - )} - - )} - - ); -}); diff --git a/diffr/tui/packages/hunk/src/ui/diff/diffRowModel.ts b/diffr/tui/packages/hunk/src/ui/diff/diffRowModel.ts deleted file mode 100644 index 93d8a84b5..000000000 --- a/diffr/tui/packages/hunk/src/ui/diff/diffRowModel.ts +++ /dev/null @@ -1,57 +0,0 @@ -/** - * Declares the terminal diff row model: the row and span shapes every review surface - * measures, windows, and paints. - * - * Kept as a leaf module so both the row builders (`diffRows.ts`) and their consumers — - * column math, the highlight worker, geometry — can share these types without importing - * the builders themselves. - */ -import type { FoldTint, RowFold } from "../../diffr/regions"; - -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. */ - transformFg?: (sourceFg: string | undefined, renderedBg: string) => string; -} - -export interface SplitLineCell { - kind: "context" | "addition" | "deletion" | "empty"; - sign: string; - lineNumber?: number; - /** This cell starts a fold region; the chevron and placeholder come from here. */ - fold?: RowFold; - /** A line of a collapsed fold's label, painted in the fold tint without a line number. */ - 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[]; -} - -export interface UnifiedLineCell { - kind: "context" | "addition" | "deletion"; - sign: string; - oldLineNumber?: number; - newLineNumber?: number; - 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/diffr/tui/packages/hunk/src/ui/diff/rowWindowing.test.ts b/diffr/tui/packages/hunk/src/ui/diff/rowWindowing.test.ts deleted file mode 100644 index 48a93bc12..000000000 --- a/diffr/tui/packages/hunk/src/ui/diff/rowWindowing.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { expect, test } from "bun:test"; -import { resolveVisibleRowIndexWindow as window } from "./rowWindowing"; -test("window keeps wrapped rows intersecting either edge and replaces skipped height", () => { - expect( - window({ - bodyHeight: 12, - rowBounds: [ - { top: 0, height: 2 }, - { top: 2, height: 5 }, - { top: 7, height: 1 }, - { top: 8, height: 4 }, - ], - visibleBodyBounds: { top: 3, height: 5 }, - }), - ).toEqual({ - startIndex: 1, - endIndex: 3, - topSpacerHeight: 2, - bottomSpacerHeight: 4, - }); -}); -test("offscreen file becomes only spacers", () => { - expect( - window({ - bodyHeight: 12, - rowBounds: [{ top: 0, height: 12 }], - visibleBodyBounds: { top: 20, height: 5 }, - }), - ).toEqual({ - startIndex: 0, - endIndex: 0, - topSpacerHeight: 12, - bottomSpacerHeight: 0, - }); -}); -test("hidden structural rows at the viewport boundary retain their anchors", () => { - expect( - window({ - bodyHeight: 4, - rowBounds: [ - { top: 0, height: 2 }, - { top: 2, height: 0 }, - { top: 2, height: 2 }, - { top: 4, height: 0 }, - ], - visibleBodyBounds: { top: 2, height: 2 }, - }), - ).toEqual({ - startIndex: 1, - endIndex: 4, - topSpacerHeight: 2, - bottomSpacerHeight: 0, - }); -}); diff --git a/diffr/tui/packages/hunk/src/ui/diff/rowWindowing.ts b/diffr/tui/packages/hunk/src/ui/diff/rowWindowing.ts deleted file mode 100644 index a48f97da3..000000000 --- a/diffr/tui/packages/hunk/src/ui/diff/rowWindowing.ts +++ /dev/null @@ -1,177 +0,0 @@ -export interface RowBounds { - top: number; - height: number; -} - -/** One visible slice within a file body, measured in file-local row units. */ -export interface VisibleBodyBounds { - top: number; - height: number; -} - -/** Index-only visible slice over measured terminal rows. */ -export interface VisibleRowIndexWindow { - bottomSpacerHeight: number; - endIndex: number; - startIndex: number; - topSpacerHeight: number; -} - -/** - * Find the first row whose bottom edge is after the visible top boundary. - * Requires row bounds to be sorted by non-decreasing row bottom. - */ -function findFirstRowWithBottomAfter( - rowBounds: readonly RowBounds[], - top: number, -) { - let low = 0; - let high = rowBounds.length - 1; - let result = rowBounds.length; - - while (low <= high) { - const mid = (low + high) >>> 1; - const rowBoundsEntry = rowBounds[mid]!; - - if (rowBoundsEntry.top + rowBoundsEntry.height > top) { - result = mid; - high = mid - 1; - } else { - low = mid + 1; - } - } - - return result; -} - -/** - * Find the last row whose top edge is before the visible bottom boundary. - * Requires row bounds to be sorted by non-decreasing row top. - */ -function findLastRowWithTopBefore( - rowBounds: readonly RowBounds[], - bottom: number, -) { - let low = 0; - let high = rowBounds.length - 1; - let result = -1; - - while (low <= high) { - const mid = (low + high) >>> 1; - const rowBoundsEntry = rowBounds[mid]!; - - if (rowBoundsEntry.top < bottom) { - result = mid; - low = mid + 1; - } else { - high = mid - 1; - } - } - - return result; -} - -/** Return whether one measured row overlaps the requested closed-open visible interval. */ -function rowOverlapsVisibleRange( - rowBounds: RowBounds, - minVisibleTop: number, - maxVisibleBottom: number, -) { - if (rowBounds.height <= 0) { - return false; - } - - const rowBottom = rowBounds.top + rowBounds.height; - return rowBottom > minVisibleTop && rowBounds.top < maxVisibleBottom; -} - -/** Resolve a measured visible slice in O(log n + edge structural rows). */ -export function resolveVisibleRowIndexWindow({ - bodyHeight, - rowBounds, - visibleBodyBounds, -}: { - bodyHeight: number; - rowBounds: readonly RowBounds[]; - visibleBodyBounds: VisibleBodyBounds; -}): VisibleRowIndexWindow { - const minVisibleTop = Math.max(0, visibleBodyBounds.top); - const maxVisibleBottom = Math.min( - bodyHeight, - visibleBodyBounds.top + Math.max(0, visibleBodyBounds.height), - ); - - let firstVisibleIndex = findFirstRowWithBottomAfter(rowBounds, minVisibleTop); - while ( - firstVisibleIndex < rowBounds.length && - !rowOverlapsVisibleRange( - rowBounds[firstVisibleIndex]!, - minVisibleTop, - maxVisibleBottom, - ) - ) { - firstVisibleIndex += 1; - } - - let lastVisibleIndex = findLastRowWithTopBefore(rowBounds, maxVisibleBottom); - while ( - lastVisibleIndex >= 0 && - !rowOverlapsVisibleRange( - rowBounds[lastVisibleIndex]!, - minVisibleTop, - maxVisibleBottom, - ) - ) { - lastVisibleIndex -= 1; - } - - if (firstVisibleIndex >= rowBounds.length) { - firstVisibleIndex = -1; - } - - // firstVisibleIndex > lastVisibleIndex should not happen with sorted row bounds, but keep the - // empty-window fallback defensive in case an upstream geometry invariant is ever broken. - if ( - firstVisibleIndex < 0 || - lastVisibleIndex < 0 || - firstVisibleIndex > lastVisibleIndex - ) { - const topSpacerHeight = Math.min(bodyHeight, minVisibleTop); - - return { - bottomSpacerHeight: Math.max(0, bodyHeight - topSpacerHeight), - endIndex: 0, - startIndex: 0, - topSpacerHeight, - }; - } - - let startIndex = firstVisibleIndex; - // Zero-height rows still matter structurally: for example, hidden hunk headers keep anchor ids - // and stable row ordering. If one sits immediately before the visible slice, keep it attached. - while (startIndex > 0 && rowBounds[startIndex - 1]?.height === 0) { - startIndex -= 1; - } - - let endIndex = lastVisibleIndex + 1; - // Do the same on the trailing edge so hidden structural rows continue to travel with the last - // visible rendered row instead of being stranded in the spacer region. - while (endIndex < rowBounds.length && rowBounds[endIndex]?.height === 0) { - endIndex += 1; - } - - const startRowBounds = rowBounds[startIndex]!; - const endRowBounds = rowBounds[endIndex - 1]!; - - return { - // The top spacer is exactly the skipped body height before the first mounted row. - topSpacerHeight: startRowBounds.top, - startIndex, - endIndex, - // The bottom spacer is the remaining body height after the last mounted row's bottom edge. - bottomSpacerHeight: Math.max( - 0, - bodyHeight - (endRowBounds.top + endRowBounds.height), - ), - }; -} diff --git a/diffr/xtask/src/main.rs b/diffr/xtask/src/main.rs index 109a8c697..305b93e3a 100644 --- a/diffr/xtask/src/main.rs +++ b/diffr/xtask/src/main.rs @@ -40,10 +40,10 @@ fn install(root: &Path, with_cli: bool) -> Result<()> { "Bun --version failed; check your Bun installation" ); run( - Command::new(&bun) - .current_dir(root.join("tui")) - .args(["install", "--frozen-lockfile"]), - "Installing TUI dependencies", + Command::new(if cfg!(windows) { "pnpm.cmd" } else { "pnpm" }) + .current_dir(root.parent().unwrap()) + .args(["--filter", "@diffr/tui...", "install", "--frozen-lockfile"]), + "Installing TUI dependencies with pnpm", )?; let artifact = root .join("target/tui") @@ -51,7 +51,7 @@ fn install(root: &Path, with_cli: bool) -> Result<()> { std::fs::create_dir_all(artifact.parent().unwrap())?; run( Command::new(&bun) - .current_dir(root.join("tui")) + .current_dir(root.join("packages/tui")) .args([ "build", "--compile", @@ -61,7 +61,7 @@ fn install(root: &Path, with_cli: bool) -> Result<()> { } else { "process.env.OPENTUI_LIBC=\"glibc\"" }, - "packages/hunk/src/main.tsx", + "src/main.tsx", "--outfile", ]) .arg(&artifact), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6e71fa231..903468ec1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -108,6 +108,68 @@ importers: specifier: 0.1.17 version: 0.1.17 + diffr/packages/tui: + dependencies: + '@diffr/viewer': + specifier: workspace:* + version: link:../viewer + '@opentui/core': + specifier: 0.5.6 + version: 0.5.6(typescript@5.9.3)(web-tree-sitter@0.25.10) + '@opentui/react': + specifier: 0.5.6 + version: 0.5.6(react-devtools-core@7.0.1)(react@19.2.0)(typescript@5.9.3)(web-tree-sitter@0.25.10)(ws@8.21.3) + react: + specifier: 19.2.0 + version: 19.2.0 + react-devtools-core: + specifier: 7.0.1 + version: 7.0.1 + web-tree-sitter: + specifier: 0.25.10 + version: 0.25.10 + ws: + specifier: 8.21.3 + version: 8.21.3 + devDependencies: + '@types/bun': + specifier: 1.3.14 + version: 1.3.14 + '@types/react': + specifier: 19.2.0 + version: 19.2.0 + typescript: + specifier: 5.9.3 + version: 5.9.3 + + diffr/packages/viewer: + dependencies: + fuzzysort: + specifier: 4.0.2 + version: 4.0.2 + get-east-asian-width: + specifier: 1.4.0 + version: 1.4.0 + smol-toml: + specifier: 1.9.0 + version: 1.9.0 + string-width: + specifier: 8.1.0 + version: 8.1.0 + zod: + specifier: 4.6.5 + version: 4.6.5 + devDependencies: + '@types/node': + specifier: 24.12.2 + version: 24.12.2 + typescript: + specifier: 5.9.3 + version: 5.9.3 + vitest: + specifier: 4.1.11 + version: 4.1.11(@types/node@24.12.2)(@vitest/browser-playwright@4.1.11)(esbuild@0.28.1)(jsdom@29.1.1)(tsx@4.21.0) + packages/agent-plugins/pi: {} packages/json: @@ -865,6 +927,60 @@ packages: engines: {node: '>=16'} hasBin: true + '@opentui/core-darwin-arm64@0.5.6': + resolution: {integrity: sha512-g/OOSSXuFlpLOhLHm8w8MsMh1oGVYlQ7QuW8l6oqG9KIkOg4hkYN5eFnpe6Px1p6cNCw5NOC+198WwdcKhQncg==} + cpu: [arm64] + os: [darwin] + + '@opentui/core-darwin-x64@0.5.6': + resolution: {integrity: sha512-4+z7/CaV27u9JpdooMa6oRIpLwsaA5j1a2xz7Xzm8oWhakeKGZnW0wUJAfStn/sks3NK5tUpF+eKHa9oawGiEw==} + cpu: [x64] + os: [darwin] + + '@opentui/core-linux-arm64-musl@0.5.6': + resolution: {integrity: sha512-cMdxhADkuJW0Nkn7pTB43q5Djg4Ch5i3hlC4iLOVkbOgvckKqz8xJHdl7fuqVs8g5LaadGBmZqdlFKjXIMKl2g==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@opentui/core-linux-arm64@0.5.6': + resolution: {integrity: sha512-RAiRmosbg7DH4+ZuXRMjEmQmDAWhcRpuQKQ6bGL76nUd6wl/CufBiD7il/FQMQyUGzmQQH0Hu+pVQLz+kAkNJw==} + cpu: [arm64] + os: [linux] + + '@opentui/core-linux-x64-musl@0.5.6': + resolution: {integrity: sha512-vb5mn1i1s8/3lp03O6lX3190eN+iVCZxXOl9qCO5BTCXWtFztyIkp5YDad3/20SptcOHcLPKDiRGWNmSkYBrkQ==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@opentui/core-linux-x64@0.5.6': + resolution: {integrity: sha512-gzlNauPg5UT5UnWiSIlONRwKnkNA6fjN8SyRGlT3SGvcAox+vT2WeP6TwoJSM6pq5H5MF/tlY+REIqc/ZMGJtQ==} + cpu: [x64] + os: [linux] + + '@opentui/core-win32-arm64@0.5.6': + resolution: {integrity: sha512-FTSpFrNyuh4k2Z3+KeLVLlryFpe89ZyQcqBE0XcmtgiU3RlXuoMPSj2TtRekukR/FaWw9toDBfWokiQjOJGjrA==} + cpu: [arm64] + os: [win32] + + '@opentui/core-win32-x64@0.5.6': + resolution: {integrity: sha512-rWplkCuHX0OxVi7bO8dAVPttzPpCgmz6Bsu1XCm4ErPAhZOofRd5FbeW2+pC71AbLOKicSwI7lQ4mhGCNaweaA==} + cpu: [x64] + os: [win32] + + '@opentui/core@0.5.6': + resolution: {integrity: sha512-e38H1VceXoSSOEYmhUeHDE3F3LHXn/sFEZ6NhbXkbYdrkdV+1MMTgEkGZJ9I5fHhBlJdGJ1LDTPw8V0ZSUXSyQ==} + peerDependencies: + web-tree-sitter: 0.25.10 + + '@opentui/react@0.5.6': + resolution: {integrity: sha512-dMQTlcD1zlDTQC8r9rZKlHD7CORkY2M5D7tqhgoccoOK9p6q05FqrdRCTYHu1+VZYafqIV0k2IpMY2e34sngiQ==} + peerDependencies: + react: '>=19.2.0' + react-devtools-core: ^7.0.1 + ws: ^8.18.0 + '@oxc-project/types@0.133.0': resolution: {integrity: sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==} @@ -1350,6 +1466,9 @@ packages: '@tybys/wasm-util@0.10.3': resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} + '@types/bun@1.3.14': + resolution: {integrity: sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw==} + '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} @@ -1407,6 +1526,9 @@ packages: peerDependencies: '@types/react': ^19.2.0 + '@types/react@19.2.0': + resolution: {integrity: sha512-1LOH8xovvsKsCBq1wnT4ntDUdCJKmnEakhsuoUSy6ExlHCkGP2hqnatagYTgFk6oeL0VU31u7SNjunPN+GchtA==} + '@types/react@19.2.14': resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} @@ -1775,6 +1897,10 @@ packages: ajv@8.20.0: resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + ansi-regex@6.4.0: + resolution: {integrity: sha512-KzTVk2tCWAHtYrvvvaP8bJKJq2pVinhLcGEQdtLIYPbmNGNyYe8QwNaTUYQp2J7/vIsUKt5QCqAfUkYyG9DkOw==} + engines: {node: '>=12'} + ansis@4.3.1: resolution: {integrity: sha512-BJ8/l4R5LRE7hW9WdSuGYrLSHi2ynxeFpDFbH0K/CgNeY/tyhk+vO6TYxXC5r5CpUhNVX310xzPsN/H9lCdfOA==} engines: {node: '>=14'} @@ -1804,6 +1930,14 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + bun-ffi-structs@0.3.1: + resolution: {integrity: sha512-3gM7PpVWLyrwxWjcilSiGuhWanhZivvo6l0u573NziPH6f/gwk6McbaYgn7oJWov6pKGRTDbrg94W5DcJsKTtQ==} + peerDependencies: + typescript: ^5 + + bun-types@1.3.14: + resolution: {integrity: sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ==} + bundle-name@4.1.0: resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} engines: {node: '>=18'} @@ -2012,6 +2146,9 @@ packages: elkjs@0.11.1: resolution: {integrity: sha512-zxxR9k+rx5ktMwT/FwyLdPCrq7xN6e4VGGHH8hA01vVYKjTFik7nHOxBnAYtrgYUB1RpAiLvA1/U2YraWxyKKg==} + emoji-regex@10.6.0: + resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} + empathic@2.0.1: resolution: {integrity: sha512-YGRs8knHhKHVShLkFET/rWAU8kmHbOV5LwN938RHI0pljAJ1Gf6SzXsSmRaEzcXTtOOmVqJ5+WtQPL5uigY50Q==} engines: {node: '>=14'} @@ -2140,6 +2277,10 @@ packages: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} + get-east-asian-width@1.4.0: + resolution: {integrity: sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==} + engines: {node: '>=18'} + get-intrinsic@1.3.0: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} @@ -2386,6 +2527,11 @@ packages: markdown-table@3.0.4: resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} + marked@17.0.1: + resolution: {integrity: sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg==} + engines: {node: '>= 20'} + hasBin: true + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -2718,11 +2864,24 @@ packages: resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} engines: {node: '>= 0.10'} + react-devtools-core@7.0.1: + resolution: {integrity: sha512-C3yNvRHaizlpiASzy7b9vbnBGLrhvdhl1CbdU6EnZgxPNbai60szdLtl+VL76UNOt5bOoVTOz5rNWZxgGt+Gsw==} + react-dom@19.2.5: resolution: {integrity: sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==} peerDependencies: react: ^19.2.5 + react-reconciler@0.33.0: + resolution: {integrity: sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA==} + engines: {node: '>=0.10.0'} + peerDependencies: + react: ^19.2.0 + + react@19.2.0: + resolution: {integrity: sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==} + engines: {node: '>=0.10.0'} + react@19.2.5: resolution: {integrity: sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==} engines: {node: '>=0.10.0'} @@ -2836,6 +2995,10 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} + shell-quote@1.11.0: + resolution: {integrity: sha512-JdxDPD0DBTyu08pq0kPC0xSNet/qsU07qT6IsX1AS8oO2ICNRY4ldNa8OAI6PuwAH8tG3lxEhbqmyp4Dw4036g==} + engines: {node: '>= 0.4'} + side-channel-list@1.0.1: resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} engines: {node: '>= 0.4'} @@ -2887,6 +3050,22 @@ packages: std-env@4.2.0: resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} + string-width@7.2.0: + resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} + engines: {node: '>=18'} + + string-width@8.1.0: + resolution: {integrity: sha512-Kxl3KJGb/gxkaUMOjRsQ8IrXiGW75O4E3RPjFIINOVH8AMl2SQ/yWdTzWwF3FevIX9LcMAjJW+GRwAlAbTSXdg==} + engines: {node: '>=20'} + + strip-ansi@7.1.2: + resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==} + engines: {node: '>=12'} + + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + strip-json-comments@5.0.3: resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==} engines: {node: '>=14.16'} @@ -3151,6 +3330,14 @@ packages: resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} engines: {node: '>=18'} + web-tree-sitter@0.25.10: + resolution: {integrity: sha512-Y09sF44/13XvgVKgO2cNDw5rGk6s26MgoZPXLESvMXeefBf7i6/73eFurre0IsTW6E14Y0ArIzhUMmjoc7xyzA==} + peerDependencies: + '@types/emscripten': ^1.40.0 + peerDependenciesMeta: + '@types/emscripten': + optional: true + webidl-conversions@8.0.1: resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} engines: {node: '>=20'} @@ -3179,6 +3366,18 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + ws@7.5.13: + resolution: {integrity: sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA==} + engines: {node: '>=8.3.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: ^5.0.2 + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + ws@8.21.3: resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==} engines: {node: '>=10.0.0'} @@ -3663,6 +3862,61 @@ snapshots: '@openai/codex@0.158.0': {} + '@opentui/core-darwin-arm64@0.5.6': + optional: true + + '@opentui/core-darwin-x64@0.5.6': + optional: true + + '@opentui/core-linux-arm64-musl@0.5.6': + optional: true + + '@opentui/core-linux-arm64@0.5.6': + optional: true + + '@opentui/core-linux-x64-musl@0.5.6': + optional: true + + '@opentui/core-linux-x64@0.5.6': + optional: true + + '@opentui/core-win32-arm64@0.5.6': + optional: true + + '@opentui/core-win32-x64@0.5.6': + optional: true + + '@opentui/core@0.5.6(typescript@5.9.3)(web-tree-sitter@0.25.10)': + dependencies: + bun-ffi-structs: 0.3.1(typescript@5.9.3) + diff: 9.0.0 + marked: 17.0.1 + string-width: 7.2.0 + strip-ansi: 7.1.2 + web-tree-sitter: 0.25.10 + optionalDependencies: + '@opentui/core-darwin-arm64': 0.5.6 + '@opentui/core-darwin-x64': 0.5.6 + '@opentui/core-linux-arm64': 0.5.6 + '@opentui/core-linux-arm64-musl': 0.5.6 + '@opentui/core-linux-x64': 0.5.6 + '@opentui/core-linux-x64-musl': 0.5.6 + '@opentui/core-win32-arm64': 0.5.6 + '@opentui/core-win32-x64': 0.5.6 + transitivePeerDependencies: + - typescript + + '@opentui/react@0.5.6(react-devtools-core@7.0.1)(react@19.2.0)(typescript@5.9.3)(web-tree-sitter@0.25.10)(ws@8.21.3)': + dependencies: + '@opentui/core': 0.5.6(typescript@5.9.3)(web-tree-sitter@0.25.10) + react: 19.2.0 + react-devtools-core: 7.0.1 + react-reconciler: 0.33.0(react@19.2.0) + ws: 8.21.3 + transitivePeerDependencies: + - typescript + - web-tree-sitter + '@oxc-project/types@0.133.0': {} '@oxc-project/types@0.143.0': {} @@ -3936,6 +4190,10 @@ snapshots: tslib: 2.8.1 optional: true + '@types/bun@1.3.14': + dependencies: + bun-types: 1.3.14 + '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 @@ -3998,6 +4256,10 @@ snapshots: dependencies: '@types/react': 19.2.14 + '@types/react@19.2.0': + dependencies: + csstype: 3.2.3 + '@types/react@19.2.14': dependencies: csstype: 3.2.3 @@ -4259,6 +4521,8 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 + ansi-regex@6.4.0: {} + ansis@4.3.1: {} assertion-error@2.0.1: {} @@ -4293,6 +4557,14 @@ snapshots: node-releases: 2.0.56 update-browserslist-db: 1.3.3(browserslist@4.29.0) + bun-ffi-structs@0.3.1(typescript@5.9.3): + dependencies: + typescript: 5.9.3 + + bun-types@1.3.14: + dependencies: + '@types/node': 24.12.2 + bundle-name@4.1.0: dependencies: run-applescript: 7.1.0 @@ -4453,6 +4725,8 @@ snapshots: elkjs@0.11.1: {} + emoji-regex@10.6.0: {} + empathic@2.0.1: {} encodeurl@2.0.0: {} @@ -4602,6 +4876,8 @@ snapshots: gensync@1.0.0-beta.2: {} + get-east-asian-width@1.4.0: {} + get-intrinsic@1.3.0: dependencies: call-bind-apply-helpers: 1.0.2 @@ -4822,6 +5098,8 @@ snapshots: markdown-table@3.0.4: {} + marked@17.0.1: {} + math-intrinsics@1.1.0: {} mdast-util-find-and-replace@3.0.2: @@ -5367,11 +5645,26 @@ snapshots: iconv-lite: 0.7.3 unpipe: 1.0.0 + react-devtools-core@7.0.1: + dependencies: + shell-quote: 1.11.0 + ws: 7.5.13 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + react-dom@19.2.5(react@19.2.5): dependencies: react: 19.2.5 scheduler: 0.27.0 + react-reconciler@0.33.0(react@19.2.0): + dependencies: + react: 19.2.0 + scheduler: 0.27.0 + + react@19.2.0: {} + react@19.2.5: {} real-require@0.2.0: {} @@ -5510,6 +5803,8 @@ snapshots: shebang-regex@3.0.0: {} + shell-quote@1.11.0: {} + side-channel-list@1.0.1: dependencies: es-errors: 1.3.0 @@ -5564,6 +5859,25 @@ snapshots: std-env@4.2.0: {} + string-width@7.2.0: + dependencies: + emoji-regex: 10.6.0 + get-east-asian-width: 1.4.0 + strip-ansi: 7.1.2 + + string-width@8.1.0: + dependencies: + get-east-asian-width: 1.4.0 + strip-ansi: 7.2.0 + + strip-ansi@7.1.2: + dependencies: + ansi-regex: 6.4.0 + + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.4.0 + strip-json-comments@5.0.3: {} styleq@0.2.1: {} @@ -5790,6 +6104,8 @@ snapshots: dependencies: xml-name-validator: 5.0.0 + web-tree-sitter@0.25.10: {} + webidl-conversions@8.0.1: {} webpack-virtual-modules@0.6.2: {} @@ -5815,6 +6131,8 @@ snapshots: wrappy@1.0.2: {} + ws@7.5.13: {} + ws@8.21.3: {} wsl-utils@1.0.0: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index a8164362c..60ba5d5d0 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -3,6 +3,8 @@ packages: - "packages/agent-plugins/pi" - "packages/review/app" - "diffr/diffr-ts" + - "diffr/packages/viewer" + - "diffr/packages/tui" - "apps/*" catalog: