From c14c53d326e931b1e4b6be19875e596a94fb7ee9 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 11 Aug 2026 02:58:40 +0100 Subject: [PATCH 1/4] feat(renderers): pass content width to code block renderers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renderers that must be told their width — a Hunk diff block, say — had no way to learn it: `CodeBlockRendererProps` carried the block's indent but not its columns. `MarkdownView` now measures the terminal, subtracts the row-document gutter and the scrollbar reserve, and passes each block the columns left inside `CodeBlockChrome`. Flex-sized renderers can keep ignoring it. Also exports `SourceIndex` so packages outside `@tooee/renderers` can build source spans for their own row models. --- packages/renderers/src/code-blocks.tsx | 18 +++++++++++++++ packages/renderers/src/index.ts | 2 +- packages/renderers/src/markdown-view.tsx | 28 +++++++++++++++++++++++- 3 files changed, 46 insertions(+), 2 deletions(-) diff --git a/packages/renderers/src/code-blocks.tsx b/packages/renderers/src/code-blocks.tsx index 2a558cde..42cac1eb 100644 --- a/packages/renderers/src/code-blocks.tsx +++ b/packages/renderers/src/code-blocks.tsx @@ -45,6 +45,13 @@ export interface CodeBlockRendererProps { syntax: SyntaxStyle; /** Left indentation (columns) when the block is nested inside a list. */ indent: number; + /** + * Columns available to the renderer's own content: the document width less + * the gutter, the block's indent and margins, and `CodeBlockChrome`'s border. + * Renderers that must be told their width (Hunk diffs, for one) use it; + * flex-sized renderers can ignore it. Kept current across resizes. + */ + width: number; /** Index of this block in the flattened block list. */ blockIndex: number; /** Horizontal panning hooks for wide content (optional to use). */ @@ -66,6 +73,13 @@ export interface CodeBlockRendererProps { */ export type CodeBlockRenderer = (props: CodeBlockRendererProps) => ReactNode; +/** + * Columns `CodeBlockChrome` consumes around its children: left margin, right + * margin and both border edges. Subtracted (with the block indent) from the + * document content width to give a renderer its usable `width`. + */ +export const CODE_BLOCK_CHROME_WIDTH = 4; + /** * The standard bordered-box chrome used by built-in code and mermaid blocks. * Custom renderers can wrap their content in this to match the default look. @@ -263,6 +277,7 @@ export const CodeBlock = function CodeBlock({ theme, syntax, indent, + contentWidth, hScrollableBlocksRef, renderers, }: { @@ -271,6 +286,8 @@ export const CodeBlock = function CodeBlock({ theme: ResolvedTheme; syntax: SyntaxStyle; indent: number; + /** Columns the block list is laid out in, before chrome and indent. */ + contentWidth: number; hScrollableBlocksRef?: RefObject>; renderers?: Record; }): ReactNode { @@ -285,6 +302,7 @@ export const CodeBlock = function CodeBlock({ syntax, text: token.text, theme, + width: Math.max(1, contentWidth - CODE_BLOCK_CHROME_WIDTH - indent), }; const custom = rendererProps.lang === "" ? undefined : renderers?.[rendererProps.lang]; diff --git a/packages/renderers/src/index.ts b/packages/renderers/src/index.ts index fb3ad618..083da16e 100644 --- a/packages/renderers/src/index.ts +++ b/packages/renderers/src/index.ts @@ -8,7 +8,7 @@ export { splitMarkdownImages, } from "./markdown-images.js"; export type { MarkdownImageEmbed, MarkdownInlineSegment } from "./markdown-images.js"; -export { sourceLines, sourceLineAdapter } from "./source.js"; +export { sourceLines, sourceLineAdapter, SourceIndex } from "./source.js"; export type { DocumentRowAnchor, DocumentRowSource, diff --git a/packages/renderers/src/markdown-view.tsx b/packages/renderers/src/markdown-view.tsx index 2a9f6a17..b67c7123 100644 --- a/packages/renderers/src/markdown-view.tsx +++ b/packages/renderers/src/markdown-view.tsx @@ -1,5 +1,6 @@ import type { Token, Tokens } from "marked"; import { useEffect, useMemo, useState } from "react"; +import { useTerminalDimensions } from "@opentui/react"; import type { ReactNode, RefObject } from "react"; import { useTheme } from "@tooee/themes"; import type { ResolvedTheme } from "@tooee/themes"; @@ -18,7 +19,10 @@ import type { MouseEvent, } from "@opentui/core"; import type { DocumentBindings } from "./document-bindings.js"; -import { DEFAULT_SIGN_COLUMN_WIDTH } from "./row-document-renderable.js"; +import { + DEFAULT_SIGN_COLUMN_WIDTH, + computeRowDocumentGutterWidth, +} from "./row-document-renderable.js"; import { useGutterPalette } from "./use-gutter-palette.js"; import { CodeBlock, DEFAULT_CODE_BLOCK_RENDERERS } from "./code-blocks.js"; import type { CodeBlockRenderer } from "./code-blocks.js"; @@ -223,6 +227,9 @@ const linkMouseHandler = function linkMouseHandler( // Component // --------------------------------------------------------------------------- +/** Columns held back from the measured width for the scrollbar and right edge. */ +const MARKDOWN_SCROLLBAR_RESERVE = 2; + export const MarkdownView = function MarkdownView({ content, blocks: providedBlocks, @@ -235,11 +242,25 @@ export const MarkdownView = function MarkdownView({ }: MarkdownViewProps): ReactNode { const { theme, syntax } = useTheme(); const palette = useGutterPalette(); + const { width: terminalWidth } = useTerminalDimensions(); const blocks = useMemo( () => providedBlocks ?? flattenMarkdown(content), [providedBlocks, content], ); + // Blocks that must be told their width (diff fences) need the columns left + // after the row-document gutter and the scrollbar. + const contentWidth = Math.max( + 1, + terminalWidth - + computeRowDocumentGutterWidth({ + rowCount: blocks.length, + showLineNumbers, + signColumnWidth: DEFAULT_SIGN_COLUMN_WIDTH, + }) - + MARKDOWN_SCROLLBAR_RESERVE, + ); + // Merge user renderers over built-in defaults, normalizing keys to // lowercase so registration matches fence types case-insensitively. const mergedCodeBlockRenderers = useMemo(() => { @@ -261,6 +282,7 @@ export const MarkdownView = function MarkdownView({ blockIndex={index} theme={theme} syntax={syntax} + contentWidth={contentWidth} hScrollableBlocksRef={hScrollableBlocksRef} codeBlockRenderers={mergedCodeBlockRenderers} onLinkActivate={onLinkActivate} @@ -272,6 +294,7 @@ export const MarkdownView = function MarkdownView({ blocks, theme, syntax, + contentWidth, hScrollableBlocksRef, mergedCodeBlockRenderers, onLinkActivate, @@ -303,6 +326,7 @@ const FlatBlockRenderer = function FlatBlockRenderer({ blockIndex, theme, syntax, + contentWidth, hScrollableBlocksRef, codeBlockRenderers, onLinkActivate, @@ -312,6 +336,7 @@ const FlatBlockRenderer = function FlatBlockRenderer({ blockIndex: number; theme: ResolvedTheme; syntax: SyntaxStyle; + contentWidth: number; hScrollableBlocksRef?: RefObject>; codeBlockRenderers?: Record; onLinkActivate?: MarkdownLinkHandler; @@ -364,6 +389,7 @@ const FlatBlockRenderer = function FlatBlockRenderer({ theme={theme} syntax={syntax} indent={indent} + contentWidth={contentWidth} hScrollableBlocksRef={hScrollableBlocksRef} renderers={codeBlockRenderers} /> From 9494bec6f18ac77ab6e1d086e5f0a38277177781 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 11 Aug 2026 02:58:53 +0100 Subject: [PATCH 2/4] feat(diff): add @tooee/diff, Hunk-backed diff rendering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wraps Hunk's public OpenTUI primitives (`hunkdiff/opentui`, pinned exactly because Hunk is pre-1.0) behind one package so no other Tooee package imports it. `buildDiffModel` parses unified patch text into navigation rows — one per file header, one per `@@` hunk — each carrying its own patch text and its span in the original patch, recovered by scanning the same text since Hunk exposes no source positions. A hunk row hands Hunk a copy of its file narrowed to a single hunk while keeping the whole-file line arrays, so line numbers and collapsed-gap counts still resolve against the complete file. Files Hunk renders without hunks (binary, too large, untracked) get one body row. `DiffView` renders those rows into a `row-document`, which stays the only scroll owner, so the cursor, search decorations, marks, scroll-follow and mouse routing keep working over diff rows. `diffCodeBlockRenderer` draws ```diff and ```patch Markdown fences the same way, falling back to the default code block when the fence body is not a real unified diff. Known limits — per-hunk line-number column width, approximated themes, and Hunk's install weight — are documented in the package README. --- AGENTS.md | 1 + bun.lock | 188 ++++++++++++++- packages/diff/README.md | 73 ++++++ packages/diff/package.json | 58 +++++ packages/diff/src/detect.ts | 18 ++ packages/diff/src/diff-code-block.tsx | 102 ++++++++ packages/diff/src/diff-view.tsx | 166 +++++++++++++ packages/diff/src/index.ts | 19 ++ packages/diff/src/model.ts | 247 ++++++++++++++++++++ packages/diff/src/theme-map.ts | 88 +++++++ packages/diff/test/detect.test.ts | 22 ++ packages/diff/test/diff-code-block.test.tsx | 108 +++++++++ packages/diff/test/diff-view.test.tsx | 111 +++++++++ packages/diff/test/fixtures.ts | 53 +++++ packages/diff/test/model.test.ts | 99 ++++++++ packages/diff/test/theme-map.test.ts | 72 ++++++ packages/diff/test/tsconfig.json | 4 + packages/diff/tsconfig.json | 10 + scripts/tegami.mts | 1 + tsconfig.json | 1 + 20 files changed, 1440 insertions(+), 1 deletion(-) create mode 100644 packages/diff/README.md create mode 100644 packages/diff/package.json create mode 100644 packages/diff/src/detect.ts create mode 100644 packages/diff/src/diff-code-block.tsx create mode 100644 packages/diff/src/diff-view.tsx create mode 100644 packages/diff/src/index.ts create mode 100644 packages/diff/src/model.ts create mode 100644 packages/diff/src/theme-map.ts create mode 100644 packages/diff/test/detect.test.ts create mode 100644 packages/diff/test/diff-code-block.test.tsx create mode 100644 packages/diff/test/diff-view.test.tsx create mode 100644 packages/diff/test/fixtures.ts create mode 100644 packages/diff/test/model.test.ts create mode 100644 packages/diff/test/theme-map.test.ts create mode 100644 packages/diff/test/tsconfig.json create mode 100644 packages/diff/tsconfig.json diff --git a/AGENTS.md b/AGENTS.md index d36671b5..a306c138 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,6 +32,7 @@ User-facing changes should include a Tegami release note. See [docs/releasing.md - `@tooee/shell` is the composition layer — `TooeeProvider` wraps all providers, `launchCli()` creates renderers - Hotkey format: `ctrl+x`, sequences `g g`, leader keys `n` - **Raw `useKeyboard` policy**: app-level `useKeyboard` handlers MUST guard with `useHasOverlay()` (from `@tooee/overlays`) or be ported to `useCommand`. Raw handlers subscribe before the command dispatcher (child effects run first), so modal command surfaces cannot suspend them and `key.preventDefault()` does not protect them — an unguarded handler double-handles keys while an overlay is open. See the `@tooee/commands` README. +- **Diff rendering**: `@tooee/diff` is the only package allowed to import `hunkdiff` (pinned exactly, pre-1.0). It owns the `diff` content format, the `DiffView` row document, and the ` ```diff `/` ```patch ` Markdown fence renderer — see its README for the row model and known limits. - **Store conventions**: stateful interaction systems use `@xstate/store` event stores with thin React adapters — see [docs/store-conventions.md](docs/store-conventions.md) for when to use a store vs `useState` vs an effect, file layout, testing, and selector discipline. ## Documentation diff --git a/bun.lock b/bun.lock index 0065bda5..6f070d0e 100644 --- a/bun.lock +++ b/bun.lock @@ -160,6 +160,27 @@ "react": "^18.0.0 || ^19.0.0", }, }, + "packages/diff": { + "name": "@tooee/diff", + "version": "0.6.3", + "dependencies": { + "@tooee/renderers": "workspace:*", + "@tooee/themes": "workspace:*", + "hunkdiff": "0.18.0", + }, + "devDependencies": { + "@opentui/core": "^0.5.1", + "@opentui/react": "^0.5.1", + "@types/bun": "^1.3.10", + "@types/react": "^19.2.14", + "typescript": "^5.9.3", + }, + "peerDependencies": { + "@opentui/core": "^0.5.1", + "@opentui/react": "^0.5.1", + "react": "^18.0.0 || ^19.0.0", + }, + }, "packages/e2e": { "name": "@tooee/e2e", "version": "0.4.0", @@ -395,6 +416,7 @@ "dependencies": { "@tooee/commands": "workspace:*", "@tooee/config": "workspace:*", + "@tooee/diff": "workspace:*", "@tooee/layout": "workspace:*", "@tooee/marks": "workspace:*", "@tooee/overlays": "workspace:*", @@ -551,6 +573,38 @@ "@opentui/react": ["@opentui/react@0.5.1", "", { "dependencies": { "@opentui/core": "0.5.1", "react-reconciler": "^0.33.0" }, "peerDependencies": { "react": ">=19.2.0", "react-devtools-core": "^7.0.1", "ws": "^8.18.0" } }, "sha512-cGuVz8Pjpmq2GUKtZUSWWevX2Vjg3//sTulk8SgXPatIFlmZdBi3aoT3nmzn7ePbdI0IHWxQ8BLegsZiPAF2yg=="], + "@oven/bun-darwin-aarch64": ["@oven/bun-darwin-aarch64@1.3.14", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Omj20SuiHBOUjUBIyqtkNjSUIjOtEOJwmbix/ZyFH4BaQ6OZTaaRWIR4TjHVz0yadHgli6lLTiAh1uarnvD49A=="], + + "@oven/bun-darwin-x64": ["@oven/bun-darwin-x64@1.3.14", "", { "os": "darwin", "cpu": "x64" }, "sha512-FFj3QdU/OhlDyZOJ8CWfN5eWLpRlT4qjZg7lMQi7jA6GuoY5ajlO1zWLP/MuHYRSbXQUvV52RejNi8DVnAp13w=="], + + "@oven/bun-darwin-x64-baseline": ["@oven/bun-darwin-x64-baseline@1.3.14", "", { "os": "darwin", "cpu": "x64" }, "sha512-OSfsTZstc898HHElhU4NccaBGOSSDn5VfahiVTnidZ9B/+wb7WTyfZJaBeJcfjwJ9H2W9uTh2TGtl3UfcXgV9g=="], + + "@oven/bun-freebsd-aarch64": ["@oven/bun-freebsd-aarch64@1.3.14", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-LIKrXaFxAHybVO5Pf+9XP2FHUj/5APvXTUKk9dqHm5iFz4oH+W24cmhjkJirNujh9hKeTyrpWSe3no9JZKowIw=="], + + "@oven/bun-freebsd-x64": ["@oven/bun-freebsd-x64@1.3.14", "", { "os": "freebsd", "cpu": "x64" }, "sha512-uwD+fGUH1ADpIF3B1U2jWzzb20QwRLZfj5QZ28GUCGrAJ/nTmWrD6YYGsblCY1wuhldRez3lU40AyuvSCyLYmw=="], + + "@oven/bun-linux-aarch64": ["@oven/bun-linux-aarch64@1.3.14", "", { "os": "linux", "cpu": "arm64" }, "sha512-X5SsPZHs+iYO8R/efIcRtc7gT2Q2DgPfliCxEkx4cXBumwkw0c/EsHMNwH3EgGpCDaZ7IYVPhpCG/xBOQHEwZw=="], + + "@oven/bun-linux-aarch64-android": ["@oven/bun-linux-aarch64-android@1.3.14", "", { "os": "android", "cpu": "arm64" }, "sha512-y4kq5b85lsrmFb9Xvi4w9mA5IEFJkLMrSmYn06q24KjL9rUWDWO3VFZEtteZxUN5+ec3Zm5S8OnJw1umaCbVjA=="], + + "@oven/bun-linux-aarch64-musl": ["@oven/bun-linux-aarch64-musl@1.3.14", "", { "os": "linux", "cpu": "arm64" }, "sha512-jmqOA92Cd1NL/1XBd4bFkJLxQ86K0RW7ohxS2qzzAvuitO4JiIxjjTeCspoU44zCozH72HpfZfUE2On31OjnWA=="], + + "@oven/bun-linux-x64": ["@oven/bun-linux-x64@1.3.14", "", { "os": "linux", "cpu": "x64" }, "sha512-7OVTAKvwfPmSbIV1HpdOoVVx5VRc427GuPPne93N6vk4eQBPId9nXmZDh9/zGaKPdbVjVtQSZafWQoUjx38Utw=="], + + "@oven/bun-linux-x64-android": ["@oven/bun-linux-x64-android@1.3.14", "", { "os": "android", "cpu": "x64" }, "sha512-qe9e1d+3VAEU7nAA2ol9Jvmy/o99PVMSgZhHn7Q/9O3YcDrfEqyQ8zm4zoe5qTEo8HZH0dN03Le0Ys2eQPs7eg=="], + + "@oven/bun-linux-x64-baseline": ["@oven/bun-linux-x64-baseline@1.3.14", "", { "os": "linux", "cpu": "x64" }, "sha512-q/8EdOC0yUE8FPeoOVq8/Pw5I9/tJaYmUfO/uDUAREx8IUnOJH1RJ5A3BjFqre8pvJoiZA9AovPJq5FnNNjSxA=="], + + "@oven/bun-linux-x64-musl": ["@oven/bun-linux-x64-musl@1.3.14", "", { "os": "linux", "cpu": "x64" }, "sha512-GBCB/k/sIqcr06eTNgg7g46qiUv35Jasx4XiccJ/n7RGqrE4RWUD/XJBbWFprVPjvqd59+QtSnS99XGqvftHfg=="], + + "@oven/bun-linux-x64-musl-baseline": ["@oven/bun-linux-x64-musl-baseline@1.3.14", "", { "os": "linux", "cpu": "x64" }, "sha512-n6iE71G4lQE4XkrZhQQcL5YUlxDbnq6nqV7zeQi33PMsLT/0kYE+RvHOtBWZ3w0wMdXZfINmp63hIb9ijUBGtw=="], + + "@oven/bun-windows-aarch64": ["@oven/bun-windows-aarch64@1.3.14", "", { "os": "win32", "cpu": "arm64" }, "sha512-T7s3x/BsVKQObGU6QDkZeI6wKynzqGbBH1yI77jrrj5siElclxr3DQrDIk8CV4G5/SJq2HHq4kpLyYY2DKCSmA=="], + + "@oven/bun-windows-x64": ["@oven/bun-windows-x64@1.3.14", "", { "os": "win32", "cpu": "x64" }, "sha512-mUFWL3BoYkNpjd8e9PqROiFF/1Xeotq20mABJsiQH62jM1g5zqWh4khw1RZ6bX8Q8fWvlPaxG1PjofkmjUi3vg=="], + + "@oven/bun-windows-x64-baseline": ["@oven/bun-windows-x64-baseline@1.3.14", "", { "os": "win32", "cpu": "x64" }, "sha512-uIjLUC1S9DWgICzuoMba7vurBJnBruE4S5CxnvmZkdqWVXRzx1Rgu636HoH+k0qeaQCFh3jeG3JQ1y6fRHv0sw=="], + "@oxc-parser/binding-android-arm-eabi": ["@oxc-parser/binding-android-arm-eabi@0.132.0", "", { "os": "android", "cpu": "arm" }, "sha512-KrLaPWa5c9Y7LkW+rKkaUE3y7DBDrQtaf7rlsSDfv6KAHUjgzAIRA761Lrrp6//Yd/Rlie/yEOt9YENCoJnOcw=="], "@oxc-parser/binding-android-arm64": ["@oxc-parser/binding-android-arm64@0.132.0", "", { "os": "android", "cpu": "arm64" }, "sha512-SThDrSeamB/kG2+NxcJ5/wSLcV6dUqDknrPLqFYQ0ST/55mtBP4M7Q/f3QbubH6aAd11wpzZn/nwbVRSdobOpg=="], @@ -719,6 +773,10 @@ "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.73.0", "", { "os": "win32", "cpu": "x64" }, "sha512-WaNRvh4f6zY9CvUQk2YoA1O90ieWrIklI84+HXFr9Isjz9CSESrdqo/RtIYt4Dll/cAchqGDMehfaZd0vqEFZw=="], + "@pierre/diffs": ["@pierre/diffs@1.2.2", "", { "dependencies": { "@pierre/theme": "1.0.3", "@shikijs/transformers": "^3.0.0", "diff": "8.0.3", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "^3.0.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-MvWLv2oSOJOF8oYXWLdhicguHM11G/VNWu6OPR5ZETolp2NM2/KPQG3cZTnKpJ6ImqEHwvw6Gl6z2gmmy2FQmQ=="], + + "@pierre/theme": ["@pierre/theme@1.0.3", "", {}, "sha512-sWHv11TMoqKxKDgTIk5VbhQjdPhs8DCcBxbjh3mRlS3YOM/OcrWoGX6MM8eBGn9cUu3M46Py0JnxsG2nJaFTuA=="], + "@rainbowatcher/toml-edit-js": ["@rainbowatcher/toml-edit-js@0.6.5", "", {}, "sha512-EdALJyTDcp2yOxeVw1OJevOF7NZrlWAbVEnOnQBV7YqycVCI0OcpDy6oLUa8vqSvv2F1RmXW7kz1kgYv2A5h/w=="], "@resvg/resvg-wasm": ["@resvg/resvg-wasm@2.6.2", "", {}, "sha512-FqALmHI8D4o6lk/LRWDnhw95z5eO+eAa6ORjVg09YRR7BkcM6oPHU9uyC0gtQG5vpFLvgpeU4+zEAz2H8APHNw=="], @@ -735,6 +793,22 @@ "@sentry/server-utils": ["@sentry/server-utils@10.65.0", "", { "dependencies": { "@apm-js-collab/code-transformer": "^0.15.0", "@apm-js-collab/code-transformer-bundler-plugins": "^0.5.0", "@apm-js-collab/tracing-hooks": "^0.10.1", "@sentry/conventions": "^0.15.1", "@sentry/core": "10.65.0", "magic-string": "~0.30.0" } }, "sha512-80toEFD6s+0Le7jrYB6pHWLF703WSg0WyavAWqrBGWG8JkREHgedAxzFYgoY5GlMI756qk6Ea7UzhJTHd2zAXA=="], + "@shikijs/core": ["@shikijs/core@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA=="], + + "@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.4" } }, "sha512-aHt9eiGFobmWR5uqJUViySI1bHMqrAgamWE1TYSUoftkAeCCAiGawPMwM+VCadylQtF4V3VNOZ5LmfItH5f3yA=="], + + "@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g=="], + + "@shikijs/langs": ["@shikijs/langs@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0" } }, "sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg=="], + + "@shikijs/themes": ["@shikijs/themes@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0" } }, "sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA=="], + + "@shikijs/transformers": ["@shikijs/transformers@3.23.0", "", { "dependencies": { "@shikijs/core": "3.23.0", "@shikijs/types": "3.23.0" } }, "sha512-F9msZVxdF+krQNSdQ4V+Ja5QemeAoTQ2jxt7nJCwhDsdF1JWS3KxIQXA3lQbyKwS3J61oHRUSv4jYWv3CkaKTQ=="], + + "@shikijs/types": ["@shikijs/types@3.23.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ=="], + + "@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="], + "@tooee/ask": ["@tooee/ask@workspace:packages/ask"], "@tooee/choose": ["@tooee/choose@workspace:packages/choose"], @@ -747,6 +821,8 @@ "@tooee/config": ["@tooee/config@workspace:packages/config"], + "@tooee/diff": ["@tooee/diff@workspace:packages/diff"], + "@tooee/e2e": ["@tooee/e2e@workspace:packages/e2e"], "@tooee/examples": ["@tooee/examples@workspace:examples"], @@ -783,12 +859,18 @@ "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], + "@types/hast": ["@types/hast@3.0.5", "", { "dependencies": { "@types/unist": "*" } }, "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g=="], + "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], + "@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="], + "@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="], "@types/react": ["@types/react@19.2.17", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw=="], + "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], + "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.63.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.63.0", "@typescript-eslint/types": "^8.63.0", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-e5dh0/UI0ok53AlZ5wRkXCB32z/f2jUZqPR/ygAw5WYaSw8j9EoJWlS7wQjr/dmOaqWjnPIn2m+HhVPCMWGZVQ=="], "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.63.0", "", { "dependencies": { "@typescript-eslint/types": "8.63.0", "@typescript-eslint/visitor-keys": "8.63.0" } }, "sha512-uUyfMWCnDSN8bCpcrY8nGP2BLkQ9Xn0GsipcONcpIDWhwhO4ZSyHvyS14U3X75mzxWxL3I2UZIrenTzdzcJO8A=="], @@ -803,6 +885,8 @@ "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.63.0", "", { "dependencies": { "@typescript-eslint/types": "8.63.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-UexrHGnGTpbuQHct2ExOc2ZcFbGUS9FOesCxxqdBGcpI1BxYu/LZ6U8Aq6/72XtF/qRBk9nhuGHFJIXXMhPMdw=="], + "@ungap/structured-clone": ["@ungap/structured-clone@1.3.3", "", {}, "sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg=="], + "@xstate/store": ["@xstate/store@4.2.1", "", {}, "sha512-dX7g9bYFOK/YvyFJVCsdHk3o/g1eC+eJcX8mHPWmitAXH6O58xSk1V91lUj67/cn27c2+uMKl98N7nRz+IyHoA=="], "@xstate/store-react": ["@xstate/store-react@2.0.0", "", { "dependencies": { "@xstate/store": "^4.0.0" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0" } }, "sha512-i8rXkUHfZqHUijgA/IORXDESAuHwO939jHIsEtf3nitJkOy1AcZ73DMaAlLqH03nVS082PJkUZFRAu5nwb9Fxg=="], @@ -835,18 +919,30 @@ "browserslist": ["browserslist@4.28.6", "", { "dependencies": { "baseline-browser-mapping": "^2.10.42", "caniuse-lite": "^1.0.30001803", "electron-to-chromium": "^1.5.389", "node-releases": "^2.0.51", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-FQBYNK15VMslhLHpA7+n+n1GOlF1kId2xcCg7/j95f24AOF6VDYMNH4mFxF7KuaTdv627faazpOAjFzMrfJOUw=="], + "bun": ["bun@1.3.14", "", { "optionalDependencies": { "@oven/bun-darwin-aarch64": "1.3.14", "@oven/bun-darwin-x64": "1.3.14", "@oven/bun-darwin-x64-baseline": "1.3.14", "@oven/bun-freebsd-aarch64": "1.3.14", "@oven/bun-freebsd-x64": "1.3.14", "@oven/bun-linux-aarch64": "1.3.14", "@oven/bun-linux-aarch64-android": "1.3.14", "@oven/bun-linux-aarch64-musl": "1.3.14", "@oven/bun-linux-x64": "1.3.14", "@oven/bun-linux-x64-android": "1.3.14", "@oven/bun-linux-x64-baseline": "1.3.14", "@oven/bun-linux-x64-musl": "1.3.14", "@oven/bun-linux-x64-musl-baseline": "1.3.14", "@oven/bun-windows-aarch64": "1.3.14", "@oven/bun-windows-x64": "1.3.14", "@oven/bun-windows-x64-baseline": "1.3.14" }, "os": [ "!aix", "!sunos", "!openbsd", ], "cpu": [ "x64", "arm64", ], "bin": { "bun": "bin/bun.exe", "bunx": "bin/bunx.exe" } }, "sha512-aB6GVd42x1Y5ie1K16SF+oLGtgSkwX9hgoDdIW88pjvfTccU8F1vfpoOt34QLv0dZ1v3XimtaxPlZUG81Gx9Zg=="], + "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=="], "caniuse-lite": ["caniuse-lite@1.0.30001805", "", {}, "sha512-52noaS3DubycKSXaU30TwPGIp+POyQSUVa5jBEq3vkRkY0kjyb3LQgvhU6WGyCcyXqVLWO0Cw0Q6BSdD0kUfVA=="], + "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], + + "character-entities-html4": ["character-entities-html4@2.1.0", "", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="], + + "character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="], + + "chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], + "citty": ["citty@0.2.2", "", {}, "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w=="], "cjs-module-lexer": ["cjs-module-lexer@2.2.0", "", {}, "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ=="], "clone": ["clone@1.0.4", "", {}, "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg=="], + "comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="], + "commander": ["commander@15.0.0", "", {}, "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg=="], "conf": ["conf@15.1.0", "", { "dependencies": { "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "atomically": "^2.0.3", "debounce-fn": "^6.0.0", "dot-prop": "^10.0.0", "env-paths": "^3.0.0", "json-schema-typed": "^8.0.1", "semver": "^7.7.2", "uint8array-extras": "^1.5.0" } }, "sha512-Uy5YN9KEu0WWDaZAVJ5FAmZoaJt9rdK6kH+utItPyGsCqCgaTKkrmZx3zoE0/3q6S3bcp3Ihkk+ZqPxWxFK5og=="], @@ -869,8 +965,12 @@ "defaults": ["defaults@1.0.4", "", { "dependencies": { "clone": "^1.0.2" } }, "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A=="], + "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], + "deslop-js": ["deslop-js@0.7.4", "", { "dependencies": { "@oxc-project/types": "^0.132.0", "fast-glob": "^3.3.3", "minimatch": "^10.2.5", "oxc-parser": "^0.132.0", "oxc-resolver": "^11.19.1", "typescript": ">=5.0.4 <6" } }, "sha512-OKhLEBDFk3wYgfSUz65O/1SP2L/jcMOYym5EB9wvEuDUrJrg+32X3tnUHHvlB/2sSGDU6wCSMqy1006EToOptA=="], + "devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="], + "diff": ["diff@9.0.0", "", {}, "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw=="], "dot-prop": ["dot-prop@10.1.0", "", { "dependencies": { "type-fest": "^5.0.0" } }, "sha512-MVUtAugQMOff5RnBy2d9N31iG0lNwg1qAoAOn7pOK5wf94WIaE3My2p3uwTQuvS2AcqchkcR3bHByjaM0mmi7Q=="], @@ -959,12 +1059,30 @@ "goke": ["goke@6.12.3", "", {}, "sha512-zB5CsmtGFY0a9VjUmGBhQZOf5C/TGQoAKV2gai7mrj1RIOhfEzAjFF60yRI2M6d6OSWtn03JcV2oBe0x9nIQFw=="], + "hast-util-to-html": ["hast-util-to-html@9.0.5", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-whitespace": "^3.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "stringify-entities": "^4.0.0", "zwitch": "^2.0.4" } }, "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw=="], + + "hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="], + "hermes-estree": ["hermes-estree@0.25.1", "", {}, "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw=="], "hermes-parser": ["hermes-parser@0.25.1", "", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="], "hono": ["hono@4.12.25", "", {}, "sha512-2NFaIyNVgJmBs/ecmtGzlmluTFs5cHEWGTdu0t1HBwYzoGXOL5nUQBRMXsXWla5i4KkG//QMzVP88m1+I3fdAQ=="], + "html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="], + + "hunkdiff": ["hunkdiff@0.18.0", "", { "dependencies": { "@pierre/diffs": "1.2.2", "bun": "^1.3.14", "chokidar": "^4.0.3", "commander": "^14.0.3", "diff": "^8.0.3", "get-east-asian-width": "^1.5.0", "shell-quote": "1.9.0", "string-width": "^8.2.1", "zod": "^4.3.6" }, "optionalDependencies": { "hunkdiff-darwin-arm64": "0.18.0", "hunkdiff-darwin-x64": "0.18.0", "hunkdiff-linux-arm64": "0.18.0", "hunkdiff-linux-x64": "0.18.0", "hunkdiff-windows-x64": "0.18.0" }, "peerDependencies": { "@opentui/core": "^0.4.3", "@opentui/react": "^0.4.3", "react": "^19.2.4" }, "bin": { "hunk": "bin/hunk.cjs", "hunkdiff": "bin/hunk.cjs" } }, "sha512-uY0wlcRQL8kNdKdOcxcXp03CJsmcCdGCk97uYFmDGMw+ObM5Ep2dLHAuqaF83e0rRDEdle1NZXx+tFmEegyj1Q=="], + + "hunkdiff-darwin-arm64": ["hunkdiff-darwin-arm64@0.18.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-W++rBVrnv198FgrEbsf7j13xuQ1Em9LvviwO3o0HmDfT0D6mLXDOsbAnMB06+AU3SRMY607AnF66EXDas9tplQ=="], + + "hunkdiff-darwin-x64": ["hunkdiff-darwin-x64@0.18.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-q2Yglv7zzCHGMqFtOAzUPMdR7e0y58zHhH/8kNBNNFI5A6C58GVOLRLC2y13fuBCug9UZLA1RgnBQvBudL3b/A=="], + + "hunkdiff-linux-arm64": ["hunkdiff-linux-arm64@0.18.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-RhFNNUPjknOqd9qLUraRAiQXI4X1qMCU5wa9cL2AZUEJL1ciuadaVZ+YylYrATYkkNLrXhiIxbTHPWZ8/4nAgg=="], + + "hunkdiff-linux-x64": ["hunkdiff-linux-x64@0.18.0", "", { "os": "linux", "cpu": "x64" }, "sha512-jaUQZ9riXa+36oa3MkaJTFAoyL/JqL2OUXST/2XNsfgz3oFRRJA8WztSnPcJnwrxTYm40z0oFPOAfXT3Q/8Chw=="], + + "hunkdiff-windows-x64": ["hunkdiff-windows-x64@0.18.0", "", { "os": "win32", "cpu": "x64" }, "sha512-I4c4dGgoX9xd6VwhGqYRE4uqiYfxzL47nhOSwYr9V5+SAV2gylisjlcHYzaaZrWQ+yPgzFXciWseKis74EFv8Q=="], + "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], "import-in-the-middle": ["import-in-the-middle@3.3.1", "", { "dependencies": { "cjs-module-lexer": "^2.2.0", "es-module-lexer": "^2.2.0", "module-details-from-path": "^1.0.4" } }, "sha512-0rymlHSFLwZ0ixx8DaQkoIyZojJPY2a0K2nEYslhKJ6jIYO/m0IcCb7iQsFPmS7WmKwISZiIrv5Icstrw/CmqA=="], @@ -1033,16 +1151,30 @@ "lru-cache": ["lru-cache@11.5.2", "", {}, "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g=="], + "lru_map": ["lru_map@0.4.1", "", {}, "sha512-I+lBvqMMFfqaV8CJCISjI3wbjmwVu/VyOoU7+qtu9d7ioW5klMgsTTiUOUp+DJvfTTzKXoPbyC6YfgkNcyPSOg=="], + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], "magicast": ["magicast@0.5.3", "", { "dependencies": { "@babel/parser": "^7.29.3", "@babel/types": "^7.29.0", "source-map-js": "^1.2.1" } }, "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw=="], "marked": ["marked@17.0.6", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-gB0gkNafnonOw0obSTEGZTT86IuhILt2Wfx0mWH/1Au83kybTayroZ/V6nS25mN7u8ASy+5fMhgB3XPNrOZdmA=="], + "mdast-util-to-hast": ["mdast-util-to-hast@13.2.1", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@ungap/structured-clone": "^1.0.0", "devlop": "^1.0.0", "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA=="], + "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], "meriyah": ["meriyah@6.1.4", "", {}, "sha512-Sz8FzjzI0kN13GK/6MVEsVzMZEPvOhnmmI1lU5+/1cGOiK3QUahntrNNtdVeihrO7t9JpoH75iMNXg6R6uWflQ=="], + "micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="], + + "micromark-util-encode": ["micromark-util-encode@2.0.1", "", {}, "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="], + + "micromark-util-sanitize-uri": ["micromark-util-sanitize-uri@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ=="], + + "micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="], + + "micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="], + "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], "mimic-function": ["mimic-function@5.0.1", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="], @@ -1067,6 +1199,10 @@ "nypm": ["nypm@0.6.8", "", { "dependencies": { "citty": "^0.2.2", "pathe": "^2.0.3", "tinyexec": "^1.2.4" }, "bin": { "nypm": "./dist/cli.mjs" } }, "sha512-Q9K4Diu6l5u6xJQogeFSs/zKtyMSgFKFtRQV+tHP4kL7KPm2grpBU0dFIwFaXwNxN0MtfKWc43VpCugAa+LPsw=="], + "oniguruma-parser": ["oniguruma-parser@0.12.2", "", {}, "sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw=="], + + "oniguruma-to-es": ["oniguruma-to-es@4.3.6", "", { "dependencies": { "oniguruma-parser": "^0.12.2", "regex": "^6.1.0", "regex-recursion": "^6.0.2" } }, "sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA=="], + "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], "oxc-parser": ["oxc-parser@0.132.0", "", { "dependencies": { "@oxc-project/types": "^0.132.0" }, "optionalDependencies": { "@oxc-parser/binding-android-arm-eabi": "0.132.0", "@oxc-parser/binding-android-arm64": "0.132.0", "@oxc-parser/binding-darwin-arm64": "0.132.0", "@oxc-parser/binding-darwin-x64": "0.132.0", "@oxc-parser/binding-freebsd-x64": "0.132.0", "@oxc-parser/binding-linux-arm-gnueabihf": "0.132.0", "@oxc-parser/binding-linux-arm-musleabihf": "0.132.0", "@oxc-parser/binding-linux-arm64-gnu": "0.132.0", "@oxc-parser/binding-linux-arm64-musl": "0.132.0", "@oxc-parser/binding-linux-ppc64-gnu": "0.132.0", "@oxc-parser/binding-linux-riscv64-gnu": "0.132.0", "@oxc-parser/binding-linux-riscv64-musl": "0.132.0", "@oxc-parser/binding-linux-s390x-gnu": "0.132.0", "@oxc-parser/binding-linux-x64-gnu": "0.132.0", "@oxc-parser/binding-linux-x64-musl": "0.132.0", "@oxc-parser/binding-openharmony-arm64": "0.132.0", "@oxc-parser/binding-wasm32-wasi": "0.132.0", "@oxc-parser/binding-win32-arm64-msvc": "0.132.0", "@oxc-parser/binding-win32-ia32-msvc": "0.132.0", "@oxc-parser/binding-win32-x64-msvc": "0.132.0" } }, "sha512-+0LAPHaqtfQlvWdpaAa09SmOaZZgP8C552xosEkGJ4+ruEwP1Vgx+sqBgcBCNfR6KDCmagGOZTde8wmAvcI/Hg=="], @@ -1105,6 +1241,8 @@ "prompts": ["prompts@2.4.2", "", { "dependencies": { "kleur": "^3.0.3", "sisteransi": "^1.0.5" } }, "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q=="], + "property-information": ["property-information@7.2.0", "", {}, "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg=="], + "pseudomap": ["pseudomap@1.0.2", "", {}, "sha512-b/YwNhb8lk1Zz2+bXXpS/LK9OisiZZ1SNsSLxN1x2OXVEhW2Ckr/7mWE5vrC1ZTiJlD9g19jWszTmJsB+oEpFQ=="], "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], @@ -1117,8 +1255,18 @@ "react-doctor": ["react-doctor@0.7.4", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@sentry/node": "^10.54.0", "agent-install": "0.0.5", "conf": "^15.1.0", "confbox": "^0.2.4", "deslop-js": "0.7.4", "eslint-plugin-react-hooks": "^7.1.1", "jiti": "^2.7.0", "magicast": "^0.5.3", "oxlint": ">=1.66.0 <1.67.0", "oxlint-plugin-react-doctor": "0.7.4", "prompts": "^2.4.2", "typescript": ">=5.0.4 <6", "vscode-languageserver": "^9.0.1", "vscode-languageserver-textdocument": "^1.0.12", "vscode-uri": "^3.1.0", "yaml": "^2.9.0" }, "bin": { "react-doctor": "bin/react-doctor.js" } }, "sha512-OcNqh3joJ6ihycni2d/IgZq/aJBgn5XXsztwRpt9bvb195jM76PyYgK5M2LjWnzIScvPFZu3GzQjHqzoKmjLaA=="], + "react-dom": ["react-dom@19.2.8", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.8" } }, "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ=="], + "react-reconciler": ["react-reconciler@0.33.0", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.0" } }, "sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA=="], + "readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], + + "regex": ["regex@6.1.0", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg=="], + + "regex-recursion": ["regex-recursion@6.0.2", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg=="], + + "regex-utilities": ["regex-utilities@2.3.0", "", {}, "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng=="], + "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], "require-in-the-middle": ["require-in-the-middle@8.0.1", "", { "dependencies": { "debug": "^4.3.5", "module-details-from-path": "^1.0.3" } }, "sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ=="], @@ -1137,7 +1285,9 @@ "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], - "shell-quote": ["shell-quote@1.8.4", "", {}, "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ=="], + "shell-quote": ["shell-quote@1.9.0", "", {}, "sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA=="], + + "shiki": ["shiki@3.23.0", "", { "dependencies": { "@shikijs/core": "3.23.0", "@shikijs/engine-javascript": "3.23.0", "@shikijs/engine-oniguruma": "3.23.0", "@shikijs/langs": "3.23.0", "@shikijs/themes": "3.23.0", "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA=="], "signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], @@ -1147,10 +1297,14 @@ "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + "space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="], + "string-dedent": ["string-dedent@3.0.2", "", {}, "sha512-M4q+HpHCtGXlbyzYDOcOo7V185dlq6YXvGUPcWZqL4vttCX9gFYoWIOxcPd7v5CAYcTJsGLs3ZJCAH2TXONF/g=="], "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=="], + "stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="], + "strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="], "strip-eof": ["strip-eof@1.0.0", "", {}, "sha512-7FCwGGmx8mD5xQd3RPUvnSpUXHM3BWuzjtpD4TXsfcZ9EL4azvVVUscFYwD9nx8Kh+uCBC00XBtAykoMHwTh8Q=="], @@ -1171,6 +1325,8 @@ "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], + "trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="], + "ts-api-utils": ["ts-api-utils@2.5.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="], "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], @@ -1189,10 +1345,24 @@ "undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], + "unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="], + + "unist-util-position": ["unist-util-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA=="], + + "unist-util-stringify-position": ["unist-util-stringify-position@4.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="], + + "unist-util-visit": ["unist-util-visit@5.1.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg=="], + + "unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="], + "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], + "vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="], + + "vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="], + "vscode-jsonrpc": ["vscode-jsonrpc@8.2.0", "", {}, "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA=="], "vscode-languageserver": ["vscode-languageserver@9.0.1", "", { "dependencies": { "vscode-languageserver-protocol": "3.17.5" }, "bin": { "installServerIntoExtension": "bin/installServerIntoExtension" } }, "sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g=="], @@ -1227,6 +1397,8 @@ "zod-validation-error": ["zod-validation-error@4.0.2", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ=="], + "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], + "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], @@ -1241,6 +1413,8 @@ "@oxc-resolver/binding-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw=="], + "@pierre/diffs/diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="], + "agent-install/commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], "eslint/ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="], @@ -1253,12 +1427,22 @@ "ghostty-opentui/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], + "hunkdiff/commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], + + "hunkdiff/diff": ["diff@8.0.4", "", {}, "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw=="], + + "hunkdiff/string-width": ["string-width@8.2.2", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg=="], + + "hunkdiff/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], + "micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], "npm-run-path/path-key": ["path-key@2.0.1", "", {}, "sha512-fEHGKCSmUSDPv4uoj8AlD+joPlq3peND+HRYyxFz4KPw4z926S/b8rIuFs2FYJg3BwsxJf6A9/3eIdLaYC+9Dw=="], "oxlint-plugin-react-doctor/oxc-parser": ["oxc-parser@0.135.0", "", { "dependencies": { "@oxc-project/types": "^0.135.0" }, "optionalDependencies": { "@oxc-parser/binding-android-arm-eabi": "0.135.0", "@oxc-parser/binding-android-arm64": "0.135.0", "@oxc-parser/binding-darwin-arm64": "0.135.0", "@oxc-parser/binding-darwin-x64": "0.135.0", "@oxc-parser/binding-freebsd-x64": "0.135.0", "@oxc-parser/binding-linux-arm-gnueabihf": "0.135.0", "@oxc-parser/binding-linux-arm-musleabihf": "0.135.0", "@oxc-parser/binding-linux-arm64-gnu": "0.135.0", "@oxc-parser/binding-linux-arm64-musl": "0.135.0", "@oxc-parser/binding-linux-ppc64-gnu": "0.135.0", "@oxc-parser/binding-linux-riscv64-gnu": "0.135.0", "@oxc-parser/binding-linux-riscv64-musl": "0.135.0", "@oxc-parser/binding-linux-s390x-gnu": "0.135.0", "@oxc-parser/binding-linux-x64-gnu": "0.135.0", "@oxc-parser/binding-linux-x64-musl": "0.135.0", "@oxc-parser/binding-openharmony-arm64": "0.135.0", "@oxc-parser/binding-wasm32-wasi": "0.135.0", "@oxc-parser/binding-win32-arm64-msvc": "0.135.0", "@oxc-parser/binding-win32-ia32-msvc": "0.135.0", "@oxc-parser/binding-win32-x64-msvc": "0.135.0" } }, "sha512-/DaPStu0s2zzNSRRniKyTPM6Z/o+DapOp2JYNKDL8AsgaBGPK2IdZyB87SQjVH+xeQPz+Qr9mrjglfkYgtbVRA=="], + "react-devtools-core/shell-quote": ["shell-quote@1.8.4", "", {}, "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ=="], + "react-devtools-core/ws": ["ws@7.5.11", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-zS54Oen9bITtp7kp2XM3AydrCIq1D+HwJOuH+c+e4LfpL/lotP5osijd+UoMnxwAam1GN8R4KtLAyIrIcBNpiA=="], "react-doctor/oxlint": ["oxlint@1.66.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.66.0", "@oxlint/binding-android-arm64": "1.66.0", "@oxlint/binding-darwin-arm64": "1.66.0", "@oxlint/binding-darwin-x64": "1.66.0", "@oxlint/binding-freebsd-x64": "1.66.0", "@oxlint/binding-linux-arm-gnueabihf": "1.66.0", "@oxlint/binding-linux-arm-musleabihf": "1.66.0", "@oxlint/binding-linux-arm64-gnu": "1.66.0", "@oxlint/binding-linux-arm64-musl": "1.66.0", "@oxlint/binding-linux-ppc64-gnu": "1.66.0", "@oxlint/binding-linux-riscv64-gnu": "1.66.0", "@oxlint/binding-linux-riscv64-musl": "1.66.0", "@oxlint/binding-linux-s390x-gnu": "1.66.0", "@oxlint/binding-linux-x64-gnu": "1.66.0", "@oxlint/binding-linux-x64-musl": "1.66.0", "@oxlint/binding-openharmony-arm64": "1.66.0", "@oxlint/binding-win32-arm64-msvc": "1.66.0", "@oxlint/binding-win32-ia32-msvc": "1.66.0", "@oxlint/binding-win32-x64-msvc": "1.66.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.22.1" }, "optionalPeers": ["oxlint-tsgolint"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-N4LLxYLd94KEBqXDMDM5f+2PUpItTjDLreXe2Gn5KhjhCK4Qp2YUXaBi8Yu325ryOgKwt22m45fpD7nPOn69Yw=="], @@ -1277,6 +1461,8 @@ "execa/cross-spawn/which": ["which@1.3.1", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "which": "./bin/which" } }, "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ=="], + "hunkdiff/string-width/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], + "oxlint-plugin-react-doctor/oxc-parser/@oxc-parser/binding-android-arm-eabi": ["@oxc-parser/binding-android-arm-eabi@0.135.0", "", { "os": "android", "cpu": "arm" }, "sha512-sHeZItACNcA5WRAWqF6ixriR4GkZDyY10gVgnZU7pXku1DjHFATSqnwZM809jl0gXPHxb6fKzYQCK7bNK5cACQ=="], "oxlint-plugin-react-doctor/oxc-parser/@oxc-parser/binding-android-arm64": ["@oxc-parser/binding-android-arm64@0.135.0", "", { "os": "android", "cpu": "arm64" }, "sha512-wPte+SzgzWWFgMSF8YZDNM+tBXtJg0AXBi7+tU3yS2z1f2Af9kRLZLKuJojADmuD/cZexmnMHHC3SDItTW77Iw=="], diff --git a/packages/diff/README.md b/packages/diff/README.md new file mode 100644 index 00000000..1aa01ddf --- /dev/null +++ b/packages/diff/README.md @@ -0,0 +1,73 @@ +# @tooee/diff + +Unified and split diff rendering for Tooee, drawn by [Hunk](https://github.com/modem-dev/hunk)'s +public OpenTUI primitives (`hunkdiff/opentui`). + +Part of the [Tooee](https://github.com/gingerhendrix/tooee) monorepo. See the main repo for +documentation. + +## What it provides + +- `buildDiffModel(patch)` — parses unified patch text into navigation rows: one row per file + header and one per `@@` hunk, each carrying its own patch text and its span in the original + patch. +- `DiffView` — a `row-document` whose rows are those diff rows. The row document stays the only + scroll owner, so the cursor, search decorations, marks, scroll-follow and mouse routing all keep + working. +- `diffCodeBlockRenderer` / `DIFF_CODE_BLOCK_RENDERERS` — a Markdown code-block renderer that + draws ` ```diff ` and ` ```patch ` fences as Hunk blocks. `@tooee/view` registers it by default. +- `resolveHunkDiffTheme` — maps a Tooee theme onto the closest bundled Hunk theme. +- `isDiffPatch` — content sniffing for patch text. + +`@tooee/diff` is the only package that imports `hunkdiff`. `@tooee/renderers` stays free of it. + +## Row model + +Hunk renders a whole file at a time, but a diff is only pleasant to navigate hunk by hunk. A hunk +row therefore carries a copy of its file whose `metadata.hunks` is narrowed to a single hunk while +the whole-file line arrays stay intact — so line numbers and the `··· N unchanged lines ···` +counts still resolve against the complete file. + +Files Hunk renders without hunks (binary, too large, untracked) contribute one `body` row instead, +so the notice Hunk draws for them is still shown. + +## Fence options + +Words after the fence type are read as options; unknown words are ignored. + + ```diff split nolines wrap + +| Word | Effect | +| --------- | ------------------------------------------------------------ | +| `split` | Side-by-side layout (falls back to stacked below 80 columns) | +| `nolines` | Hides Hunk's line-number columns | +| `wrap` | Wraps long lines instead of clipping them | + +A fence whose body is not a real unified diff — prose-style `+`/`-` bullets, for instance — +returns `null` and falls back to the default syntax-highlighted code block. + +## Known limits + +- **Line-number column width is per hunk.** Hunk sizes its line-number columns from the hunks it + is given, and each hunk row is given one hunk, so two hunks of the same file can differ by a + column when their line numbers differ in digit count. Everything else — collapsed-gap counts, + content, word-level highlights — matches a whole-file render. +- **Themes are approximated.** Hunk resolves one of its own bundled palettes by name and accepts + no custom colour table, so each Tooee theme is mapped to the closest bundled Hunk theme rather + than reproduced exactly. Unmapped (user) themes fall back to GitHub's palette on the matching + light/dark side. +- **Marks are not painted inside hunks.** Row-level decorations (cursor, search, selection, marks) + paint under the row, but Hunk draws its own backgrounds over most of it, so a mark on a diff row + reads mainly from the gutter sign. +- **Diff content is replace-only when streaming.** `ContentChunk`'s `append` does not accept + `diff`; send the full patch through a `replace` chunk instead. +- **Peer range.** `hunkdiff@0.18.0` declares `@opentui/core`/`@opentui/react` `^0.4.3`. It runs and + type-checks against Tooee's `0.5.1`, and Bun resolves it without a warning inside this workspace, + but a standalone install of `@tooee/diff` may print a peer-dependency warning until Hunk widens + the range. The version is pinned exactly because Hunk is pre-1.0. +- **Install weight.** `hunkdiff` declares the `bun` npm package as a runtime dependency and ships a + prebuilt CLI binary as an optional one, neither of which `hunkdiff/opentui` imports. Adding this + package grew the local Bun store by roughly 580 MB, of which about 400 MB is the Bun binary and + its platform variants and 134 MB is `hunkdiff-linux-x64`. Only the ~18 MB `hunkdiff/opentui` + bundle is actually used. Fixing this needs an upstream packaging change; `bun patch` cannot drop + a declared dependency from the resolution graph. diff --git a/packages/diff/package.json b/packages/diff/package.json new file mode 100644 index 00000000..70103be2 --- /dev/null +++ b/packages/diff/package.json @@ -0,0 +1,58 @@ +{ + "name": "@tooee/diff", + "version": "0.6.3", + "description": "Hunk-backed unified/split diff rendering for Tooee", + "keywords": [ + "cli", + "diff", + "opentui", + "patch", + "terminal", + "tui" + ], + "homepage": "https://github.com/gingerhendrix/tooee", + "bugs": "https://github.com/gingerhendrix/tooee/issues", + "license": "MIT", + "author": "Gareth Andrew", + "repository": { + "type": "git", + "url": "https://github.com/gingerhendrix/tooee.git", + "directory": "packages/diff" + }, + "files": [ + "dist", + "src" + ], + "type": "module", + "exports": { + ".": { + "import": { + "@tooee/source": "./src/index.ts", + "default": "./dist/index.js" + } + } + }, + "publishConfig": { + "access": "public" + }, + "scripts": { + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@tooee/renderers": "workspace:*", + "@tooee/themes": "workspace:*", + "hunkdiff": "0.18.0" + }, + "devDependencies": { + "@opentui/core": "^0.5.1", + "@opentui/react": "^0.5.1", + "@types/bun": "^1.3.10", + "@types/react": "^19.2.14", + "typescript": "^5.9.3" + }, + "peerDependencies": { + "@opentui/core": "^0.5.1", + "@opentui/react": "^0.5.1", + "react": "^18.0.0 || ^19.0.0" + } +} diff --git a/packages/diff/src/detect.ts b/packages/diff/src/detect.ts new file mode 100644 index 00000000..587c52df --- /dev/null +++ b/packages/diff/src/detect.ts @@ -0,0 +1,18 @@ +/** Lines that only appear at the start of a unified diff. */ +const GIT_HEADER = /^diff --git /mu; +const UNIFIED_HEADERS = /^--- .*\n\+\+\+ /mu; +const HUNK_HEADER = /^@@ -\d/mu; + +/** + * Whether text looks like a unified patch. + * + * Used to route extension-less input (stdin, files without a `.patch`/`.diff` + * suffix) to the diff viewer. A hunk header is required as well as a file + * header so prose containing a stray `--- ` rule is not mistaken for a diff. + */ +export const isDiffPatch = function isDiffPatch(text: string): boolean { + if (!HUNK_HEADER.test(text)) { + return false; + } + return GIT_HEADER.test(text) || UNIFIED_HEADERS.test(text); +}; diff --git a/packages/diff/src/diff-code-block.tsx b/packages/diff/src/diff-code-block.tsx new file mode 100644 index 00000000..a05d02e0 --- /dev/null +++ b/packages/diff/src/diff-code-block.tsx @@ -0,0 +1,102 @@ +import { useMemo } from "react"; +import type { ReactNode } from "react"; +import type { HunkDiffLayout } from "hunkdiff/opentui"; +import { CodeBlockChrome } from "@tooee/renderers"; +import type { CodeBlockRenderer, CodeBlockRendererProps } from "@tooee/renderers"; +import { useTheme } from "@tooee/themes"; +import { buildDiffModel } from "./model.js"; +import { DiffRowView, effectiveLayout } from "./diff-view.js"; +import { resolveHunkDiffTheme } from "./theme-map.js"; + +/** Options a fence info string can carry after the fence type. */ +export interface DiffFenceOptions { + layout: HunkDiffLayout; + showLineNumbers: boolean; + wrapLines: boolean; +} + +/** + * Read fence options from the info string, e.g. ```` ```diff split nolines ````. + * + * Unknown words are ignored so ordinary info strings (a filename, say) still + * render. + */ +export const parseDiffFenceOptions = function parseDiffFenceOptions( + info: string, +): DiffFenceOptions { + const words = new Set( + info + .trim() + .split(/\s+/u) + .slice(1) + .map((word) => word.toLowerCase()), + ); + return { + layout: words.has("split") ? "split" : "stack", + showLineNumbers: !words.has("nolines"), + wrapLines: words.has("wrap"), + }; +}; + +/** + * Renders ```` ```diff ```` and ```` ```patch ```` fences as Hunk diff blocks. + * + * The fence stays one Markdown block, so the document cursor, search and copy + * still treat it as a unit over the raw fence text; only its drawing changes. + * Fence bodies that are not parseable unified diffs return `null`, which falls + * back to the default syntax-highlighted code block — today's behaviour. + */ +const DiffCodeBlock = function DiffCodeBlock({ + text, + info, + width, + indent, +}: CodeBlockRendererProps): ReactNode { + const { theme, name: themeName } = useTheme(); + const model = useMemo(() => { + try { + return buildDiffModel(text); + } catch { + return null; + } + }, [text]); + const options = useMemo(() => parseDiffFenceOptions(info), [info]); + + // A ```diff fence is often prose-style +/- lines with no hunk headers. Only + // real unified diffs render as Hunk blocks; the rest fall back. + if (!model || !model.rows.some((row) => row.kind === "hunk")) { + return null; + } + + const hunkTheme = resolveHunkDiffTheme(themeName, theme); + const blockWidth = Math.max(1, width); + // A single-file fence needs no file header: the fence is the file. + const rows = + model.files.length > 1 ? model.rows : model.rows.filter((row) => row.kind !== "file"); + + return ( + + {rows.map( + (row): ReactNode => ( + + ), + )} + + ); +}; + +export const diffCodeBlockRenderer: CodeBlockRenderer = DiffCodeBlock; + +/** Fence types `diffCodeBlockRenderer` is registered for. */ +export const DIFF_CODE_BLOCK_RENDERERS: Record = { + diff: diffCodeBlockRenderer, + patch: diffCodeBlockRenderer, +}; diff --git a/packages/diff/src/diff-view.tsx b/packages/diff/src/diff-view.tsx new file mode 100644 index 00000000..522a6add --- /dev/null +++ b/packages/diff/src/diff-view.tsx @@ -0,0 +1,166 @@ +import { useMemo } from "react"; +import type { ReactNode } from "react"; +import { useTerminalDimensions } from "@opentui/react"; +import { HunkDiffBody, HunkDiffFileHeader } from "hunkdiff/opentui"; +import type { HunkDiffLayout, HunkDiffThemeName } from "hunkdiff/opentui"; +import { useTheme } from "@tooee/themes"; +import { + DEFAULT_SIGN_COLUMN_WIDTH, + computeRowDocumentGutterWidth, + useGutterPalette, +} from "@tooee/renderers"; +import type { DocumentBindings } from "@tooee/renderers"; +import "@tooee/renderers/row-document"; +import type { DiffRow } from "./model.js"; +import { resolveHunkDiffTheme } from "./theme-map.js"; + +/** + * Narrowest content width a split layout stays readable at. Below it, split + * falls back to stack so a narrow terminal shows whole lines instead of two + * unusable columns. + */ +export const MIN_SPLIT_WIDTH = 80; + +/** Columns held back from the measured width for the scrollbar and right edge. */ +const SCROLLBAR_RESERVE = 2; + +export interface DiffRenderOptions { + layout?: HunkDiffLayout; + /** Line-number columns inside each hunk (Hunk's own gutter). */ + showHunkLineNumbers?: boolean; + showHunkHeaders?: boolean; + wrapLines?: boolean; + horizontalOffset?: number; + /** Word-level intra-line highlighting. */ + highlight?: boolean; + tabWidth?: number; + /** Overrides the theme derived from the active Tooee theme. */ + theme?: HunkDiffThemeName; +} + +export interface DiffRowViewProps extends DiffRenderOptions { + row: DiffRow; + width: number; + theme: HunkDiffThemeName; + /** Draws Hunk's selected-hunk styling under the document cursor. */ + active?: boolean; +} + +/** Split falls back to stack when the content area is too narrow for two columns. */ +export const effectiveLayout = function effectiveLayout( + layout: HunkDiffLayout | undefined, + width: number, +): HunkDiffLayout { + return layout === "split" && width < MIN_SPLIT_WIDTH ? "stack" : (layout ?? "stack"); +}; + +/** + * One diff row. File rows render Hunk's compact header; hunk and body rows + * render a Hunk body — for hunk rows the file model is already narrowed to a + * single hunk, so exactly one hunk is drawn. + */ +export const DiffRowView = function DiffRowView({ + row, + width, + theme, + active = false, + layout, + showHunkLineNumbers = true, + showHunkHeaders = true, + wrapLines = false, + horizontalOffset = 0, + highlight = true, + tabWidth, +}: DiffRowViewProps): ReactNode { + if (row.kind === "file") { + return ; + } + + return ( + + ); +}; + +export interface DiffViewProps extends DiffRenderOptions { + rows: readonly DiffRow[]; + /** Controller bindings: scroll follow, decorations and row mouse handling. */ + document?: DocumentBindings; + /** Row-document gutter (Tooee's row numbers), not Hunk's line numbers. */ + showLineNumbers?: boolean; + /** Row index under the cursor, so the active hunk can be styled. */ + activeIndex?: number | null; + /** Overrides the measured content width; mainly for tests. */ + width?: number; +} + +/** + * The scrolling diff document: one `row-document` row per file header and per + * hunk, each drawn by Hunk. + * + * The row document is the single scroll owner — Hunk's public primitives bring + * no scrolling of their own — so the cursor, search decorations, marks and + * scroll-follow all keep working over diff rows. + */ +export const DiffView = function DiffView({ + rows, + document, + showLineNumbers = true, + activeIndex = null, + width, + ...render +}: DiffViewProps): ReactNode { + const { theme, name: themeName } = useTheme(); + const palette = useGutterPalette(); + const { width: terminalWidth } = useTerminalDimensions(); + + const gutterWidth = useMemo( + () => + computeRowDocumentGutterWidth({ + rowCount: rows.length, + showLineNumbers, + signColumnWidth: DEFAULT_SIGN_COLUMN_WIDTH, + }), + [rows.length, showLineNumbers], + ); + const contentWidth = Math.max(1, width ?? terminalWidth - gutterWidth - SCROLLBAR_RESERVE); + + const hunkTheme = render.theme ?? resolveHunkDiffTheme(themeName, theme); + + return ( + + {rows.map( + (row, index): ReactNode => ( + + + + ), + )} + + ); +}; diff --git a/packages/diff/src/index.ts b/packages/diff/src/index.ts new file mode 100644 index 00000000..a15d1875 --- /dev/null +++ b/packages/diff/src/index.ts @@ -0,0 +1,19 @@ +export { buildDiffModel, diffRowAdapter, scanPatchSections, countHunkDiffStats } from "./model.js"; +export type { DiffModel, DiffRow, DiffRowKind } from "./model.js"; +export { DiffView, DiffRowView, effectiveLayout, MIN_SPLIT_WIDTH } from "./diff-view.js"; +export type { DiffViewProps, DiffRowViewProps, DiffRenderOptions } from "./diff-view.js"; +export { + diffCodeBlockRenderer, + parseDiffFenceOptions, + DIFF_CODE_BLOCK_RENDERERS, +} from "./diff-code-block.js"; +export type { DiffFenceOptions } from "./diff-code-block.js"; +export { resolveHunkDiffTheme, isLightBackground, HUNK_THEME_MAP } from "./theme-map.js"; +export type { HunkThemePair } from "./theme-map.js"; +export { isDiffPatch } from "./detect.js"; +export type { + HunkDiffFile, + HunkDiffLayout, + HunkDiffStats, + HunkDiffThemeName, +} from "hunkdiff/opentui"; diff --git a/packages/diff/src/model.ts b/packages/diff/src/model.ts new file mode 100644 index 00000000..d7ac805e --- /dev/null +++ b/packages/diff/src/model.ts @@ -0,0 +1,247 @@ +import { createHunkDiffFilesFromPatch } from "hunkdiff/opentui"; +import type { HunkDiffFile, HunkDiffFileInput, HunkDiffStats } from "hunkdiff/opentui"; +import { SourceIndex } from "@tooee/renderers"; +import type { DocumentRowSource } from "@tooee/renderers"; + +/** + * What a navigation row stands for. + * + * - `file` — the file's header line (path, rename arrow, stats). + * - `hunk` — one `@@` hunk of a file. + * - `body` — a file Hunk renders without hunks (binary, too large, untracked); + * the whole file model is handed to the body renderer so it can draw its + * own notice. + */ +export type DiffRowKind = "file" | "hunk" | "body"; + +export interface DiffRow { + kind: DiffRowKind; + /** Stable identity across re-parses of the same patch. */ + key: string; + /** Id of the owning file in the parsed model. */ + fileId: string; + fileIndex: number; + /** Hunk position within the file; `-1` for `file` and `body` rows. */ + hunkIndex: number; + /** + * The model handed to Hunk for this row. `hunk` rows carry a copy of the + * owning file whose metadata is narrowed to a single hunk, so Hunk renders + * exactly one hunk while still resolving line content and collapsed-gap + * counts against the complete file. + */ + file: HunkDiffFileInput; + /** The owning file, unnarrowed. */ + parent: HunkDiffFile; + /** Patch text for this row — the unit search and copy work in. */ + text: string; + /** Provenance in the original patch text, or `null` when it cannot be resolved. */ + source: DocumentRowSource | null; +} + +export interface DiffModel { + /** Files as parsed by Hunk, in patch order. */ + files: HunkDiffFile[]; + rows: DiffRow[]; + /** Total additions/deletions across every file. */ + stats: HunkDiffStats; + /** The patch text the model was built from. */ + patch: string; +} + +// --------------------------------------------------------------------------- +// Patch scanning +// --------------------------------------------------------------------------- + +interface PatchSection { + start: number; + end: number; + /** End of the file's header lines: the offset of its first hunk, or `end`. */ + headerEnd: number; + hunks: { start: number; end: number }[]; +} + +const GIT_HEADER = "diff --git "; +const OLD_FILE_HEADER = "--- "; +const HUNK_HEADER = "@@"; + +/** + * Split patch text into per-file sections and per-hunk ranges, in offsets over + * the original string. + * + * Hunk's parser does not expose source positions, so provenance is recovered by + * scanning the same text. Both `diff --git` patches and bare unified diffs are + * handled: a `--- ` line opens a new section only when it cannot belong to the + * section already being read. + */ +export const scanPatchSections = function scanPatchSections(patch: string): PatchSection[] { + const sections: PatchSection[] = []; + + const openSection = (start: number): PatchSection => { + const section: PatchSection = { end: start, headerEnd: start, hunks: [], start }; + sections.push(section); + return section; + }; + + let offset = 0; + for (const line of patch.split("\n")) { + const lineStart = offset; + const lineEnd = offset + line.length + 1; + offset = lineEnd; + + let current = sections.at(-1); + if ( + line.startsWith(GIT_HEADER) || + (line.startsWith(OLD_FILE_HEADER) && (current === undefined || current.hunks.length > 0)) + ) { + current = openSection(lineStart); + } else if (line.startsWith(HUNK_HEADER)) { + current ??= openSection(lineStart); + if (current.hunks.length === 0) { + current.headerEnd = lineStart; + } + current.hunks.push({ end: lineEnd, start: lineStart }); + } + + if (current === undefined) { + continue; + } + current.end = lineEnd; + const lastHunk = current.hunks.at(-1); + if (lastHunk) { + lastHunk.end = lineEnd; + } else { + current.headerEnd = lineEnd; + } + } + + // The trailing split entry after a final newline contributes no content. + for (const section of sections) { + section.end = Math.min(section.end, patch.length); + section.headerEnd = Math.min(section.headerEnd, section.end); + for (const hunk of section.hunks) { + hunk.end = Math.min(hunk.end, section.end); + } + } + + return sections; +}; + +// --------------------------------------------------------------------------- +// Model construction +// --------------------------------------------------------------------------- + +/** Narrow a file to a single hunk while keeping its whole-file line arrays. */ +const narrowToHunk = function narrowToHunk( + file: HunkDiffFile, + hunkIndex: number, +): HunkDiffFileInput { + const hunk = file.metadata.hunks[hunkIndex]; + return { + ...file, + id: `${file.id}#${hunkIndex}`, + metadata: { ...file.metadata, hunks: [hunk] }, + }; +}; + +const fallbackHunkTexts = function fallbackHunkTexts(patch: string): { + header: string; + hunks: string[]; +} { + const header: string[] = []; + const hunks: string[][] = []; + let currentHunk: string[] | null = null; + for (const line of patch.split("\n")) { + if (line.startsWith(HUNK_HEADER)) { + currentHunk = [line]; + hunks.push(currentHunk); + } else if (currentHunk) { + currentHunk.push(line); + } else { + header.push(line); + } + } + return { header: header.join("\n"), hunks: hunks.map((lines) => lines.join("\n")) }; +}; + +/** + * Parse unified patch text into the navigation rows the diff subview and the + * Markdown fence renderer share. + * + * Rows are `file` headers and `hunk` bodies so navigation, marks and the + * cursor land on a hunk rather than on a whole file. Files Hunk renders without + * hunks contribute a single `body` row instead. + */ +export const buildDiffModel = function buildDiffModel(patch: string, sourceId?: string): DiffModel { + const files = createHunkDiffFilesFromPatch(patch, sourceId); + const sections = scanPatchSections(patch); + const aligned = sections.length === files.length ? sections : null; + const index = new SourceIndex(patch, sourceId); + + const rows: DiffRow[] = []; + for (const [fileIndex, file] of files.entries()) { + const section = aligned?.[fileIndex]; + const fallback = fallbackHunkTexts(file.patch ?? ""); + const { hunks } = file.metadata; + const hunkSpansAligned = section !== undefined && section.hunks.length === hunks.length; + + rows.push({ + file, + fileId: file.id, + fileIndex, + hunkIndex: -1, + key: `${file.id}:file`, + kind: "file", + parent: file, + source: section ? { primary: index.span(section.start, section.headerEnd) } : null, + text: section ? patch.slice(section.start, section.headerEnd) : fallback.header, + }); + + if (hunks.length === 0) { + rows.push({ + file, + fileId: file.id, + fileIndex, + hunkIndex: -1, + key: `${file.id}:body`, + kind: "body", + parent: file, + source: section ? { primary: index.span(section.start, section.end) } : null, + text: section ? patch.slice(section.start, section.end) : (file.patch ?? ""), + }); + continue; + } + + for (const [hunkIndex] of hunks.entries()) { + const span = hunkSpansAligned ? section.hunks[hunkIndex] : undefined; + rows.push({ + file: narrowToHunk(file, hunkIndex), + fileId: file.id, + fileIndex, + hunkIndex, + key: `${file.id}:hunk:${hunkIndex}`, + kind: "hunk", + parent: file, + source: span ? { primary: index.span(span.start, span.end) } : null, + text: span ? patch.slice(span.start, span.end) : (fallback.hunks[hunkIndex] ?? ""), + }); + } + } + + let additions = 0; + let deletions = 0; + for (const file of files) { + additions += file.stats.additions; + deletions += file.stats.deletions; + } + + return { files, patch, rows, stats: { additions, deletions } }; +}; + +/** Row identity, text and provenance for a `DocumentController` over `DiffRow`s. */ +export const diffRowAdapter = { + getKey: (row: DiffRow): string => row.key, + getSource: (row: DiffRow): DocumentRowSource | null => row.source, + getText: (row: DiffRow): string => row.text, +}; + +export { countHunkDiffStats } from "hunkdiff/opentui"; diff --git a/packages/diff/src/theme-map.ts b/packages/diff/src/theme-map.ts new file mode 100644 index 00000000..c68a8613 --- /dev/null +++ b/packages/diff/src/theme-map.ts @@ -0,0 +1,88 @@ +import type { HunkDiffThemeName } from "hunkdiff/opentui"; +import type { ResolvedTheme } from "@tooee/themes"; + +/** + * Hunk resolves one of its own bundled palettes by name; it accepts no custom + * colour table, so a Tooee theme is matched to the closest bundled Hunk theme + * rather than reproduced exactly. + * + * Every Tooee theme ships both a light and a dark variant, and which one is + * active depends on the terminal, so each entry names a Hunk theme per variant + * and the resolved background decides between them. + */ +export interface HunkThemePair { + dark: HunkDiffThemeName; + light: HunkDiffThemeName; +} + +const GITHUB: HunkThemePair = { dark: "github-dark", light: "github-light" }; + +/** Closest bundled Hunk theme for each theme shipped with `@tooee/themes`. */ +export const HUNK_THEME_MAP: Record = { + aura: { dark: "laserwave", light: "min-light" }, + ayu: { dark: "ayu-dark", light: "ayu-light" }, + catppuccin: { dark: "catppuccin-mocha", light: "catppuccin-latte" }, + "catppuccin-frappe": { dark: "catppuccin-frappe", light: "catppuccin-latte" }, + "catppuccin-macchiato": { dark: "catppuccin-macchiato", light: "catppuccin-latte" }, + cobalt2: { dark: "dark-plus", light: "light-plus" }, + cursor: { dark: "vitesse-dark", light: "vitesse-light" }, + dracula: { dark: "dracula", light: "min-light" }, + everforest: { dark: "everforest-dark", light: "everforest-light" }, + flexoki: { dark: "vitesse-dark", light: "vitesse-light" }, + github: GITHUB, + "github-light": { dark: "github-dark", light: "github-light" }, + gruvbox: { dark: "gruvbox-dark-medium", light: "gruvbox-light-medium" }, + kanagawa: { dark: "kanagawa-wave", light: "kanagawa-lotus" }, + "lucent-orng": { dark: "vesper", light: "min-light" }, + material: { dark: "material-theme", light: "material-theme-lighter" }, + matrix: { dark: "vitesse-black", light: "min-light" }, + mercury: { dark: "min-dark", light: "min-light" }, + monokai: { dark: "monokai", light: "min-light" }, + nightowl: { dark: "night-owl", light: "night-owl-light" }, + nord: { dark: "nord", light: "min-light" }, + "one-dark": { dark: "one-dark-pro", light: "one-light" }, + opencode: { dark: "vitesse-dark", light: "vitesse-light" }, + "opencode-light": { dark: "vitesse-dark", light: "vitesse-light" }, + orng: { dark: "vesper", light: "min-light" }, + "osaka-jade": { dark: "everforest-dark", light: "everforest-light" }, + palenight: { dark: "material-theme-palenight", light: "material-theme-lighter" }, + rosepine: { dark: "rose-pine", light: "rose-pine-dawn" }, + solarized: { dark: "solarized-dark", light: "solarized-light" }, + synthwave84: { dark: "synthwave-84", light: "min-light" }, + tokyonight: { dark: "tokyo-night", light: "min-light" }, + vercel: { dark: "vitesse-black", light: "vitesse-light" }, + vesper: { dark: "vesper", light: "min-light" }, + zenburn: { dark: "gruvbox-dark-soft", light: "gruvbox-light-soft" }, +}; + +const HEX_COLOR = /^#(?[0-9a-f]{3}|[0-9a-f]{6})$/iu; +/** Rec. 601 luma above this counts as a light background. */ +const LIGHT_LUMA = 128; + +/** `true` when `color` is a hex colour bright enough to read as a light background. */ +export const isLightBackground = function isLightBackground(color: string): boolean { + const digits = HEX_COLOR.exec(color.trim())?.groups?.digits; + if (digits === undefined) { + // Named or transparent backgrounds carry no brightness: assume dark. + return false; + } + // #abc expands to #aabbcc; the pattern only ever matches ASCII hex digits. + const full = digits.length === 3 ? digits.replaceAll(/[0-9a-f]/giu, "$&$&") : digits; + const r = Number.parseInt(full.slice(0, 2), 16); + const g = Number.parseInt(full.slice(2, 4), 16); + const b = Number.parseInt(full.slice(4, 6), 16); + return 0.299 * r + 0.587 * g + 0.114 * b > LIGHT_LUMA; +}; + +/** + * The bundled Hunk theme to render a diff with. Unmapped themes (user themes + * from `~/.config/tooee/themes`) fall back to GitHub's palette on the same + * light/dark side as the active theme's background. + */ +export const resolveHunkDiffTheme = function resolveHunkDiffTheme( + themeName: string, + theme: ResolvedTheme, +): HunkDiffThemeName { + const pair = HUNK_THEME_MAP[themeName] ?? GITHUB; + return isLightBackground(theme.background) ? pair.light : pair.dark; +}; diff --git a/packages/diff/test/detect.test.ts b/packages/diff/test/detect.test.ts new file mode 100644 index 00000000..1fa978d2 --- /dev/null +++ b/packages/diff/test/detect.test.ts @@ -0,0 +1,22 @@ +import { test, expect, describe } from "bun:test"; +import { isDiffPatch } from "../src/detect.js"; +import { BARE_UNIFIED_PATCH, MULTI_FILE_PATCH, RENAME_AND_BINARY_PATCH } from "./fixtures.js"; + +describe("isDiffPatch", () => { + test("accepts git and bare unified patches", () => { + expect(isDiffPatch(MULTI_FILE_PATCH)).toBe(true); + expect(isDiffPatch(BARE_UNIFIED_PATCH)).toBe(true); + expect(isDiffPatch(RENAME_AND_BINARY_PATCH)).toBe(true); + }); + + test("rejects prose, code and Markdown", () => { + expect(isDiffPatch("")).toBe(false); + expect(isDiffPatch("# Title\n\n--- a horizontal rule of sorts\n")).toBe(false); + expect(isDiffPatch("const a = 1;\nexport { a };\n")).toBe(false); + }); + + test("needs both a file header and a hunk header", () => { + expect(isDiffPatch("diff --git a/x b/x\nindex 1..2 100644\n")).toBe(false); + expect(isDiffPatch("@@ -1 +1 @@\n-a\n+b\n")).toBe(false); + }); +}); diff --git a/packages/diff/test/diff-code-block.test.tsx b/packages/diff/test/diff-code-block.test.tsx new file mode 100644 index 00000000..021d019a --- /dev/null +++ b/packages/diff/test/diff-code-block.test.tsx @@ -0,0 +1,108 @@ +import { testRender } from "../../../test/support/test-render.ts"; +import { test, expect, describe, afterEach } from "bun:test"; +import { ThemeProvider } from "@tooee/themes"; +import { MarkdownView } from "@tooee/renderers"; +import { DIFF_CODE_BLOCK_RENDERERS, parseDiffFenceOptions } from "../src/diff-code-block.js"; +import { MULTI_FILE_PATCH } from "./fixtures.js"; + +let testSetup: Awaited> | undefined; + +afterEach(() => { + testSetup?.renderer.destroy(); + testSetup = undefined; +}); + +const renderMarkdown = async function renderMarkdown(markdown: string, width = 100) { + testSetup = await testRender( + + + , + { height: 40, width }, + ); + await testSetup.renderOnce(); + return testSetup.captureCharFrame(); +}; + +const lineWith = function lineWith(frame: string, needle: string): string { + return frame.split("\n").find((line) => line.includes(needle)) ?? ""; +}; + +const fence = function fence(info: string, body: string): string { + return `# Changes\n\n\`\`\`${info}\n${body}\`\`\`\n`; +}; + +describe("diffCodeBlockRenderer", () => { + test("renders a ```diff fence as a Hunk block", async () => { + const frame = await renderMarkdown(fence("diff", MULTI_FILE_PATCH)); + expect(frame).toContain("Changes"); + expect(frame).toContain("@@ -1,3 +1,4 @@"); + expect(frame).toContain("const b = 22;"); + // Multi-file fences keep their file headers. + expect(frame).toContain("src/a.ts"); + expect(frame).toContain("docs/notes.md"); + }); + + test("renders a ```patch fence too", async () => { + const frame = await renderMarkdown(fence("patch", MULTI_FILE_PATCH)); + expect(frame).toContain("@@ -1,3 +1,4 @@"); + }); + + test("drops the file header when the fence holds a single file", async () => { + const single = MULTI_FILE_PATCH.slice(0, MULTI_FILE_PATCH.indexOf("diff --git a/docs")); + const frame = await renderMarkdown(fence("diff", single)); + expect(frame).toContain("@@ -1,3 +1,4 @@"); + expect(frame).not.toContain("+3 -2"); + }); + + test("falls back to the default code block for prose-style diff fences", async () => { + const frame = await renderMarkdown(fence("diff", "- removed idea\n+ added idea\n")); + expect(frame).toContain("- removed idea"); + expect(frame).toContain("+ added idea"); + expect(frame).not.toContain("@@"); + }); + + test("falls back for a fence that is not a diff at all", async () => { + const frame = await renderMarkdown(fence("diff", "just some text\n")); + expect(frame).toContain("just some text"); + }); + + test("honours nolines from the fence info string", async () => { + const withLines = await renderMarkdown(fence("diff", MULTI_FILE_PATCH)); + const withoutLines = await renderMarkdown(fence("diff nolines", MULTI_FILE_PATCH)); + expect(lineWith(withLines, "const b = 22;")).toMatch(/\d/u); + expect(lineWith(withoutLines, "const b = 22;")).not.toMatch(/\d\s+\+/u); + }); + + test("re-renders the block at the new width after a resize", async () => { + const wide = await renderMarkdown(fence("diff split", MULTI_FILE_PATCH), 140); + const wideLine = lineWith(wide, "const b = 22;"); + expect(wideLine).toContain("const b = 2;"); + + const narrow = await renderMarkdown(fence("diff split", MULTI_FILE_PATCH), 70); + const narrowLine = lineWith(narrow, "const b = 22;"); + expect(narrowLine).not.toContain("const b = 2;"); + }); +}); + +describe("parseDiffFenceOptions", () => { + test("reads layout, line-number and wrap options after the fence type", () => { + expect(parseDiffFenceOptions("diff")).toEqual({ + layout: "stack", + showLineNumbers: true, + wrapLines: false, + }); + expect(parseDiffFenceOptions("diff split nolines wrap")).toEqual({ + layout: "split", + showLineNumbers: false, + wrapLines: true, + }); + }); + + test("ignores unknown info-string words", () => { + expect(parseDiffFenceOptions("patch changes.patch")).toEqual({ + layout: "stack", + showLineNumbers: true, + wrapLines: false, + }); + }); +}); diff --git a/packages/diff/test/diff-view.test.tsx b/packages/diff/test/diff-view.test.tsx new file mode 100644 index 00000000..75ea1fcd --- /dev/null +++ b/packages/diff/test/diff-view.test.tsx @@ -0,0 +1,111 @@ +import { testRender } from "../../../test/support/test-render.ts"; +import { test, expect, describe, afterEach } from "bun:test"; +import { act } from "react"; +import { ThemeProvider } from "@tooee/themes"; +import { DiffView, effectiveLayout } from "../src/diff-view.js"; +import { buildDiffModel } from "../src/model.js"; +import { MULTI_FILE_PATCH, RENAME_AND_BINARY_PATCH } from "./fixtures.js"; + +let testSetup: Awaited> | undefined; + +afterEach(() => { + testSetup?.renderer.destroy(); + testSetup = undefined; +}); + +const DEFAULT_SIZE = { height: 40, width: 100 }; + +const renderDiff = async function renderDiff( + patch: string, + props: Partial[0]> = {}, + size = DEFAULT_SIZE, +) { + const model = buildDiffModel(patch); + testSetup = await testRender( + + + , + size, + ); + await testSetup.renderOnce(); + return testSetup.captureCharFrame(); +}; + +describe("DiffView", () => { + test("renders every file header and hunk of a multi-file patch", async () => { + const frame = await renderDiff(MULTI_FILE_PATCH); + expect(frame).toContain("src/a.ts"); + expect(frame).toContain("docs/notes.md"); + expect(frame).toContain("@@ -1,3 +1,4 @@"); + expect(frame).toContain("@@ -20,3 +21,3 @@"); + expect(frame).toContain("const b = 22;"); + expect(frame).toContain("new note"); + }); + + test("keeps collapsed-gap counts accurate across hunk rows", async () => { + const frame = await renderDiff(MULTI_FILE_PATCH); + // Between the two hunks of src/a.ts there are 16 unchanged lines. The count + // is only right because hunk rows keep the whole-file metadata. + expect(frame).toContain("16 unchanged lines"); + }); + + test("split layout renders old and new side by side", async () => { + const frame = await renderDiff(MULTI_FILE_PATCH, { layout: "split" }); + const changed = frame.split("\n").find((line) => line.includes("const b = 22;")); + expect(changed).toBeDefined(); + expect(changed).toContain("const b = 2;"); + }); + + test("split falls back to stack when the content area is too narrow", async () => { + const frame = await renderDiff(MULTI_FILE_PATCH, { layout: "split", width: 40 }); + const changed = frame.split("\n").find((line) => line.includes("const b = 22;")); + expect(changed).toBeDefined(); + expect(changed).not.toContain("const b = 2;"); + }); + + test("renders binary files through their body row", async () => { + const frame = await renderDiff(RENAME_AND_BINARY_PATCH); + expect(frame).toContain("old.txt -> new.txt"); + expect(frame).toContain("img.png"); + expect(frame).toContain("Binary file skipped"); + }); + + test("re-renders at the new width after a resize", async () => { + const model = buildDiffModel(MULTI_FILE_PATCH); + testSetup = await testRender( + + + , + { height: 40, width: 120 }, + ); + await testSetup.renderOnce(); + expect( + testSetup + .captureCharFrame() + .split("\n") + .find((line) => line.includes("const b = 22;")), + ).toContain("const b = 2;"); + + await act(async () => { + testSetup?.resize(70, 40); + await Promise.resolve(); + }); + await testSetup.renderOnce(); + // Narrower than the split threshold, so the same content stacks. + expect( + testSetup + .captureCharFrame() + .split("\n") + .find((line) => line.includes("const b = 22;")), + ).not.toContain("const b = 2;"); + }); +}); + +describe("effectiveLayout", () => { + test("keeps split only above the minimum width", () => { + expect(effectiveLayout("split", 120)).toBe("split"); + expect(effectiveLayout("split", 40)).toBe("stack"); + expect(effectiveLayout("stack", 200)).toBe("stack"); + expect(effectiveLayout(undefined, 200)).toBe("stack"); + }); +}); diff --git a/packages/diff/test/fixtures.ts b/packages/diff/test/fixtures.ts new file mode 100644 index 00000000..88c759af --- /dev/null +++ b/packages/diff/test/fixtures.ts @@ -0,0 +1,53 @@ +/** Two-file git patch with a multi-hunk file, used across the diff tests. */ +export const MULTI_FILE_PATCH = `diff --git a/src/a.ts b/src/a.ts +index 1111111..2222222 100644 +--- a/src/a.ts ++++ b/src/a.ts +@@ -1,3 +1,4 @@ + const a = 1; +-const b = 2; ++const b = 22; ++const c = 3; + export { a }; +@@ -20,3 +21,3 @@ function tail() { + const x = 1; +-const y = 2; ++const y = 3; + const z = 4; +diff --git a/docs/notes.md b/docs/notes.md +index 3333333..4444444 100644 +--- a/docs/notes.md ++++ b/docs/notes.md +@@ -1,2 +1,2 @@ +-old note ++new note + trailing +`; + +/** A rename plus a binary file: the shapes Hunk renders without hunks. */ +export const RENAME_AND_BINARY_PATCH = `diff --git a/old.txt b/new.txt +similarity index 90% +rename from old.txt +rename to new.txt +--- a/old.txt ++++ b/new.txt +@@ -1 +1 @@ +-hello ++world +diff --git a/img.png b/img.png +Binary files a/img.png and b/img.png differ +`; + +/** A bare unified diff, as produced by `diff -u` without git headers. */ +export const BARE_UNIFIED_PATCH = `--- a/one.txt ++++ b/one.txt +@@ -1,2 +1,2 @@ +-first ++FIRST + second +--- a/two.txt ++++ b/two.txt +@@ -1 +1 @@ +-alpha ++beta +`; diff --git a/packages/diff/test/model.test.ts b/packages/diff/test/model.test.ts new file mode 100644 index 00000000..b9541b33 --- /dev/null +++ b/packages/diff/test/model.test.ts @@ -0,0 +1,99 @@ +import { test, expect, describe } from "bun:test"; +import { buildDiffModel, diffRowAdapter, scanPatchSections } from "../src/model.js"; +import { BARE_UNIFIED_PATCH, MULTI_FILE_PATCH, RENAME_AND_BINARY_PATCH } from "./fixtures.js"; + +describe("scanPatchSections", () => { + test("splits a git patch into files and hunks", () => { + const sections = scanPatchSections(MULTI_FILE_PATCH); + expect(sections).toHaveLength(2); + expect(sections[0].hunks).toHaveLength(2); + expect(sections[1].hunks).toHaveLength(1); + expect(MULTI_FILE_PATCH.slice(sections[0].start, sections[0].headerEnd)).toContain( + "diff --git a/src/a.ts", + ); + expect(MULTI_FILE_PATCH.slice(sections[0].hunks[1].start, sections[0].hunks[1].end)).toBe( + "@@ -20,3 +21,3 @@ function tail() {\n const x = 1;\n-const y = 2;\n+const y = 3;\n const z = 4;\n", + ); + }); + + test("splits a bare unified diff on --- headers", () => { + const sections = scanPatchSections(BARE_UNIFIED_PATCH); + expect(sections).toHaveLength(2); + expect(sections.map((section) => section.hunks.length)).toEqual([1, 1]); + }); + + test("returns nothing for text that is not a patch", () => { + expect(scanPatchSections("just some prose\nwith lines\n")).toEqual([]); + }); +}); + +describe("buildDiffModel", () => { + test("emits a header row per file and a row per hunk", () => { + const model = buildDiffModel(MULTI_FILE_PATCH); + expect(model.files).toHaveLength(2); + expect(model.rows.map((row) => `${row.kind}:${row.hunkIndex}`)).toEqual([ + "file:-1", + "hunk:0", + "hunk:1", + "file:-1", + "hunk:0", + ]); + expect(model.stats).toEqual({ additions: 4, deletions: 3 }); + }); + + test("hunk rows carry their own patch text and source span", () => { + const model = buildDiffModel(MULTI_FILE_PATCH); + const [, firstHunk] = model.rows; + expect(firstHunk.text).toStartWith("@@ -1,3 +1,4 @@"); + expect(firstHunk.text).toContain("+const c = 3;"); + expect(firstHunk.source?.primary.start.line).toBe(4); + expect(diffRowAdapter.getText(firstHunk)).toBe(firstHunk.text); + expect(diffRowAdapter.getSource(firstHunk)).toBe(firstHunk.source); + }); + + test("row keys are unique and stable across rebuilds", () => { + const keys = buildDiffModel(MULTI_FILE_PATCH).rows.map((row) => diffRowAdapter.getKey(row)); + expect(new Set(keys).size).toBe(keys.length); + expect(buildDiffModel(MULTI_FILE_PATCH).rows.map((row) => row.key)).toEqual(keys); + }); + + test("hunk rows narrow the file metadata to a single hunk", () => { + const model = buildDiffModel(MULTI_FILE_PATCH); + const hunkRows = model.rows.filter((row) => row.kind === "hunk" && row.fileIndex === 0); + expect(hunkRows).toHaveLength(2); + for (const row of hunkRows) { + expect(row.file.metadata.hunks).toHaveLength(1); + // Whole-file line arrays stay intact so line numbers and collapsed-gap + // counts still resolve against the complete file. + expect(row.file.metadata.additionLines).toEqual(row.parent.metadata.additionLines); + } + expect(hunkRows[1].file.metadata.hunks[0].collapsedBefore).toBe(16); + }); + + test("a file with no hunks contributes a single body row", () => { + const model = buildDiffModel(RENAME_AND_BINARY_PATCH); + const binaryRows = model.rows.filter((row) => row.fileIndex === 1); + expect(binaryRows.map((row) => row.kind)).toEqual(["file", "body"]); + expect(binaryRows[1].text).toContain("Binary files"); + }); + + test("keeps rename metadata on the file row", () => { + const model = buildDiffModel(RENAME_AND_BINARY_PATCH); + expect(model.files[0].previousPath).toBe("old.txt"); + expect(model.rows[0].parent.path).toBe("new.txt"); + }); + + test("handles bare unified diffs", () => { + const model = buildDiffModel(BARE_UNIFIED_PATCH); + expect(model.files).toHaveLength(2); + expect(model.rows.filter((row) => row.kind === "hunk")).toHaveLength(2); + expect(model.rows[1].source).not.toBeNull(); + }); + + test("empty input yields no rows", () => { + const model = buildDiffModel(""); + expect(model.files).toEqual([]); + expect(model.rows).toEqual([]); + expect(model.stats).toEqual({ additions: 0, deletions: 0 }); + }); +}); diff --git a/packages/diff/test/theme-map.test.ts b/packages/diff/test/theme-map.test.ts new file mode 100644 index 00000000..beaa5d30 --- /dev/null +++ b/packages/diff/test/theme-map.test.ts @@ -0,0 +1,72 @@ +import { test, expect, describe } from "bun:test"; +import { HUNK_DIFF_THEME_NAMES } from "hunkdiff/opentui"; +import { loadThemes, resolveTheme } from "@tooee/themes"; +import { HUNK_THEME_MAP, isLightBackground, resolveHunkDiffTheme } from "../src/theme-map.js"; + +const BUNDLED = new Set(HUNK_DIFF_THEME_NAMES); + +const colorsFor = function colorsFor(name: string) { + const json = loadThemes().get(name); + if (json === undefined) { + throw new Error(`missing theme fixture: ${name}`); + } + return resolveTheme(json, "dark"); +}; + +describe("resolveHunkDiffTheme", () => { + test("every bundled Tooee theme resolves to a real Hunk theme, in both modes", () => { + const themes = loadThemes(); + expect(themes.size).toBeGreaterThan(0); + for (const [name, json] of themes) { + for (const mode of ["dark", "light"] as const) { + const resolved = resolveHunkDiffTheme(name, resolveTheme(json, mode)); + expect(BUNDLED.has(resolved)).toBe(true); + } + } + // Every shipped theme is mapped explicitly, not just via the fallback. + for (const name of themes.keys()) { + expect(Object.hasOwn(HUNK_THEME_MAP, name)).toBe(true); + } + }); + + test("every mapped name is a bundled Hunk theme", () => { + for (const pair of Object.values(HUNK_THEME_MAP)) { + expect(BUNDLED.has(pair.dark)).toBe(true); + expect(BUNDLED.has(pair.light)).toBe(true); + } + }); + + test("unmapped themes fall back to GitHub on the matching side", () => { + const dark = colorsFor("tokyonight"); + expect(resolveHunkDiffTheme("some-user-theme", { ...dark, background: "#101014" })).toBe( + "github-dark", + ); + expect(resolveHunkDiffTheme("some-user-theme", { ...dark, background: "#fdfdfd" })).toBe( + "github-light", + ); + }); + + test("the resolved background decides between a theme's light and dark palettes", () => { + const colors = colorsFor("github"); + expect(resolveHunkDiffTheme("github", { ...colors, background: "#0d1117" })).toBe( + "github-dark", + ); + expect(resolveHunkDiffTheme("github", { ...colors, background: "#ffffff" })).toBe( + "github-light", + ); + }); +}); + +describe("isLightBackground", () => { + test("classifies hex colours by luma", () => { + expect(isLightBackground("#ffffff")).toBe(true); + expect(isLightBackground("#fff")).toBe(true); + expect(isLightBackground("#000000")).toBe(false); + expect(isLightBackground("#1e1e2a")).toBe(false); + }); + + test("treats non-hex backgrounds as dark", () => { + expect(isLightBackground("transparent")).toBe(false); + expect(isLightBackground("")).toBe(false); + }); +}); diff --git a/packages/diff/test/tsconfig.json b/packages/diff/test/tsconfig.json new file mode 100644 index 00000000..1d18dcfb --- /dev/null +++ b/packages/diff/test/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../../tsconfig.lint.json", + "include": ["./**/*.ts", "./**/*.tsx"] +} diff --git a/packages/diff/tsconfig.json b/packages/diff/tsconfig.json new file mode 100644 index 00000000..f46f06aa --- /dev/null +++ b/packages/diff/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "composite": true, + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["./src"], + "references": [{ "path": "../renderers" }, { "path": "../themes" }] +} diff --git a/scripts/tegami.mts b/scripts/tegami.mts index ae126252..02752b68 100644 --- a/scripts/tegami.mts +++ b/scripts/tegami.mts @@ -51,6 +51,7 @@ const paper = tegami({ "@tooee/clipboard": { group: "tooee" }, "@tooee/commands": { group: "tooee" }, "@tooee/config": { group: "tooee" }, + "@tooee/diff": { group: "tooee" }, "@tooee/fuzzy": { group: "tooee" }, "@tooee/layout": { group: "tooee" }, "@tooee/marks": { group: "tooee" }, diff --git a/tsconfig.json b/tsconfig.json index 0088c370..c56a26b7 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -29,6 +29,7 @@ { "path": "packages/search" }, { "path": "packages/layout" }, { "path": "packages/renderers" }, + { "path": "packages/diff" }, { "path": "packages/shell" }, { "path": "packages/view" }, { "path": "packages/ask" }, From bafc701b4442de5e20f432f92bd082a1502cb430 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 11 Aug 2026 02:59:02 +0100 Subject: [PATCH 3/4] feat(view): add the diff content format and subview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `.patch`/`.diff` files — and patch text sniffed from stdin or an unhelpful extension — now open a first-class diff subview instead of plain text. `DiffSubview` runs a document controller over the diff rows, so `j`/`k` step hunk by hunk, search and copy work in real patch text, and the status bar shows file count, totals, layout and the current file:hunk. Commands: `s` toggles split/unified, `w` toggles wrapping, `]`/`[` jump between files, `f` opens a fuzzy file picker, and `h`/`l` pan wide hunks. The picker sets the cursor through `navigation.setCursor` rather than `selectRow`, which stands down while the modal overlay that owns the picker is still open. ```diff and ```patch fences in Markdown render as diff blocks by default; a host's own `codeBlockRenderers` entry still wins. --- .tegami/feat-hunk-diff-rendering.md | 19 ++ apps/cli/src/main.ts | 7 +- packages/config/src/types.ts | 2 + packages/e2e/view/diff.test.ts | 86 +++++++ packages/view/package.json | 1 + .../view/src/components/diff-file-picker.tsx | 37 +++ .../src/components/subviews/diff-subview.tsx | 229 ++++++++++++++++++ .../view/src/components/subviews/index.ts | 1 + packages/view/src/default-provider.ts | 18 +- packages/view/src/index.ts | 1 + packages/view/src/types.ts | 21 +- packages/view/src/view.tsx | 14 +- packages/view/test/diff-provider.test.ts | 40 +++ packages/view/test/diff-view.test.tsx | 181 ++++++++++++++ packages/view/test/fixtures/diff-fence.md | 25 ++ packages/view/test/fixtures/sample.patch | 18 ++ packages/view/tsconfig.json | 1 + 17 files changed, 693 insertions(+), 8 deletions(-) create mode 100644 .tegami/feat-hunk-diff-rendering.md create mode 100644 packages/e2e/view/diff.test.ts create mode 100644 packages/view/src/components/diff-file-picker.tsx create mode 100644 packages/view/src/components/subviews/diff-subview.tsx create mode 100644 packages/view/test/diff-provider.test.ts create mode 100644 packages/view/test/diff-view.test.tsx create mode 100644 packages/view/test/fixtures/diff-fence.md create mode 100644 packages/view/test/fixtures/sample.patch diff --git a/.tegami/feat-hunk-diff-rendering.md b/.tegami/feat-hunk-diff-rendering.md new file mode 100644 index 00000000..2934a361 --- /dev/null +++ b/.tegami/feat-hunk-diff-rendering.md @@ -0,0 +1,19 @@ +--- +packages: + "group:tooee": + type: minor +--- + +## Render diffs with Hunk + +Patches are now a first-class Tooee format. `tooee view changes.patch` (or piping `git diff` into +`tooee view`) opens a diff viewer built on Hunk's OpenTUI primitives, with stacked and split +layouts, word-level highlights and multi-file review. + +Navigation is per hunk: `j`/`k` step between hunks, `]`/`[` jump between files, `f` opens a file +picker, `s` toggles split, `w` toggles wrapping, and `h`/`l` pan wide hunks. Search, copy and +selection all work in real patch text. + +Markdown ` ```diff ` and ` ```patch ` fences render as diff blocks too, with `split`, `nolines` and +`wrap` options in the fence info string. Fences that are not real unified diffs keep falling back +to the syntax-highlighted code block. diff --git a/apps/cli/src/main.ts b/apps/cli/src/main.ts index ca4abd72..2d4c7993 100755 --- a/apps/cli/src/main.ts +++ b/apps/cli/src/main.ts @@ -12,7 +12,7 @@ import { launch as launchChoose, createStdinChooseProvider } from "@tooee/choose const [command, ...args] = process.argv.slice(2); -const RENDERERS: ContentFormat[] = ["markdown", "code", "text", "table"]; +const RENDERERS: ContentFormat[] = ["markdown", "code", "text", "table", "diff"]; interface ViewArgs { filePath?: string; @@ -64,20 +64,21 @@ const printUsage = function printUsage(): void { console.log("Usage: tooee [options]"); console.log(""); console.log("Commands:"); - console.log(" view [file] Display markdown, code, text, or tables"); + console.log(" view [file] Display markdown, code, text, diffs, or tables"); console.log(" ask [prompt] Gather multiline user input"); console.log(" choose Select items from a filterable list (stdin)"); console.log(" table [file] Display tabular data (deprecated; use view --renderer table)"); console.log(""); console.log("View options:"); - console.log(" --renderer, -r Force renderer: markdown, code, text, table"); + console.log(" --renderer, -r Force renderer: markdown, code, text, table, diff"); console.log(""); console.log("Examples:"); console.log(" tooee view README.md"); console.log(" tooee view --renderer text README.md"); console.log(" tooee view --renderer table data.csv"); + console.log(" git diff | tooee view --renderer diff"); console.log(" cat file.md | tooee view"); console.log(" cat data.csv | tooee view --renderer table"); console.log(' tooee ask "Search for:"'); diff --git a/packages/config/src/types.ts b/packages/config/src/types.ts index dcc47d39..b90664af 100644 --- a/packages/config/src/types.ts +++ b/packages/config/src/types.ts @@ -10,5 +10,7 @@ export interface TooeeConfig { wrap?: boolean; gutter?: boolean; copyOnSelect?: boolean | "primary" | "clipboard"; + /** Initial layout for diff content. Defaults to "stack" (unified). */ + diffLayout?: "split" | "stack"; }; } diff --git a/packages/e2e/view/diff.test.ts b/packages/e2e/view/diff.test.ts new file mode 100644 index 00000000..cade9a6a --- /dev/null +++ b/packages/e2e/view/diff.test.ts @@ -0,0 +1,86 @@ +import { describe, test, expect, afterEach } from "bun:test"; +import { launchTerminal } from "tuistory"; +import type { Session } from "tuistory"; +import path from "node:path"; +import { ensureTestConfigHome, resetTestConfig } from "../support/test-config.js"; +import { VIEW_FIXTURES } from "./helpers.js"; + +const REPO_ROOT = path.resolve(import.meta.dir, "../../.."); +const CLI = path.resolve(REPO_ROOT, "apps/cli/src/main.ts"); +const CONFIG_NAMESPACE = "diff-e2e"; +const TEST_CONFIG_HOME = ensureTestConfigHome(CONFIG_NAMESPACE); + +/** + * Diffs need a wide terminal: split layout falls back to stacked below 80 + * content columns, and the shared 80-column helper also wraps status values + * across lines, which makes status assertions unreadable. + */ +const launchDiff = async function launchDiff(fixture: string): Promise { + resetTestConfig(CONFIG_NAMESPACE); + const session = await launchTerminal({ + args: ["--conditions=@tooee/source", CLI, "view", path.resolve(VIEW_FIXTURES, fixture)], + cols: 120, + command: "bun", + cwd: REPO_ROOT, + env: { ...process.env, XDG_CONFIG_HOME: TEST_CONFIG_HOME }, + rows: 40, + }); + await session.waitForText("Format:", { timeout: 15_000 }); + await session.waitForText(/Mode:/u, { timeout: 5000 }); + await Bun.sleep(150); + return session; +}; + +let session: Session; + +afterEach(() => { + try { + session?.close(); + } catch { + // The session may already have exited or closed. + } +}); + +describe("diff rendering e2e", () => { + test("a .patch file opens the diff viewer", async () => { + session = await launchDiff("sample.patch"); + const text = await session.text(); + expect(text).toContain("Format: diff"); + expect(text).toContain("@@ -1,3 +1,4 @@"); + expect(text).toContain('const target = "terminal";'); + expect(text).toMatch(/Files:\s*2/u); + expect(text).toMatch(/Changes:\s*\+3 -2/u); + }, 20_000); + + test("s switches the diff to a split layout", async () => { + session = await launchDiff("sample.patch"); + await session.press("s"); + await session.waitForText(/Layout:\s*split/u, { timeout: 5000 }); + const text = await session.text(); + const changed = text.split("\n").find((line) => line.includes('const target = "terminal"')); + expect(changed).toContain('const target = "world"'); + }, 20_000); + + test("] jumps to the next file header", async () => { + session = await launchDiff("sample.patch"); + await session.press("]"); + await session.waitForText(/At:\s*docs\/notes.md/u, { timeout: 5000 }); + expect(await session.text()).toMatch(/Cursor:\s*2/u); + }, 20_000); + + test("j steps hunk by hunk", async () => { + session = await launchDiff("sample.patch"); + await session.press("j"); + await session.waitForText(/At:\s*src\/greet.ts:1/u, { timeout: 5000 }); + }, 20_000); + + test("a markdown diff fence renders as a diff block", async () => { + session = await launchDiff("diff-fence.md"); + const text = await session.text(); + expect(text).toContain("Review notes"); + expect(text).toContain("@@ -1,3 +1,4 @@"); + expect(text).toContain('const target = "terminal";'); + // The prose-style fence keeps falling back to the plain code block. + expect(text).toContain("- drop the old plan"); + }, 20_000); +}); diff --git a/packages/view/package.json b/packages/view/package.json index 15bf5afe..fb5ac72e 100644 --- a/packages/view/package.json +++ b/packages/view/package.json @@ -42,6 +42,7 @@ "dependencies": { "@tooee/commands": "workspace:*", "@tooee/config": "workspace:*", + "@tooee/diff": "workspace:*", "@tooee/layout": "workspace:*", "@tooee/marks": "workspace:*", "@tooee/overlays": "workspace:*", diff --git a/packages/view/src/components/diff-file-picker.tsx b/packages/view/src/components/diff-file-picker.tsx new file mode 100644 index 00000000..5fd98298 --- /dev/null +++ b/packages/view/src/components/diff-file-picker.tsx @@ -0,0 +1,37 @@ +import type { ReactNode } from "react"; +import { CommandPalette } from "@tooee/renderers"; +import type { HunkDiffFile } from "@tooee/diff"; + +export interface DiffFilePickerOverlayProps { + files: readonly HunkDiffFile[]; + onSelect: (fileIndex: number) => void; + close: () => void; +} + +/** + * Fuzzy file picker for a diff. Entries are indexed by position so two files + * with the same path (a rename pair, say) still resolve to distinct rows. + */ +export const DiffFilePickerOverlay = function DiffFilePickerOverlay({ + files, + onSelect, + close, +}: DiffFilePickerOverlayProps): ReactNode { + const entries = files.map((file, index) => ({ + id: String(index), + title: `${file.previousPath === undefined ? "" : `${file.previousPath} -> `}${ + file.path ?? file.id + } +${file.stats.additions} -${file.stats.deletions}`, + })); + + return ( + { + onSelect(Number(id)); + close(); + }} + /> + ); +}; diff --git a/packages/view/src/components/subviews/diff-subview.tsx b/packages/view/src/components/subviews/diff-subview.tsx new file mode 100644 index 00000000..0a483975 --- /dev/null +++ b/packages/view/src/components/subviews/diff-subview.tsx @@ -0,0 +1,229 @@ +import { createElement, useCallback, useMemo, useState } from "react"; +import { DiffView, buildDiffModel, diffRowAdapter } from "@tooee/diff"; +import type { DiffRow } from "@tooee/diff"; +import { useCommand } from "@tooee/commands"; +import { useConfig } from "@tooee/config"; +import { useOverlay } from "@tooee/overlays"; +import type { OverlayCloseReason } from "@tooee/overlays"; +import { useDocumentController } from "@tooee/shell"; +import type { DocumentRowAdapter } from "@tooee/shell"; +import type { DiffContent } from "../../types.js"; +import { useContentCommands } from "../../hooks/use-content-commands.js"; +import { ViewScreen } from "../view-screen.js"; +import { DiffFilePickerOverlay } from "../diff-file-picker.js"; +import type { SubviewProps } from "./types.js"; + +interface DiffSubviewProps extends SubviewProps { + content: DiffContent; +} + +/** Columns panned per h/l press when a hunk is wider than the viewport. */ +const HSCROLL_STEP = 4; + +const FILE_PICKER_OVERLAY = "diff-file-picker"; + +/** + * Rows are file headers and hunks, so `getText` is the row's patch text and + * `getSource` its span in the original patch: search, copy and marks all work + * in real patch coordinates rather than in rendered diff lines. + */ +const DIFF_ROW_ADAPTER: DocumentRowAdapter = diffRowAdapter; + +/** + * The built-in diff viewer: a Hunk-rendered patch inside Tooee's normal chrome. + * + * Hunk owns the drawing of each file header and hunk; the row document owns + * scrolling, the cursor, decorations and mouse routing. The cursor row maps 1:1 + * onto Hunk's `{ fileId, hunkIndex }` selection, so `j`/`k` step hunk by hunk. + */ +export const DiffSubview = function DiffSubview({ + content, + decorations, + actions, + ...screen +}: DiffSubviewProps): React.ReactNode { + const config = useConfig(); + const overlay = useOverlay(); + const textContent = content.patch; + const model = useMemo(() => buildDiffModel(content.patch), [content.patch]); + + const [layout, setLayout] = useState<"split" | "stack">( + content.layout ?? config.view?.diffLayout ?? "stack", + ); + const [wrapLines, setWrapLines] = useState(config.view?.wrap ?? false); + const [horizontalOffset, setHorizontalOffset] = useState(0); + + const { showLineNumbers } = useContentCommands({ content, textContent }); + + const document = useDocumentController({ + adapter: DIFF_ROW_ADAPTER, + // The controller projects the screen's actions onto menu entries at open time. + contextMenu: actions, + decorations, + multiSelect: true, + preserveCursorByKey: true, + rows: model.rows, + }); + + const { activeIndex } = document; + // `setCursor` rather than `selectRow`: the latter stands down while a modal + // overlay is open, which is exactly when the file picker commits its choice. + const { setCursor } = document.navigation; + + /** Index of the file-header row for `fileIndex`, or `null` when absent. */ + const fileRowIndex = useCallback( + (fileIndex: number): number | null => { + const index = model.rows.findIndex( + (row) => row.kind === "file" && row.fileIndex === fileIndex, + ); + return index === -1 ? null : index; + }, + [model.rows], + ); + + const jumpFile = useCallback( + (delta: number) => { + const current = activeIndex === null ? undefined : model.rows[activeIndex]; + const from = current?.fileIndex ?? 0; + const target = Math.min(Math.max(from + delta, 0), model.files.length - 1); + const index = fileRowIndex(target); + if (index !== null) { + setCursor(index); + } + }, + [activeIndex, fileRowIndex, model.files.length, model.rows, setCursor], + ); + + useCommand({ + handler: () => { + setLayout((value) => (value === "split" ? "stack" : "split")); + }, + hotkey: "s", + id: "diff.toggle-layout", + modes: ["cursor", "select"], + title: "Toggle split/unified diff", + }); + useCommand({ + handler: () => { + setWrapLines((value) => !value); + setHorizontalOffset(0); + }, + hotkey: "w", + id: "diff.toggle-wrap", + modes: ["cursor", "select"], + title: "Toggle diff line wrapping", + }); + useCommand({ + handler: () => { + jumpFile(1); + }, + hotkey: "]", + id: "diff.next-file", + modes: ["cursor", "select"], + title: "Next file", + }); + useCommand({ + handler: () => { + jumpFile(-1); + }, + hotkey: "[", + id: "diff.prev-file", + modes: ["cursor", "select"], + title: "Previous file", + }); + useCommand({ + handler: () => { + setHorizontalOffset((value) => Math.max(0, value - HSCROLL_STEP)); + }, + hotkey: "h", + id: "diff.scroll-left", + modes: ["cursor"], + title: "Pan diff left", + when: () => !wrapLines, + }); + useCommand({ + handler: () => { + setHorizontalOffset((value) => value + HSCROLL_STEP); + }, + hotkey: "l", + id: "diff.scroll-right", + modes: ["cursor"], + title: "Pan diff right", + when: () => !wrapLines, + }); + useCommand({ + handler: () => { + overlay.open( + FILE_PICKER_OVERLAY, + ({ close }: { close: (reason?: OverlayCloseReason) => void }) => + createElement(DiffFilePickerOverlay, { + close: () => { + close(); + }, + files: model.files, + onSelect: (fileIndex: number) => { + const index = fileRowIndex(fileIndex); + if (index !== null) { + setCursor(index); + } + }, + }), + null, + { ownCommands: true, role: "modal", surfaceMode: "insert" }, + ); + }, + hotkey: "f", + id: "diff.file-picker", + modes: ["cursor", "select"], + title: "Go to file", + }); + + const activeRow = activeIndex === null ? undefined : model.rows[activeIndex]; + const statusItems = useMemo( + () => [ + { label: "Format:", value: content.format }, + { label: "Files:", value: String(model.files.length) }, + { label: "Changes:", value: `+${model.stats.additions} -${model.stats.deletions}` }, + { label: "Layout:", value: layout }, + ...(activeRow + ? [ + { + label: "At:", + value: + activeRow.hunkIndex >= 0 + ? `${activeRow.parent.path ?? activeRow.fileId}:${activeRow.hunkIndex + 1}` + : (activeRow.parent.path ?? activeRow.fileId), + }, + ] + : []), + ], + [ + activeRow, + content.format, + layout, + model.files.length, + model.stats.additions, + model.stats.deletions, + ], + ); + + return ( + + + + ); +}; diff --git a/packages/view/src/components/subviews/index.ts b/packages/view/src/components/subviews/index.ts index b89dcb78..f4df7d6c 100644 --- a/packages/view/src/components/subviews/index.ts +++ b/packages/view/src/components/subviews/index.ts @@ -2,5 +2,6 @@ export { MarkdownSubview } from "./markdown-subview.js"; export { CodeSubview } from "./code-subview.js"; export { ImageSubview } from "./image-subview.js"; export { TableSubview } from "./table-subview.js"; +export { DiffSubview } from "./diff-subview.js"; export { CustomSubview } from "./custom-subview.js"; export type { SubviewProps } from "./types.js"; diff --git a/packages/view/src/default-provider.ts b/packages/view/src/default-provider.ts index 0e1ca9ee..030f3248 100644 --- a/packages/view/src/default-provider.ts +++ b/packages/view/src/default-provider.ts @@ -1,5 +1,6 @@ import path from "node:path"; import { parseAuto } from "@tooee/renderers"; +import { isDiffPatch } from "@tooee/diff"; import type { Content, ContentFormat, ContentProvider } from "./types.js"; export interface CreateProviderOptions { @@ -25,6 +26,11 @@ const detectFormat = function detectFormat(filePath: string): { return { format: "table" }; } + const diffExts = new Set(["diff", "patch"]); + if (diffExts.has(ext)) { + return { format: "diff" }; + } + const markdownExts = new Set(["md", "mdx", "markdown"]); if (markdownExts.has(ext)) { return { format: "markdown" }; @@ -79,6 +85,9 @@ const contentFromText = function contentFromText( case "code": { return { code: text, format: "code", language, title }; } + case "diff": { + return { format: "diff", patch: text, title }; + } case "table": { const parsed = parseAuto(text); return { columns: parsed.columns, format: "table", rows: parsed.rows, title }; @@ -111,7 +120,12 @@ export const createFileProvider = function createFileProvider( const file = Bun.file(filePath); const text = await file.text(); - const content = contentFromText(text, format, title, detected.language); + // Patches are routinely written to files with no telling extension. + const resolved = + options.renderer === undefined && detected.format === "text" && isDiffPatch(text) + ? "diff" + : format; + const content = contentFromText(text, resolved, title, detected.language); if (content.format === "markdown") { content.imageBasePath = path.dirname(path.resolve(filePath)); } @@ -126,7 +140,7 @@ export const createStdinProvider = function createStdinProvider( return { async load(): Promise { const text = await new Response(Bun.stdin.stream()).text(); - const format = options.renderer ?? "markdown"; + const format = options.renderer ?? (isDiffPatch(text) ? "diff" : "markdown"); return contentFromText(text, format, "stdin"); }, }; diff --git a/packages/view/src/index.ts b/packages/view/src/index.ts index 12c2fca3..6745b3df 100644 --- a/packages/view/src/index.ts +++ b/packages/view/src/index.ts @@ -29,6 +29,7 @@ export type { ContentRenderer, ContentRendererProps, CustomContent, + DiffContent, ImageContent, MarkdownContent, CodeContent, diff --git a/packages/view/src/types.ts b/packages/view/src/types.ts index d1fee3a1..0cc906e4 100644 --- a/packages/view/src/types.ts +++ b/packages/view/src/types.ts @@ -6,7 +6,13 @@ import type { MarkSet } from "@tooee/marks"; // === Built-in content types === -export type Content = MarkdownContent | CodeContent | TextContent | ImageContent | TableContent; +export type Content = + | MarkdownContent + | CodeContent + | TextContent + | ImageContent + | TableContent + | DiffContent; export type ContentFormat = Content["format"]; @@ -46,6 +52,14 @@ export interface TableContent extends BaseContent { rows: TableRow[]; } +export interface DiffContent extends BaseContent { + format: "diff"; + /** Unified patch text, single- or multi-file. */ + patch: string; + /** Initial layout; defaults to stacked (unified). */ + layout?: "split" | "stack"; +} + // === Custom content === export interface CustomContent { @@ -122,7 +136,7 @@ export type { ColumnDef, TableRow } from "@tooee/renderers"; // === Utilities === -const BUILTIN_FORMATS = new Set(["markdown", "code", "text", "image", "table"]); +const BUILTIN_FORMATS = new Set(["markdown", "code", "text", "image", "table", "diff"]); export const isBuiltinContent = function isBuiltinContent(content: AnyContent): content is Content { return BUILTIN_FORMATS.has(content.format); @@ -184,6 +198,9 @@ export const getTextContent = function getTextContent(content: AnyContent): stri case "code": { return content.code; } + case "diff": { + return content.patch; + } case "text": { return content.text; } diff --git a/packages/view/src/view.tsx b/packages/view/src/view.tsx index 8f3fe8ac..bcb0e750 100644 --- a/packages/view/src/view.tsx +++ b/packages/view/src/view.tsx @@ -3,6 +3,7 @@ import { useTheme } from "@tooee/themes"; import type { ActionDefinition } from "@tooee/commands"; import type { MarkSet } from "@tooee/marks"; import type { CodeBlockRenderer, MarkdownLinkHandler } from "@tooee/renderers"; +import { DIFF_CODE_BLOCK_RENDERERS } from "@tooee/diff"; import { isCustomContent } from "./types.js"; import type { ContentProvider, ContentRenderer } from "./types.js"; import { useContentLoader } from "./hooks/use-content-loader.js"; @@ -11,6 +12,7 @@ import { CodeSubview, TableSubview, ImageSubview, + DiffSubview, CustomSubview, } from "./components/subviews/index.js"; @@ -58,6 +60,13 @@ export const View = function View({ // interaction layers it generates, ordered by each set's own priority. const decorations = useMemo(() => [...providerMarks, ...userMarks], [providerMarks, userMarks]); + // ```diff / ```patch fences render as Hunk diff blocks unless the host + // registers its own renderer for those types. + const mergedCodeBlockRenderers = useMemo( + () => ({ ...DIFF_CODE_BLOCK_RENDERERS, ...codeBlockRenderers }), + [codeBlockRenderers], + ); + if ((error?.length ?? 0) > 0) { return ( @@ -95,7 +104,7 @@ export const View = function View({ return ( @@ -111,6 +120,9 @@ export const View = function View({ case "table": { return ; } + case "diff": { + return ; + } default: { return null; } diff --git a/packages/view/test/diff-provider.test.ts b/packages/view/test/diff-provider.test.ts new file mode 100644 index 00000000..c5ac0016 --- /dev/null +++ b/packages/view/test/diff-provider.test.ts @@ -0,0 +1,40 @@ +import { test, expect, describe } from "bun:test"; +import path from "node:path"; +import { createFileProvider } from "../src/default-provider.js"; +import { getTextContent } from "../src/types.js"; + +const fixture = function fixture(name: string): string { + return path.join(import.meta.dir, "fixtures", name); +}; + +describe("diff format detection", () => { + test("a .patch file loads as diff content", async () => { + const content = await createFileProvider(fixture("sample.patch")).load(); + expect(content.format).toBe("diff"); + expect(getTextContent(content)).toContain("@@ -1,3 +1,4 @@"); + expect(content.title).toBe("sample.patch"); + }); + + test("--renderer still wins over the extension", async () => { + const content = await createFileProvider(fixture("sample.patch"), { + renderer: "text", + }).load(); + expect(content.format).toBe("text"); + }); + + test("a patch with an unhelpful extension is sniffed from its content", async () => { + const file = path.join(import.meta.dir, "fixtures", "sniffed-patch.tmp"); + await Bun.write(file, await Bun.file(fixture("sample.patch")).text()); + try { + const content = await createFileProvider(file).load(); + expect(content.format).toBe("diff"); + } finally { + await Bun.file(file).delete(); + } + }); + + test("ordinary text files are unaffected", async () => { + const content = await createFileProvider(fixture("plain.txt")).load(); + expect(content.format).toBe("text"); + }); +}); diff --git a/packages/view/test/diff-view.test.tsx b/packages/view/test/diff-view.test.tsx new file mode 100644 index 00000000..7aa9b94b --- /dev/null +++ b/packages/view/test/diff-view.test.tsx @@ -0,0 +1,181 @@ +import { testRender } from "../../../test/support/test-render.ts"; +import { test, expect, afterEach, beforeEach, describe } from "bun:test"; +import { act } from "react"; +import { copied } from "../../../test/support/clipboard-mock.ts"; +import type { AnyContent, ContentProvider } from "../src/types.js"; + +const { TooeeProvider } = await import("@tooee/shell"); +const { View } = await import("../src/view.js"); + +const PATCH = `diff --git a/src/a.ts b/src/a.ts +index 1111111..2222222 100644 +--- a/src/a.ts ++++ b/src/a.ts +@@ -1,3 +1,4 @@ + const a = 1; +-const b = 2; ++const b = 22; ++const c = 3; + export { a }; +@@ -20,3 +21,3 @@ function tail() { + const x = 1; +-const y = 2; ++const y = 3; + const z = 4; +diff --git a/docs/notes.md b/docs/notes.md +index 3333333..4444444 100644 +--- a/docs/notes.md ++++ b/docs/notes.md +@@ -1,2 +1,2 @@ +-old note ++new note + trailing +`; + +const DIFF: AnyContent = { format: "diff", patch: PATCH, title: "changes.patch" }; + +const staticProvider = function staticProvider(content: AnyContent): ContentProvider { + return { format: content.format, load: () => content }; +}; + +let testSetup: Awaited>; + +beforeEach(() => { + copied.length = 0; +}); + +afterEach(() => { + testSetup?.renderer.destroy(); +}); + +const setup = async function setup(provider: ContentProvider) { + const s = await testRender( + + + , + { height: 40, kittyKeyboard: true, width: 100 }, + ); + await s.renderOnce(); + await act(async () => { + await Bun.sleep(100); + }); + await s.renderOnce(); + return s; +}; + +const press = async function press(key: string, modifiers?: { shift?: boolean }) { + await act(async () => { + testSetup.mockInput.pressKey(key, modifiers); + await Promise.resolve(); + }); + await testSetup.renderOnce(); +}; + +/** + * The status bar squeezes labels and wraps long values, so status is read back + * by pattern rather than by exact `label:value` text. + */ +const cursorIndex = function cursorIndex(frame: string): number { + const match = /Cursor:?\s*(?\d+)/u.exec(frame); + return Number(match?.groups?.index ?? -1); +}; + +const typeQuery = async function typeQuery(query: string) { + await press("/"); + for (const char of query) { + // oxlint-disable-next-line no-await-in-loop -- each key must be rendered before the next + await press(char); + } + await act(async () => { + testSetup.mockInput.pressEnter(); + await Promise.resolve(); + }); + await testSetup.renderOnce(); +}; + +describe("diff content routing", () => { + test("a .patch document opens the diff subview with diff status", async () => { + testSetup = await setup(staticProvider(DIFF)); + const frame = testSetup.captureCharFrame(); + expect(frame).toContain("changes.patch"); + expect(frame).toContain("@@ -1,3 +1,4 @@"); + expect(frame).toMatch(/Files:\s*2/u); + expect(frame).toMatch(/\+4 -3/u); + expect(frame).toMatch(/Layout:?\s*stack/u); + }); +}); + +describe("diff navigation", () => { + test("j steps hunk by hunk and the status shows file:hunk", async () => { + testSetup = await setup(staticProvider(DIFF)); + expect(cursorIndex(testSetup.captureCharFrame())).toBe(0); + + await press("j"); + expect(cursorIndex(testSetup.captureCharFrame())).toBe(1); + expect(testSetup.captureCharFrame()).toContain("At:src/a.ts:"); + await press("j"); + expect(cursorIndex(testSetup.captureCharFrame())).toBe(2); + }); + + test("] and [ jump between file headers", async () => { + testSetup = await setup(staticProvider(DIFF)); + await press("]"); + expect(cursorIndex(testSetup.captureCharFrame())).toBe(3); + + await press("["); + expect(cursorIndex(testSetup.captureCharFrame())).toBe(0); + }); + + test("f opens the file picker and jumps to the chosen file", async () => { + testSetup = await setup(staticProvider(DIFF)); + await press("f"); + expect(testSetup.captureCharFrame()).toContain("docs/notes.md"); + + for (const char of "notes") { + // oxlint-disable-next-line no-await-in-loop -- each key must be rendered before the next + await press(char); + } + await act(async () => { + testSetup.mockInput.pressEnter(); + await Promise.resolve(); + }); + await testSetup.renderOnce(); + + expect(cursorIndex(testSetup.captureCharFrame())).toBe(3); + }); + + test("s toggles the split layout", async () => { + testSetup = await setup(staticProvider(DIFF)); + await press("s"); + const frame = testSetup.captureCharFrame(); + expect(frame).toMatch(/Layout:?\s*split/u); + expect(frame.split("\n").find((line) => line.includes("const b = 22;"))).toContain( + "const b = 2;", + ); + + await press("s"); + expect(testSetup.captureCharFrame()).toMatch(/Layout:?\s*stack/u); + }); +}); + +describe("diff search and copy", () => { + test("search matches hunk patch text and moves the cursor to that hunk", async () => { + testSetup = await setup(staticProvider(DIFF)); + await typeQuery("const y"); + expect(cursorIndex(testSetup.captureCharFrame())).toBe(2); + + await typeQuery("new note"); + expect(cursorIndex(testSetup.captureCharFrame())).toBe(4); + }); + + test("copying a selected hunk yields its patch text", async () => { + testSetup = await setup(staticProvider(DIFF)); + await press("j"); + await press("v"); + await press("y"); + + expect(copied).toHaveLength(1); + expect(copied[0]).toStartWith("@@ -1,3 +1,4 @@"); + expect(copied[0]).toContain("+const c = 3;"); + }); +}); diff --git a/packages/view/test/fixtures/diff-fence.md b/packages/view/test/fixtures/diff-fence.md new file mode 100644 index 00000000..fcd2a9c4 --- /dev/null +++ b/packages/view/test/fixtures/diff-fence.md @@ -0,0 +1,25 @@ +# Review notes + +An embedded patch: + +```diff +diff --git a/src/greet.ts b/src/greet.ts +index 1111111..2222222 100644 +--- a/src/greet.ts ++++ b/src/greet.ts +@@ -1,3 +1,4 @@ + const greeting = "hello"; +-const target = "world"; ++const target = "terminal"; ++const punctuation = "!"; + export { greeting }; +``` + +And a fence that only looks like one: + +```diff +- drop the old plan ++ adopt the new plan +``` + +End of notes. diff --git a/packages/view/test/fixtures/sample.patch b/packages/view/test/fixtures/sample.patch new file mode 100644 index 00000000..162a66ab --- /dev/null +++ b/packages/view/test/fixtures/sample.patch @@ -0,0 +1,18 @@ +diff --git a/src/greet.ts b/src/greet.ts +index 1111111..2222222 100644 +--- a/src/greet.ts ++++ b/src/greet.ts +@@ -1,3 +1,4 @@ + const greeting = "hello"; +-const target = "world"; ++const target = "terminal"; ++const punctuation = "!"; + export { greeting }; +diff --git a/docs/notes.md b/docs/notes.md +index 3333333..4444444 100644 +--- a/docs/notes.md ++++ b/docs/notes.md +@@ -1,2 +1,2 @@ +-old note ++new note + trailing diff --git a/packages/view/tsconfig.json b/packages/view/tsconfig.json index fdc6b5af..a0263da2 100644 --- a/packages/view/tsconfig.json +++ b/packages/view/tsconfig.json @@ -11,6 +11,7 @@ { "path": "../marks" }, { "path": "../themes" }, { "path": "../overlays" }, + { "path": "../diff" }, { "path": "../renderers" }, { "path": "../layout" }, { "path": "../search" }, From 36d72f5fdc1388e2aed3ebcb0d2fd5fce741d6e8 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 11 Aug 2026 18:07:41 +0100 Subject: [PATCH 4/4] docs(examples): showcase markdown diff fences --- examples/diff-showcase.md | 115 ++++++++++++++++++++++++++++++++ examples/view-markdown-diffs.ts | 30 +++++++++ 2 files changed, 145 insertions(+) create mode 100644 examples/diff-showcase.md create mode 100644 examples/view-markdown-diffs.ts diff --git a/examples/diff-showcase.md b/examples/diff-showcase.md new file mode 100644 index 00000000..198bfe20 --- /dev/null +++ b/examples/diff-showcase.md @@ -0,0 +1,115 @@ +# Patch Review: Pocket Tasks + +This review follows a small task app as it gains priorities, keyboard shortcuts, and a clearer empty state. Each patch is a real unified diff rendered by Hunk inside Tooee's Markdown view. + +> Move the cursor with `j` and `k`. Use `h` and `l` to pan a wide patch. Press `t` or `T` to change the theme. + +## 1. Give every task a priority + +The first patch updates the shared model and keeps the default explicit. Word-level highlighting makes the type and function changes easy to spot. + +```diff +diff --git a/src/tasks.ts b/src/tasks.ts +index 83d42c1..a51d9a7 100644 +--- a/src/tasks.ts ++++ b/src/tasks.ts +@@ -1,5 +1,8 @@ ++export type Priority = "low" | "normal" | "high"; ++ + export interface Task { + id: string; + title: string; + done: boolean; ++ priority: Priority; + } +@@ -7,5 +10,5 @@ export interface Task { +-export function createTask(id: string, title: string): Task { +- return { id, title, done: false }; ++export function createTask(id: string, title: string, priority: Priority = "normal"): Task { ++ return { id, title, done: false, priority }; + } +``` + +## 2. Add a compact task card + +This patch requests the `split` layout. Tooee uses a stacked layout automatically when the terminal is too narrow for two readable columns. + +```diff split +diff --git a/src/task-card.tsx b/src/task-card.tsx +new file mode 100644 +index 0000000..c78b512 +--- /dev/null ++++ b/src/task-card.tsx +@@ -0,0 +1,23 @@ ++import type { Task } from "./tasks"; ++ ++const priorityLabel = { ++ high: "Urgent", ++ low: "Whenever", ++ normal: "Next", ++} as const; ++ ++export function TaskCard({ task, onToggle }: { task: Task; onToggle: () => void }) { ++ return ( ++ ++ ); ++} +``` + +## 3. Improve the empty state and shortcuts + +The `nolines` option removes line-number columns. The `wrap` option keeps long copy visible instead of clipping it. + +```patch nolines wrap +diff --git a/src/app.tsx b/src/app.tsx +index 6d46ee2..31ca37b 100644 +--- a/src/app.tsx ++++ b/src/app.tsx +@@ -8,1 +8,13 @@ export function App() { + const [tasks, setTasks] = useState([]); ++ useEffect(() => { ++ const addTask = (event: KeyboardEvent) => { ++ if (event.key === "n" && !event.metaKey && !event.ctrlKey) { ++ setComposerOpen(true); ++ } ++ }; ++ window.addEventListener("keydown", addTask); ++ return () => window.removeEventListener("keydown", addTask); ++ }, []); ++ +@@ -10,3 +22,3 @@ export function App() { + if (tasks.length === 0) { +- return

No tasks.

; ++ return ; + } +``` + +## Review summary + +| Area | Result | +| ----------- | ----------------------------------------------- | +| Data model | Priority is typed and defaults to `normal` | +| Interface | Task cards expose state without extra chrome | +| Keyboard | `n` opens the composer when no modifier is held | +| Empty state | The first action is visible and specific | + +### Ordinary diff-style notes still work + +A fence that is not a unified patch falls back to Tooee's syntax-highlighted code renderer: + +```diff +- vague empty-state copy ++ a direct prompt for the next action +``` + +--- + +_Press `q` when the review is complete._ diff --git a/examples/view-markdown-diffs.ts b/examples/view-markdown-diffs.ts new file mode 100644 index 00000000..76997158 --- /dev/null +++ b/examples/view-markdown-diffs.ts @@ -0,0 +1,30 @@ +#!/usr/bin/env bun +/** + * view-markdown-diffs.ts - Demonstrates Hunk-backed patches inside Markdown + * + * This example shows: + * - Loading a Markdown document from a separate file + * - Rendering real `diff` and `patch` fences through @tooee/diff + * - Selecting split, hidden-line-number, and wrapped layouts per fence + * - Falling back to a code block when a diff fence is not a unified patch + * + * Run: bun examples/view-markdown-diffs.ts + * Controls: j/k move, h/l pan, q quit, t/T cycle themes + */ + +import { launch } from "@tooee/view"; +import type { ContentProvider } from "@tooee/view"; + +const showcasePath = new URL("diff-showcase.md", import.meta.url); + +const contentProvider: ContentProvider = { + async load() { + return { + format: "markdown", + markdown: await Bun.file(showcasePath).text(), + title: "Pocket Tasks · Patch Review", + }; + }, +}; + +await launch({ contentProvider });