diff --git a/.changeset/constrained-jsx-file-views.md b/.changeset/constrained-jsx-file-views.md new file mode 100644 index 00000000..ac8778a3 --- /dev/null +++ b/.changeset/constrained-jsx-file-views.md @@ -0,0 +1,5 @@ +--- +"hunkdiff": minor +--- + +Experiment with fixed-height React and OpenTUI row components in extension file views, with a shared live semantic paint theme, while Hunk retains review-stream geometry, windowing, hunk navigation, inline-note placement, and symbolic fallback. Advance the extension API to version 2 for the streamlined file-view contract, with checked-in TypeScript, CSS palette, and dependency-delta demos. diff --git a/.changeset/rendered-markdown-file-view.md b/.changeset/rendered-markdown-file-view.md new file mode 100644 index 00000000..eb6b249b --- /dev/null +++ b/.changeset/rendered-markdown-file-view.md @@ -0,0 +1,5 @@ +--- +"hunkdiff": minor +--- + +Add a streamlined experimental extension file-view contract plus an optional, user-installable Markdown preview example. File views read exact source lazily, return generic host-rendered rows, and may bind rows to exact source ranges so Hunk can place inline notes. Raw diff remains the default and the all-or-raw fallback for unresolved note bindings. The View menu can apply the active presentation to every matching file in the changeset without widening extension command controls. diff --git a/README.md b/README.md index 65e4026b..ddaf6c02 100644 --- a/README.md +++ b/README.md @@ -230,7 +230,9 @@ export default function (hunk: HunkExtensionAPI) { ``` See [docs/extensions.md](docs/extensions.md) for the full API, the trust model, -and the `[extensions]` / `[extension.]` config reference. +and the `[extensions]` / `[extension.]` config reference. Installable examples +include [review triage](examples/extensions/review-triage/) and an optional +[rendered Markdown file view](examples/extensions/rendered-markdown/). ### OpenTUI component diff --git a/bun.lock b/bun.lock index 3862d5be..24a9de80 100644 --- a/bun.lock +++ b/bun.lock @@ -27,6 +27,7 @@ "@types/shell-quote": "1.7.5", "@types/ws": "^8.18.1", "lint-staged": "^16.4.0", + "marked": "17.0.1", "oxfmt": "^0.41.0", "oxlint": "^1.56.0", "react": "^19.2.4", diff --git a/docs/extension-api-evaluation.md b/docs/extension-api-evaluation.md new file mode 100644 index 00000000..a6ce6ffa --- /dev/null +++ b/docs/extension-api-evaluation.md @@ -0,0 +1,45 @@ +# Extension API field notes: Review triage + +`examples/extensions/review-triage/` is a deliberately ordinary, user-installable extension built only against `hunkdiff/extension`. It provides a session-local hunk triage board: a reviewer can open a right sidebar, navigate through public hunk summaries, mark the current hunk approved/investigate/blocked with an optional rationale, and clear decisions. Its commands are ordinary **Extensions** menu entries, while lifecycle and bus events keep the board current. + +Building it validated the API's central path: a third-party extension can compose a React sidebar, menu-reachable commands, host-owned modal dialogs, selection snapshots, lifecycle subscriptions, notifications, and a small inter-extension bus without imports from Hunk internals. The PTY integration test loads this exact directory rather than a string fixture. + +## Findings + +### Sidebar geometry and selection following are missing + +The public sidebar props expose width but not pane height, viewport bounds, scroll position, or a way to scroll an item into view. The bundled file sidebar uses host-internal `ScrollBoxRenderable` viewport events and `scrollChildIntoView`; a third-party sidebar cannot reproduce its windowing or follow-selection behavior. Review triage therefore uses a simple scrollbox and compact rows, but selected hunks can fall out of view for a large review. + +**Suggested addition:** expose read-only pane viewport geometry plus a narrow `actions.scrollItemIntoView(id)` capability (or a supported scrollbox ref contract). This would let extensions virtualize and retain selection visibility without exposing OpenTUI internals. + +### Extensions have no safe, host-managed persistence + +The triage board can only be session-local. Extension config is repository-overridable and expressly untrusted for exec-adjacent decisions; using it as writable storage would be wrong. Writing an arbitrary file from the extension is possible but creates incompatible location, lifecycle, privacy, and cleanup policies for every author. Reloading can also change a file id or hunk index, so blindly persisting the current key would misapply decisions. + +**Suggested addition:** a namespaced, user-owned storage API with explicit scopes such as session and local-user/repository, plus a changeset identity available for reconciliation. Hunk should own the file location and trust semantics. + +### Command handlers cannot navigate the review stream + +Commands receive a selection snapshot, dialogs, and sidebar open/close controls, but no `selectFile` or `selectHunk`. A "next blocked hunk" command therefore cannot navigate directly; it would need to rely on a mounted sidebar to perform navigation, which is both indirect and unreliable on a narrow terminal. Review triage avoids shipping that misleading command and makes hunk rows clickable instead. + +**Suggested addition:** place the existing guarded `selectFile` / `selectHunk` navigation methods on command context as well as sidebar actions. + +### Dialogs are intentionally simple, but triage exposes their limits + +The select/input sequence works well for a short status and single-line rationale. There is no structured option value (only displayed strings), validation hook, multiline input, or way to retain a dialog target if the session reloads; reload cancellation is safe and correct, but an extension has to design around it. + +**Suggested addition:** retain the current simple primitives, then consider labelled `{ value, label }` select choices and a multiline input primitive. Dialog requests should still cancel on reload rather than acting on stale review state. + +### The Extensions menu is command-generated, not extensible layout + +Commands make the extension visible in the Extensions menu and are sufficient for this workflow. But they cannot add a custom submenu, separator, checked state, disabled state, or an entry elsewhere in the menu bar. This is an appropriate initial boundary, but it means command titles must carry more UI work than a purpose-built menu model. + +**Suggested addition:** no change is required yet. If richer menu integration is added, model it as declarative command state rather than arbitrary extension renderables in chrome. + +## Non-gaps confirmed by the extension + +- A React component loaded from disk can render in Hunk's tree and use hooks when it imports the host-served `react` module. +- The public hunk summaries and sidebar selection/index contract are sufficient to render and drive a hunk-level board without accessing opaque diff metadata. +- `useSyncExternalStore` is a viable bridge from detached lifecycle callbacks to sidebar rendering, including while the sidebar is closed. +- Host-rendered dialogs provide appropriate attribution and modal behavior; command registrations provide menu and keyboard access through one mechanism. +- Lifecycle events and the namespaced bus are sufficient for session-local, fire-and-forget coordination, as long as the extension treats them as observers rather than persistence or request/response channels. diff --git a/docs/extension-architecture.md b/docs/extension-architecture.md index b358ea95..98899338 100644 --- a/docs/extension-architecture.md +++ b/docs/extension-architecture.md @@ -79,6 +79,21 @@ registration identity. The frozen views fill `changeType` and the public (`src/extensions/events.ts`, deriving through `src/core/hunkSummary.ts` — the same helper the agent session surface reports hunks with). +## File-view system + +File-view registrations are selected per file but remain inside the one +host-owned review stream. `src/ui/fileViews/useFileViews.ts` bounds asynchronous +extension work and retains only immutable layouts accepted by +`src/ui/fileViews/layout.ts`; width and registration identity are part of that +accepted geometry. `src/ui/fileViews/renderPlan.ts` is the shared insertion +plan for validated extension rows and host-owned inline notes. It resolves only +unambiguous exact-source bindings and returns an explicit unresolved set, so +`DiffPane` falls the complete file back to Pierre rather than guessing or +silently dropping review data. `src/ui/fileViews/geometry.ts` measures that same +plan, and `src/ui/components/panes/FileView.tsx` windows and paints it. Extension +components can paint only their fixed validated rectangles; note cards, +scrolling, hunk bounds, and navigation remain host-owned. + ## Command system Every app-level keyboard shortcut is a named command in one dispatch table diff --git a/docs/extensions.md b/docs/extensions.md index f6e59fa3..c9561c57 100644 --- a/docs/extensions.md +++ b/docs/extensions.md @@ -187,7 +187,7 @@ cannot mutate the registry mid-session. ### `hunk.apiVersion` -The API generation this Hunk speaks (currently `1`). Branch on it if you want +The API generation this Hunk speaks (currently `2`). Branch on it if you want one file to support several Hunk versions. ### `hunk.registerTheme(theme)` @@ -646,6 +646,115 @@ Snapshots must be immutable — replace the set instead of mutating it, so `useSyncExternalStore` can compare references. Storing state in a hook inside the component instead would lose it every time the pane closes and unmounts. +### `hunk.registerFileView(view)` (experimental) + +A file view is an alternate **host-rendered** presentation of one file in the +same top-to-bottom review stream. It is not a whole-file React component: Hunk +owns row measurement, scrolling/windowing, hunk navigation, and fallback to +Pierre's raw diff. A constrained, experimental +[fixed-height JSX row POC](file-view-jsx-poc.md) lets individual validated rows +paint OpenTUI content without taking over that geometry. Raw is always the +default; users select a matching view from **View** for the selected file. +Rows may bind themselves to exact old/new source ranges so Hunk can insert its +own inline review-note cards without giving the extension note contents or +geometry. + +The installable +[`examples/extensions/rendered-markdown/`](../examples/extensions/rendered-markdown/) +uses this contract for a parsed Markdown preview. It is intentionally not bundled +or loaded by default; copy the folder into `~/.config/hunk/extensions/`, install +its dependency there, and its View entry and `F8` command become available. + +```ts +import type { HunkExtensionAPI } from "hunkdiff/extension"; + +export default function (hunk: HunkExtensionAPI) { + hunk.registerFileView({ + id: "plain-markdown", + title: "Plain Markdown", + matches: (file) => file.path.endsWith(".md"), + async layout(input) { + const document = await input.readDocument("new"); + if (!document || document.length > 100_000) return null; + + const sourceLines = (document.endsWith("\n") ? document.slice(0, -1) : document).split("\n"); + const rows = sourceLines.map((text, index) => ({ + id: `line:${index + 1}`, + spans: [{ text: text || " " }], + sourceRanges: [{ side: "new" as const, range: [index + 1, index + 1] as const }], + })); + if (rows.length === 0) return null; + + return { + rows, + hunkRows: (input.file.hunks ?? []).map((hunk) => ({ + startRow: Math.max(0, (hunk.newRange?.[0] ?? 1) - 1), + endRow: Math.min(rows.length - 1, (hunk.newRange?.[1] ?? 1) - 1), + })), + }; + }, + }); +} +``` + +`layout` receives one readonly input containing `file`, `width`, `signal`, +`changes`, and `readDocument`. `input.file` is the same frozen public +`ExtensionDiffFile` sidebars receive. `input.changes` exposes typed added and +removed ranges without Pierre metadata; +complete old/new hunk ranges remain available through `input.file.hunks`. +`readDocument("old" | "new")` is lazy and cached by Hunk; it resolves exact +text or `null` when that side is absent, unavailable, too large, or fails to +load. Never treat `null` as an exception: return `null` from `layout` to keep +raw diff active. + +Layouts use an omitted tone for ordinary text and generic symbolic tones +(`muted`, `accent`, `accent-muted`, `syntax`, `added`, `removed`) plus optional +terminal attributes (`bold`, `italic`, `underline`, `strikethrough`). Hunk +resolves those primitives only while painting, so the host does not learn the +extension's content format and measurement remains theme-independent. Every +parsed hunk needs one in-bounds, inclusive `hunkRows` entry at the same array +position as `input.file.hunks`. + +A row's optional `sourceRanges` contains inclusive, one-based exact-source +bindings such as `{ side: "new", range: [12, 18] }`. Hunk reads only the bound +source sides, verifies every range is in bounds, rejects overlapping ranges on +the same side across rows, and requires each bound row to belong to exactly one +`hunkRows` extent. One source line and one bound row therefore resolve to one +presentation/hunk target. Inline notes anchor by their existing preferred-side start +line and are inserted before the bound row. Placement is **all-or-raw** per +file: if any visible note is range-less or unbound, Hunk temporarily renders +the complete raw diff rather than guessing or dropping review data. The stored +presentation selection returns when the note layer is hidden or the mapping +becomes resolvable. Draft note editing remains raw-only. + +Invalid, oversized, cancelled, or throwing layouts are isolated with one +warning per concrete extension registration and fall back to raw diff. Rapid width changes are coalesced, and Hunk never paints +geometry measured for a stale width. An experimental custom row keeps symbolic +fallback spans and declares its fixed painter +atomically as `component: { height, render }`. Painter props include the same +curated semantic `theme` palette as custom sidebars. It updates live at paint +time without entering `layout` or changing deterministic geometry. If painting +fails, the fallback spans are clipped to that same declared height rather than +changing stream geometry. Custom rows are non-focusable +paint surfaces: registered commands are their supported keyboard path. A +cooperatively delivered, handled left-button mouse-up may act and stop +propagation, while wheel, drag, and unhandled input remain host-owned. Hunk +makes no portal, renderer, focus, or input-delivery guarantee; see the linked +JSX POC for state lifetime, clipping, and error boundaries. The opt-in +[`jsx-file-view-gallery`](../examples/extensions/jsx-file-view-gallery/) runs +fixed JSX rows against checked-in TypeScript, CSS, and package dependency diffs. + +A command handler can control the selected file's view through +`ctx.fileViews.select("view-id")`, `toggle("view-id")`, and +`isActive("view-id")`; pass `null` to `select` to restore raw rendering. +Bare ids address the calling extension; use `"other-extension:view-id"` to +address another registered view. The public command API remains current-file +only. When the current file already uses an alternate presentation, **View → +Apply “…” to all matching files** applies it to every file in the complete +changeset that passes that view's `matches` function, including files hidden by +the current filter. Nonmatches retain their existing choices, and host +constraints such as an active draft may temporarily keep a selected file raw. + ### `hunk.registerCommand(command, handler)` Register a named command, optionally bound to a key. Commands are not a @@ -956,6 +1065,17 @@ to the terminal, because the TUI owns the screen. ## A complete example +The examples directory contains two user-installable folder extensions: + +- [`examples/extensions/review-triage/`](../examples/extensions/review-triage/) + is a session-local hunk triage board combining a sidebar, commands, dialogs, + lifecycle listeners, and the extension event bus. Its API evaluation and + follow-up opportunities are recorded in + [Extension API field notes](extension-api-evaluation.md). +- [`examples/extensions/rendered-markdown/`](../examples/extensions/rendered-markdown/) + parses Markdown into generic host-owned file-view rows. Its README shows how + to run it from the checkout or copy it into the global extensions directory. + Collapse lockfiles and generated output out of every review, and say how many files were hidden. diff --git a/docs/file-view-jsx-poc.md b/docs/file-view-jsx-poc.md new file mode 100644 index 00000000..953cda89 --- /dev/null +++ b/docs/file-view-jsx-poc.md @@ -0,0 +1,23 @@ +# Fixed-height JSX file-view rows (POC) + +This worktree experiments with one constrained escape hatch in the symbolic file-view contract. A validated row may include one atomic `component: { height, render }` descriptor. Hunk mounts `render` as a real React/OpenTUI component while keeping the normal review-stream layout declarative and host-owned. Like all Hunk extensions, this is a cooperative trusted-code contract rather than a sandbox. + +## Contract + +- Layout still happens before paint and supplies stable row IDs plus one inclusive row range for every parsed hunk. +- Every custom row must retain symbolic `spans`. They are rendered if the component fails, clipped to the same declared fixed height as the component painter. Symbolic-only layouts continue through the existing renderer unchanged. +- A component row declares height and renderer atomically as `component: { height, render }`. `render` must be a function, one row is limited to 256 terminal lines, and the measured height of all symbolic and component rows together is limited to 100,000 terminal lines. Existing row/span/text limits still apply. An invalid layout falls back to raw diff. +- Hunk passes only `width`, fixed `height`, `selected`, zero-based `rowIndex`, and the shared semantic `theme` palette used by extension sidebars. Theme is paint-only: switching it rerenders mounted painters without relayout or geometry changes. A per-row closure can capture arbitrary parsed or semantic data without adding an opaque payload to the host contract. +- Hunk mounts `component.render` inside a fixed `component.height`/`minHeight`/`maxHeight`, `flexShrink: 0`, overflow-hidden wrapper. No post-mount measurement feeds back into geometry. Stable IDs, hunk bounds, selection, scrolling, and row windowing remain host-owned. + +## Deliberate limits + +- **Hook state is ephemeral paint state.** It is retained while a row stays mounted across selected-hunk prop updates. A component outside the host row window is unmounted and loses that state. State also resets for a file-view switch, extension/session reload, width-driven relayout, or any replacement layout generation. Extensions that need a durable review action must use registered commands rather than component-local state. +- **Component resizing is ignored.** Content that asks for a different size is clipped to the declared row height. There is no resize callback or measurement pass. +- **Custom rows are non-focusable paint surfaces.** They do not own review keyboard input; registered commands and menus are the supported keyboard path and preserve mouse/keyboard parity. Pointer delivery is experimental and cooperative: when normal routing delivers an un-dragged left-button mouse-up, a row may act, call `preventDefault()`, and stop propagation. Wheel, drag, and every unhandled input remain host-owned for scrolling and copy selection. Hunk provides no focus, portal, renderer, or input-delivery guarantee to custom rows. +- **Inline notes require exact bindings.** Rows may expose non-overlapping `sourceRanges`; Hunk inserts note cards before the uniquely bound row using the same plan for measurement and rendering. If any visible note lacks a bound preferred-side anchor, the complete file temporarily falls back to raw diff rather than hiding or guessing at review data. Draft editing remains raw-only. +- **Error containment is row-local only.** Synchronous render/lifecycle errors caught by React's row boundary replace that component with its symbolic spans. Hunk emits one attributed warning per extension, view, file, row, layout generation, and error message through the extension notice path; a new generation may warn again. Event-handler, promise, timer, portal, renderer-global, and other asynchronous errors are outside React error-boundary containment. +- **Clipping is not a security boundary.** Host-served runtime modules may expose capabilities outside normal row composition, but file views make no promise that portals, renderer access, focus, or input hooks work. A malicious or careless extension can still affect its trusted process. The POC proves geometry preservation for cooperative components, not enforceable containment of arbitrary code. +- Cooperative custom rows cannot replace the file section, control outer flex layout, request post-mount geometry changes, or bypass the host's raw fallback and resource validation through normal descendant rendering. + +See `examples/extensions/jsx-file-view/` for the smallest opt-in hook-using example. The checked-in `examples/extensions/jsx-file-view-gallery/` adds three real-diff demos: a responsive TypeScript change atlas, exact-source CSS color swatches, and semantic `package.json` version highlights. diff --git a/examples/README.md b/examples/README.md index a7f2f226..27f879ad 100644 --- a/examples/README.md +++ b/examples/README.md @@ -18,6 +18,15 @@ Each folder tells a small review story and includes the exact command to run fro | `8-opentui-primitives` | composing Hunk's OpenTUI primitives | `bun run examples/8-opentui-primitives/primitives-demo.tsx` | | `9-agent-markup-notes` | STML markup rendered inside notes | `hunk patch examples/9-agent-markup-notes/change.patch --agent-context examples/9-agent-markup-notes/agent-context.json` | +## Installable extension examples + +- [`extensions/review-triage/`](extensions/review-triage/) adds a session-local hunk triage sidebar. +- [`extensions/rendered-markdown/`](extensions/rendered-markdown/) adds an optional parsed Markdown file presentation. +- [`extensions/jsx-file-view/`](extensions/jsx-file-view/) is the smallest hook-using fixed-row JSX proof of concept. +- [`extensions/jsx-file-view-gallery/`](extensions/jsx-file-view-gallery/) runs three constrained-JSX presentations against checked-in TypeScript, CSS, and `package.json` diffs: an impact atlas, real color swatches, and highlighted dependency versions. + +Extension examples are not bundled with Hunk. Each README shows how to try or install its folder explicitly. + ## Notes - The patch-based examples include checked-in `change.patch` files, so you can open them without creating a temporary repo. diff --git a/examples/extensions/jsx-file-view-gallery/README.md b/examples/extensions/jsx-file-view-gallery/README.md new file mode 100644 index 00000000..174298ba --- /dev/null +++ b/examples/extensions/jsx-file-view-gallery/README.md @@ -0,0 +1,60 @@ +# JSX file-view gallery + +Three opt-in presentations exercise the constrained React/OpenTUI row contract against checked-in, realistic file pairs. Run one command from the repository root, then press **F8** or choose **Extensions → Toggle JSX demo for current file**. Press F8 again to restore Pierre's raw diff. + +## 1. Change atlas + +Nested boxes, responsive meters, semantic color, and selected-hunk styling summarize a multi-hunk TypeScript refactor. It uses no source parser and works from public hunk/change metadata alone. + +```bash +bun run src/main.tsx -- diff \ + --extension ./examples/extensions/jsx-file-view-gallery \ + --mode stack \ + examples/extensions/jsx-file-view-gallery/fixtures/change-atlas/before.ts \ + examples/extensions/jsx-file-view-gallery/fixtures/change-atlas/after.ts +``` + +## 2. CSS palette delta + +The extension lazily reads both exact documents, associates changed opaque three- or six-digit hexadecimal custom properties with each real diff hunk, and paints old/new terminal color swatches inside deterministic two-row rectangles. + +```bash +bun run src/main.tsx -- diff \ + --extension ./examples/extensions/jsx-file-view-gallery \ + --mode stack \ + examples/extensions/jsx-file-view-gallery/fixtures/css-palette/before.css \ + examples/extensions/jsx-file-view-gallery/fixtures/css-palette/after.css +``` + +## 3. Dependency delta + +A conservative package-file parser highlights only the changed semantic-version segment: patch-only changes emphasize the patch number, minor upgrades emphasize the minor number, and major upgrades emphasize the full old/new strings. It retains positional bounds for every parsed hunk; invalid JSON or unavailable source falls back to raw diff. + +```bash +bun run src/main.tsx -- diff \ + --extension ./examples/extensions/jsx-file-view-gallery \ + --mode stack \ + examples/extensions/jsx-file-view-gallery/fixtures/package-dependencies/before/package.json \ + examples/extensions/jsx-file-view-gallery/fixtures/package-dependencies/after/package.json +``` + +## Mixed five-file review + +To see all three preview types retained together between ordinary raw diffs—and enough content to exercise stream scrolling—run: + +```bash +bun run ./examples/extensions/jsx-file-view-gallery/mixed-review/run.ts +``` + +Follow the short activation sequence in [`mixed-review/README.md`](./mixed-review/README.md). + +## Contract illustrated + +- Every painter stays inside a declared fixed-height row; geometry, scrolling, windowing, and hunk navigation remain host-owned. +- Every row keeps useful symbolic spans for row-local error fallback, clipped to the same fixed rectangle. +- Semantic data is captured in closures during `layout`; painters receive only bounded paint props and use Hunk's live paint-only semantic theme palette. +- The demos intentionally have no pointer handlers. Registered commands are the supported interaction path. +- Layouts return `null` when exact source is unavailable or no supported semantic row can be attributed. Mixed diffs may retain neutral summary rows for non-semantic hunks so navigation stays positional. +- Rows bind conservatively attributed exact-source ranges. Hunk renders a note inside the alternate view only when its preferred-side anchor resolves uniquely; otherwise the complete file falls back to raw diff. + +This gallery is experimental and is not loaded unless you explicitly pass or install its folder. See [`docs/file-view-jsx-poc.md`](../../../docs/file-view-jsx-poc.md) for the full contract. diff --git a/examples/extensions/jsx-file-view-gallery/fixtures/change-atlas/after.ts b/examples/extensions/jsx-file-view-gallery/fixtures/change-atlas/after.ts new file mode 100644 index 00000000..4d69a614 --- /dev/null +++ b/examples/extensions/jsx-file-view-gallery/fixtures/change-atlas/after.ts @@ -0,0 +1,50 @@ +export interface InvoiceLine { + description: string; + quantity: number; + unitPrice: number; + taxable?: boolean; +} + +export interface Invoice { + id: string; + customerId: string; + lines: InvoiceLine[]; + discountPercent?: number; +} + +export function subtotal(invoice: Invoice) { + return invoice.lines.reduce((sum, line) => sum + line.quantity * line.unitPrice, 0); +} + +export function discount(invoice: Invoice) { + const percent = Math.min(30, Math.max(0, invoice.discountPercent ?? 0)); + return subtotal(invoice) * (percent / 100); +} + +export function total(invoice: Invoice) { + const discounted = subtotal(invoice) - discount(invoice); + const tax = invoice.lines + .filter((line) => line.taxable !== false) + .reduce((sum, line) => sum + line.quantity * line.unitPrice * 0.08, 0); + return discounted + tax; +} + +export function formatInvoice(invoice: Invoice) { + const amount = total(invoice); + const customer = invoice.customerId.padStart(8, "0"); + return `${invoice.id} · customer ${customer} · $${amount.toFixed(2)}`; +} + +export function canSend(invoice: Invoice) { + return invoice.lines.length > 0 && total(invoice) > 0; +} + +export function summarizeCustomer(invoices: Invoice[]) { + const totalRevenue = invoices.reduce((sum, invoice) => sum + total(invoice), 0); + const averageInvoice = invoices.length === 0 ? 0 : totalRevenue / invoices.length; + return { + invoiceCount: invoices.length, + totalRevenue, + averageInvoice, + }; +} diff --git a/examples/extensions/jsx-file-view-gallery/fixtures/change-atlas/before.ts b/examples/extensions/jsx-file-view-gallery/fixtures/change-atlas/before.ts new file mode 100644 index 00000000..9aba4001 --- /dev/null +++ b/examples/extensions/jsx-file-view-gallery/fixtures/change-atlas/before.ts @@ -0,0 +1,41 @@ +export interface InvoiceLine { + description: string; + quantity: number; + unitPrice: number; +} + +export interface Invoice { + id: string; + customerId: string; + lines: InvoiceLine[]; + discountPercent?: number; +} + +export function subtotal(invoice: Invoice) { + return invoice.lines.reduce((sum, line) => sum + line.quantity * line.unitPrice, 0); +} + +export function discount(invoice: Invoice) { + return subtotal(invoice) * ((invoice.discountPercent ?? 0) / 100); +} + +export function total(invoice: Invoice) { + return subtotal(invoice) - discount(invoice); +} + +export function formatInvoice(invoice: Invoice) { + const amount = total(invoice); + return `${invoice.id}: $${amount.toFixed(2)}`; +} + +export function canSend(invoice: Invoice) { + return invoice.lines.length > 0 && total(invoice) > 0; +} + +export function summarizeCustomer(invoices: Invoice[]) { + const totalRevenue = invoices.reduce((sum, invoice) => sum + total(invoice), 0); + return { + invoiceCount: invoices.length, + totalRevenue, + }; +} diff --git a/examples/extensions/jsx-file-view-gallery/fixtures/css-palette/after.css b/examples/extensions/jsx-file-view-gallery/fixtures/css-palette/after.css new file mode 100644 index 00000000..791fec88 --- /dev/null +++ b/examples/extensions/jsx-file-view-gallery/fixtures/css-palette/after.css @@ -0,0 +1,44 @@ +:root { + --canvas: #090d18; + --panel: #121a2b; + --border: #34466d; + --text: #edf4ff; + --muted: #91a0bc; + --accent: #b48ead; + --success: #8fbcbb; + --danger: #bf616a; +} + +.review-shell { + min-height: 100vh; + color: var(--text); + background: var(--canvas); +} + +.review-card { + --card-highlight: #3b3150; + border: 1px solid var(--border); + border-radius: 10px; + background: var(--panel); + padding: 20px; +} + +.review-card__title { + color: var(--accent); + font-weight: 650; +} + +.review-card__meta { + color: var(--muted); + margin-top: 6px; +} + +.review-card--approved { + border-color: var(--success); + box-shadow: 0 0 0 1px var(--success); +} + +.review-card--blocked { + border-color: var(--danger); + box-shadow: 0 0 0 1px var(--danger); +} diff --git a/examples/extensions/jsx-file-view-gallery/fixtures/css-palette/before.css b/examples/extensions/jsx-file-view-gallery/fixtures/css-palette/before.css new file mode 100644 index 00000000..610c2d4c --- /dev/null +++ b/examples/extensions/jsx-file-view-gallery/fixtures/css-palette/before.css @@ -0,0 +1,42 @@ +:root { + --canvas: #0b1020; + --panel: #151d33; + --border: #2b3858; + --text: #dbe7ff; + --muted: #8290ad; + --accent: #7aa2f7; + --success: #73daca; + --danger: #f7768e; +} + +.review-shell { + min-height: 100vh; + color: var(--text); + background: var(--canvas); +} + +.review-card { + --card-highlight: #24304a; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--panel); + padding: 16px; +} + +.review-card__title { + color: var(--accent); + font-weight: 600; +} + +.review-card__meta { + color: var(--muted); + margin-top: 4px; +} + +.review-card--approved { + border-color: var(--success); +} + +.review-card--blocked { + border-color: var(--danger); +} diff --git a/examples/extensions/jsx-file-view-gallery/fixtures/package-dependencies/after/package.json b/examples/extensions/jsx-file-view-gallery/fixtures/package-dependencies/after/package.json new file mode 100644 index 00000000..e768360e --- /dev/null +++ b/examples/extensions/jsx-file-view-gallery/fixtures/package-dependencies/after/package.json @@ -0,0 +1,34 @@ +{ + "name": "review-console", + "version": "1.9.0", + "private": true, + "keywords": [ + "diff", + "review", + "terminal" + ], + "type": "module", + "scripts": { + "build": "bun build ./src/index.tsx --outdir dist", + "check": "bun run typecheck && bun test", + "dev": "bun run ./src/index.tsx", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@opentui/core": "0.4.3", + "@opentui/react": "0.4.3", + "commander": "13.1.0", + "react": "19.2.0", + "zod": "4.0.5" + }, + "devDependencies": { + "@types/bun": "1.3.1", + "@types/react": "19.2.2", + "oxfmt": "0.20.0", + "oxlint": "1.15.0", + "typescript": "5.9.2" + }, + "engines": { + "bun": ">=1.3.0" + } +} diff --git a/examples/extensions/jsx-file-view-gallery/fixtures/package-dependencies/before/package.json b/examples/extensions/jsx-file-view-gallery/fixtures/package-dependencies/before/package.json new file mode 100644 index 00000000..739d2d34 --- /dev/null +++ b/examples/extensions/jsx-file-view-gallery/fixtures/package-dependencies/before/package.json @@ -0,0 +1,34 @@ +{ + "name": "review-console", + "version": "1.8.0", + "private": true, + "keywords": [ + "diff", + "review", + "terminal" + ], + "type": "module", + "scripts": { + "build": "bun build ./src/index.tsx --outdir dist", + "check": "bun run typecheck && bun test", + "dev": "bun run ./src/index.tsx", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@opentui/core": "0.4.1", + "@opentui/react": "0.4.1", + "commander": "12.1.0", + "react": "19.1.0", + "zod": "3.24.2" + }, + "devDependencies": { + "@types/bun": "1.2.4", + "@types/react": "19.0.10", + "oxfmt": "0.16.0", + "oxlint": "0.16.0", + "typescript": "5.7.3" + }, + "engines": { + "bun": ">=1.2.0" + } +} diff --git a/examples/extensions/jsx-file-view-gallery/index.tsx b/examples/extensions/jsx-file-view-gallery/index.tsx new file mode 100644 index 00000000..5e9ee8c3 --- /dev/null +++ b/examples/extensions/jsx-file-view-gallery/index.tsx @@ -0,0 +1,603 @@ +import type { ReactNode } from "react"; +import type { + ExtensionDiffFile, + ExtensionFileChangeRange, + ExtensionFileViewInput, + ExtensionFileViewLayout, + ExtensionFileViewRow, + ExtensionFileViewRowComponentProps, + ExtensionFileViewSourceRange, + ExtensionFactory, +} from "hunkdiff/extension"; + +const SOURCE_LIMIT = 200_000; + +type RowPainter = (props: ExtensionFileViewRowComponentProps) => ReactNode; +type DependencyGroup = "dependencies" | "devDependencies"; + +interface CssToken { + name: string; + value: string; + line: number; +} + +interface DependencyToken { + group: DependencyGroup; + name: string; + value: string; + line: number; +} + +/** Count inclusive source lines represented by one public change range. */ +function changeSize(change: ExtensionFileChangeRange) { + return change.range[1] - change.range[0] + 1; +} + +/** Omit Pierre's `[0, 0]` sentinel for the nonexistent side of added/deleted files. */ +function hunkSourceRanges(hunk: { + oldRange?: readonly [number, number]; + newRange?: readonly [number, number]; +}): ExtensionFileViewSourceRange[] { + return [ + ...(hunk.oldRange && hunk.oldRange[0] >= 1 + ? [{ side: "old" as const, range: hunk.oldRange }] + : []), + ...(hunk.newRange && hunk.newRange[0] >= 1 + ? [{ side: "new" as const, range: hunk.newRange }] + : []), + ]; +} + +/** Keep painter-authored labels inside their fixed terminal rectangle. */ +function clipLabel(text: string, width: number) { + if (width <= 1) return text.slice(0, Math.max(0, width)); + return text.length <= width ? text : `${text.slice(0, width - 1)}…`; +} + +/** Draw a bounded proportional meter without changing declared row geometry. */ +export function impactMeter(value: number, total: number, width: number) { + const filled = value === 0 || total === 0 ? 0 : Math.max(1, Math.round((value / total) * width)); + return `${"█".repeat(Math.min(width, filled))}${"░".repeat(Math.max(0, width - filled))}`; +} + +/** Paint one responsive hunk-impact card from data captured during layout. */ +function impactPainter( + position: number, + header: string, + added: number, + removed: number, +): RowPainter { + return function ImpactCard({ width, height, selected, theme }) { + const meterWidth = Math.max(4, Math.min(16, Math.floor((width - 20) / 2))); + const total = Math.max(1, added + removed); + return ( + + + + + + + + + + + + ); + }; +} + +/** Build a general-purpose visual atlas with one fixed card per real diff hunk. */ +export function createChangeAtlasLayout( + input: ExtensionFileViewInput, +): ExtensionFileViewLayout | null { + const hunks = input.file.hunks ?? []; + if (hunks.length === 0) return null; + + const rows: ExtensionFileViewRow[] = hunks.map((hunk, position) => { + const changes = input.changes.filter((change) => change.hunkIndex === position); + const added = changes + .filter((change) => change.kind === "added") + .reduce((total, change) => total + changeSize(change), 0); + const removed = changes + .filter((change) => change.kind === "removed") + .reduce((total, change) => total + changeSize(change), 0); + return { + id: `impact:${position}`, + spans: [ + { + text: `Change ${position + 1}: +${added} -${removed} · ${hunk.header}`, + tone: "accent", + }, + ], + sourceRanges: hunkSourceRanges(hunk), + component: { + height: 3, + render: impactPainter(position, hunk.header, added, removed), + }, + }; + }); + + return { + rows, + hunkRows: hunks.map((_, position) => ({ startRow: position, endRow: position })), + }; +} + +/** Paint a neutral fixed-height placeholder for a hunk with no demo-specific semantic match. */ +function summaryPainter(title: string, detail: string): RowPainter { + return function SummaryCard({ width, height, selected, theme }) { + return ( + + + + + ); + }; +} + +/** Parse conservative hexadecimal CSS custom-property declarations with source lines. */ +function parseCssTokens(source: string): CssToken[] { + return source.split(/\r?\n/).flatMap((line, index) => { + const match = /^\s*(--[\w-]+)\s*:\s*(#(?:[\da-fA-F]{6}|[\da-fA-F]{3}))\s*;/.exec(line); + return match ? [{ name: match[1]!, value: match[2]!, line: index + 1 }] : []; + }); +} + +/** Test whether a source token falls inside one of a hunk's side-specific ranges. */ +function tokenIsChanged( + line: number, + kind: ExtensionFileChangeRange["kind"], + position: number, + changes: readonly ExtensionFileChangeRange[], +) { + return changes.some( + (change) => + change.hunkIndex === position && + change.kind === kind && + change.range[0] <= line && + line <= change.range[1], + ); +} + +/** Choose readable foreground text for a hexadecimal swatch. */ +function swatchForeground(color: string) { + const raw = color.slice(1); + const normalized = raw.length === 3 ? raw.replace(/(.)/g, "$1$1") : raw.slice(0, 6); + const value = Number.parseInt(normalized, 16); + if (!Number.isFinite(value)) return "white"; + const red = (value >> 16) & 255; + const green = (value >> 8) & 255; + const blue = value & 255; + return red * 0.299 + green * 0.587 + blue * 0.114 > 150 ? "black" : "white"; +} + +/** Paint one old/new terminal color swatch without claiming source geometry. */ +function palettePainter( + name: string, + oldValue: string | null, + newValue: string | null, +): RowPainter { + return function PaletteSwatch({ width, height, selected, theme }) { + const oldColor = oldValue ?? "#303030"; + const newColor = newValue ?? "#303030"; + return ( + + + + + + + + + + + + + ); + }; +} + +/** Build semantic old/new swatches for changed hexadecimal CSS variables. */ +export async function createCssPaletteLayout( + input: ExtensionFileViewInput, +): Promise { + const hunks = input.file.hunks ?? []; + const [oldSource, newSource] = await Promise.all([ + input.readDocument("old"), + input.readDocument("new"), + ]); + if ( + hunks.length === 0 || + oldSource === null || + newSource === null || + oldSource.length > SOURCE_LIMIT || + newSource.length > SOURCE_LIMIT || + input.signal.aborted + ) { + return null; + } + + const oldTokens = parseCssTokens(oldSource); + const newTokens = parseCssTokens(newSource); + const rows: ExtensionFileViewRow[] = []; + const hunkRows: { startRow: number; endRow: number }[] = []; + let semanticRowCount = 0; + + for (const [position, hunk] of hunks.entries()) { + const startRow = rows.length; + const oldChanged = oldTokens.filter((token) => + tokenIsChanged(token.line, "removed", position, input.changes), + ); + const newChanged = newTokens.filter((token) => + tokenIsChanged(token.line, "added", position, input.changes), + ); + const names = [...new Set([...oldChanged, ...newChanged].map((token) => token.name))]; + const hasAmbiguousDuplicate = names.some( + (name) => + oldChanged.filter((token) => token.name === name).length > 1 || + newChanged.filter((token) => token.name === name).length > 1, + ); + if (hasAmbiguousDuplicate) return null; + + for (const [tokenPosition, name] of names.entries()) { + const oldToken = oldChanged.find((token) => token.name === name); + const newToken = newChanged.find((token) => token.name === name); + const oldValue = oldToken?.value ?? null; + const newValue = newToken?.value ?? null; + semanticRowCount += 1; + rows.push({ + id: `palette:${position}:${tokenPosition}:${name}`, + spans: [ + { + text: `${name}: ${oldValue ?? "missing"} → ${newValue ?? "missing"}`, + tone: "syntax", + }, + ], + sourceRanges: [ + ...(oldToken + ? [{ side: "old" as const, range: [oldToken.line, oldToken.line] as const }] + : []), + ...(newToken + ? [{ side: "new" as const, range: [newToken.line, newToken.line] as const }] + : []), + ], + component: { height: 2, render: palettePainter(name, oldValue, newValue) }, + }); + } + + if (rows.length === startRow) { + rows.push({ + id: `palette:${position}:summary`, + spans: [{ text: `Hunk ${position + 1}: no changed hexadecimal variables`, tone: "muted" }], + sourceRanges: hunkSourceRanges(hunk), + component: { + height: 2, + render: summaryPainter(`PALETTE HUNK ${position + 1}`, hunk.header), + }, + }); + } + hunkRows.push({ startRow, endRow: rows.length - 1 }); + } + + return semanticRowCount > 0 ? { rows, hunkRows } : null; +} + +/** Parse dependency entries conservatively after JSON syntax has already been validated. */ +function parseDependencyTokens(source: string): DependencyToken[] | null { + try { + JSON.parse(source); + } catch { + return null; + } + + const tokens: DependencyToken[] = []; + let group: DependencyGroup | null = null; + let sectionIndent = -1; + for (const [index, line] of source.split(/\r?\n/).entries()) { + const section = /^(\s*)"(dependencies|devDependencies)"\s*:\s*\{\s*$/.exec(line); + if (section) { + group = section[2] as DependencyGroup; + sectionIndent = section[1]!.length; + continue; + } + if (group && new RegExp(`^\\s{0,${sectionIndent}}}`).test(line) && /^\s*}/.test(line)) { + group = null; + sectionIndent = -1; + continue; + } + if (!group) continue; + const entry = /^\s*"([^"]+)"\s*:\s*"([^"]+)"\s*,?\s*$/.exec(line); + if (entry) tokens.push({ group, name: entry[1]!, value: entry[2]!, line: index + 1 }); + } + return tokens; +} + +interface HighlightedVersion { + before: string; + changed: string; + after: string; +} + +/** Split old/new versions around the most meaningful changed semantic-version segment. */ +export function versionChangeHighlights(oldValue: string | null, newValue: string | null) { + const whole = (value: string | null): HighlightedVersion => ({ + before: "", + changed: value ?? "", + after: "", + }); + if (oldValue === null) { + return { old: { before: "∅", changed: "", after: "" }, new: whole(newValue) }; + } + if (newValue === null) { + return { old: whole(oldValue), new: { before: "∅", changed: "", after: "" } }; + } + + const parse = (value: string) => { + const match = /^(\D*)(\d+)\.(\d+)\.(\d+)(.*)$/.exec(value); + if (!match) return null; + return { + major: match[2]!, + minor: match[3]!, + patch: match[4]!, + majorStart: match[1]!.length, + minorStart: match[1]!.length + match[2]!.length + 1, + patchStart: match[1]!.length + match[2]!.length + match[3]!.length + 2, + prefix: match[1]!, + suffix: match[5]!, + }; + }; + const oldParsed = parse(oldValue); + const newParsed = parse(newValue); + if (!oldParsed || !newParsed) return { old: whole(oldValue), new: whole(newValue) }; + if ( + oldParsed.major !== newParsed.major || + oldParsed.prefix !== newParsed.prefix || + oldParsed.suffix !== newParsed.suffix + ) { + return { old: whole(oldValue), new: whole(newValue) }; + } + + const split = (value: string, start: number, length: number): HighlightedVersion => ({ + before: value.slice(0, start), + changed: value.slice(start, start + length), + after: value.slice(start + length), + }); + if (oldParsed.minor !== newParsed.minor) { + return { + old: split(oldValue, oldParsed.minorStart, oldParsed.minor.length), + new: split(newValue, newParsed.minorStart, newParsed.minor.length), + }; + } + if (oldParsed.patch !== newParsed.patch) { + return { + old: split(oldValue, oldParsed.patchStart, oldParsed.patch.length), + new: split(newValue, newParsed.patchStart, newParsed.patch.length), + }; + } + return { + old: { before: oldValue, changed: "", after: "" }, + new: { before: newValue, changed: "", after: "" }, + }; +} + +/** Render one version with background only behind its semantically changed segment. */ +function highlightedVersion( + parts: HighlightedVersion, + changedColor: string, + mutedColor: string, + highlightBackground: string, +): ReactNode { + return ( + <> + {parts.before} + {parts.changed ? ( + + {parts.changed} + + ) : null} + {parts.after} + + ); +} + +/** Paint old and new dependency versions with only their changed segments highlighted. */ +function dependencyPainter( + name: string, + group: DependencyGroup, + oldValue: string | null, + newValue: string | null, +): RowPainter { + const highlights = versionChangeHighlights(oldValue, newValue); + return function DependencyDelta({ width, height, selected, theme }) { + return ( + + + + + {highlightedVersion(highlights.old, theme.fileDeleted, theme.muted, theme.panelAlt)} + + {highlightedVersion(highlights.new, theme.fileNew, theme.muted, theme.panelAlt)} + + + ); + }; +} + +/** Build semantic package dependency cards while preserving every parsed hunk position. */ +export async function createDependencyLayout( + input: ExtensionFileViewInput, +): Promise { + const hunks = input.file.hunks ?? []; + const [oldSource, newSource] = await Promise.all([ + input.readDocument("old"), + input.readDocument("new"), + ]); + if ( + hunks.length === 0 || + !oldSource || + !newSource || + oldSource.length > SOURCE_LIMIT || + newSource.length > SOURCE_LIMIT || + input.signal.aborted + ) { + return null; + } + + const oldTokens = parseDependencyTokens(oldSource); + const newTokens = parseDependencyTokens(newSource); + if (!oldTokens || !newTokens) return null; + /** Refuse JSON whose duplicate keys make effective dependency values ambiguous. */ + const hasDuplicateIdentity = (tokens: readonly DependencyToken[]) => { + const identities = tokens.map((token) => `${token.group}\0${token.name}`); + return new Set(identities).size !== identities.length; + }; + if (hasDuplicateIdentity(oldTokens) || hasDuplicateIdentity(newTokens)) return null; + + const rows: ExtensionFileViewRow[] = []; + const hunkRows: { startRow: number; endRow: number }[] = []; + let semanticRowCount = 0; + for (const [position, hunk] of hunks.entries()) { + const startRow = rows.length; + const oldChanged = oldTokens.filter((token) => + tokenIsChanged(token.line, "removed", position, input.changes), + ); + const newChanged = newTokens.filter((token) => + tokenIsChanged(token.line, "added", position, input.changes), + ); + const identities = [ + ...new Set([...oldChanged, ...newChanged].map((token) => `${token.group}\0${token.name}`)), + ]; + + for (const [tokenPosition, identity] of identities.entries()) { + const [group, name] = identity.split("\0") as [DependencyGroup, string]; + const oldToken = oldChanged.find((token) => token.group === group && token.name === name); + const newToken = newChanged.find((token) => token.group === group && token.name === name); + const oldValue = oldToken?.value ?? null; + const newValue = newToken?.value ?? null; + semanticRowCount += 1; + rows.push({ + id: `dependency:${position}:${tokenPosition}:${group}:${name}`, + spans: [ + { + text: `${name}: ${oldValue ?? "missing"} → ${newValue ?? "missing"} (${group})`, + tone: newValue === null ? "removed" : oldValue === null ? "added" : "accent", + }, + ], + sourceRanges: [ + ...(oldToken + ? [{ side: "old" as const, range: [oldToken.line, oldToken.line] as const }] + : []), + ...(newToken + ? [{ side: "new" as const, range: [newToken.line, newToken.line] as const }] + : []), + ], + component: { + height: 2, + render: dependencyPainter(name, group, oldValue, newValue), + }, + }); + } + + if (rows.length === startRow) { + const title = `Package metadata hunk ${position + 1}`; + rows.push({ + id: `dependency:${position}:summary`, + spans: [{ text: `${title}: ${hunk.header}`, tone: "muted" }], + sourceRanges: hunkSourceRanges(hunk), + component: { + height: 2, + render: summaryPainter(title, hunk.header), + }, + }); + } + hunkRows.push({ startRow, endRow: rows.length - 1 }); + } + + return semanticRowCount > 0 ? { rows, hunkRows } : null; +} + +/** Resolve the one gallery view appropriate for a selected real file. */ +function galleryViewForFile(file: ExtensionDiffFile | null) { + if (!file) return null; + const paths = [file.path, file.previousPath].filter((path): path is string => Boolean(path)); + if (paths.some((path) => /(?:^|[\\/])package\.json$/i.test(path))) { + return "dependency-delta"; + } + if (file.language === "css" || paths.some((path) => /\.css$/i.test(path))) { + return "palette-delta"; + } + if ( + file.language === "typescript" || + file.language === "javascript" || + paths.some((path) => /\.[cm]?[jt]sx?$/i.test(path)) + ) { + return "change-atlas"; + } + return null; +} + +/** Register three opt-in constrained-JSX presentations behind one contextual command. */ +const register: ExtensionFactory = (hunk) => { + hunk.registerFileView({ + id: "change-atlas", + title: "JSX demo: Change atlas", + matches: (file) => galleryViewForFile(file) === "change-atlas", + layout: createChangeAtlasLayout, + }); + hunk.registerFileView({ + id: "palette-delta", + title: "JSX demo: CSS palette delta", + matches: (file) => galleryViewForFile(file) === "palette-delta", + layout: createCssPaletteLayout, + }); + hunk.registerFileView({ + id: "dependency-delta", + title: "JSX demo: Dependency delta", + matches: (file) => galleryViewForFile(file) === "dependency-delta", + layout: createDependencyLayout, + }); + + hunk.registerCommand( + { id: "toggle-jsx-gallery", title: "Toggle JSX demo for current file", key: "f8" }, + (ctx) => { + const viewId = galleryViewForFile(ctx.selection.file); + if (viewId) { + ctx.fileViews.toggle(viewId); + } else { + ctx.notify("The JSX gallery has no demo for this file type.", "info"); + } + }, + ); +}; + +export default register; diff --git a/examples/extensions/jsx-file-view-gallery/mixed-review/README.md b/examples/extensions/jsx-file-view-gallery/mixed-review/README.md new file mode 100644 index 00000000..b71352fa --- /dev/null +++ b/examples/extensions/jsx-file-view-gallery/mixed-review/README.md @@ -0,0 +1,26 @@ +# Mixed preview review + +This launches one real five-file Git working-tree review that is intentionally taller than a terminal viewport: + +- `README.md` — raw Markdown diff; +- `package.json` — dependency version-segment highlights; +- `scripts/deploy.py` — raw Python diff; +- `src/invoice.ts` — responsive change-atlas cards; +- `styles/theme.css` — exact-source color swatches. + +The launcher creates a temporary repository, commits the checked-in `before` fixtures, copies the `after` fixtures into its working tree, and starts Hunk from this checkout. The repository is removed when Hunk exits. + +From the Hunk repository root: + +```bash +bun run ./examples/extensions/jsx-file-view-gallery/mixed-review/run.ts +``` + +Raw diff is deliberately the default. To build the mixed stream: + +1. Click `package.json` in the sidebar and press **F8**. +2. Click `src/invoice.ts` and press **F8**. +3. Click `styles/theme.css` and press **F8**. +4. Click `README.md` to return to the top, then scroll through the main pane. + +Selecting files only jumps the main review stream; it does not collapse other files. The three preview selections therefore remain active together, interleaved with the two raw Pierre diffs. Use `[` and `]` while scrolling to verify that hunk navigation crosses raw and custom sections using the same host-owned geometry. diff --git a/examples/extensions/jsx-file-view-gallery/mixed-review/fixtures/after/README.md b/examples/extensions/jsx-file-view-gallery/mixed-review/fixtures/after/README.md new file mode 100644 index 00000000..cdba4d87 --- /dev/null +++ b/examples/extensions/jsx-file-view-gallery/mixed-review/fixtures/after/README.md @@ -0,0 +1,51 @@ +# Review Console + +Review Console is a terminal workspace for understanding release changes before they ship. + +## Quick start + +1. Install dependencies with `bun install`. +2. Run `bun run dev -- --watch`. +3. Open the review URL printed by the command. +4. Press `?` at any time to inspect the active command map. + +## Review workflow + +The default workflow loads a changeset, preserves its narrative file order, and opens the first hunk. +Reviewers can move between hunks, leave notes, preview semantic file views, and export a Markdown summary. + +### Keyboard controls + +- `[` selects the previous hunk. +- `]` selects the next hunk. +- `/` focuses the file filter. +- `F8` toggles the installed semantic preview for the selected file. +- `q` exits the review. + +## Configuration + +Configuration is layered from user settings and `review.config.json` in the current directory. +The repository file may define a theme, default layout, ignored paths, and extension folders. + +```json +{ + "theme": "midnight", + "layout": "stack", + "ignored": ["dist/**"], + "extensions": ["./review-extensions"] +} +``` + +## CI integration + +Use `bun run review --check --format markdown` in CI. The command exits non-zero when unresolved notes remain. +Generated reports are written to `artifacts/review.md` and include stable file and hunk links. + +## Security + +Repository extensions run only after an explicit trust decision. +Keep access tokens in the environment rather than committing them to configuration files. + +## Support + +Open an issue with the terminal type, operating system, active extensions, and a minimal patch that reproduces the problem. diff --git a/examples/extensions/jsx-file-view-gallery/mixed-review/fixtures/after/scripts/deploy.py b/examples/extensions/jsx-file-view-gallery/mixed-review/fixtures/after/scripts/deploy.py new file mode 100644 index 00000000..4bee60e5 --- /dev/null +++ b/examples/extensions/jsx-file-view-gallery/mixed-review/fixtures/after/scripts/deploy.py @@ -0,0 +1,88 @@ +from dataclasses import dataclass +from pathlib import Path +from subprocess import run +from time import sleep +from typing import Iterable + + +@dataclass(frozen=True) +class Service: + name: str + manifest: Path + environment: str + retries: int = 2 + + +def discover_services(root: Path, environment: str) -> list[Service]: + services: list[Service] = [] + for manifest in sorted(root.glob("services/*/service.toml")): + services.append( + Service( + name=manifest.parent.name, + manifest=manifest, + environment=environment, + ) + ) + return services + + +def validate_service(service: Service) -> None: + if not service.manifest.is_file(): + raise ValueError(f"missing manifest for {service.name}: {service.manifest}") + if service.environment not in {"development", "staging", "production"}: + raise ValueError(f"unsupported environment: {service.environment}") + if service.retries < 0: + raise ValueError("retries cannot be negative") + + +def build_command(service: Service, dry_run: bool) -> list[str]: + command = [ + "deployctl", + "apply", + "--service", + service.name, + "--environment", + service.environment, + "--manifest", + str(service.manifest), + "--output", + "json", + ] + if dry_run: + command.append("--dry-run") + return command + + +def deploy(service: Service, dry_run: bool = False) -> None: + validate_service(service) + for attempt in range(service.retries + 1): + completed = run(build_command(service, dry_run), check=False) + if completed.returncode == 0: + return + if attempt < service.retries: + sleep(2**attempt) + raise RuntimeError(f"deployment failed for {service.name} after retries") + + +def deploy_all(services: Iterable[Service], dry_run: bool = False) -> None: + failures: list[str] = [] + for service in services: + print(f"deploying {service.name} to {service.environment}") + try: + deploy(service, dry_run=dry_run) + except RuntimeError: + failures.append(service.name) + if failures: + raise RuntimeError(f"failed services: {', '.join(failures)}") + + +def main() -> None: + root = Path.cwd() + services = discover_services(root, environment="staging") + if not services: + raise SystemExit("no services discovered") + deploy_all(services, dry_run=False) + + +if __name__ == "__main__": + main() diff --git a/examples/extensions/jsx-file-view-gallery/mixed-review/fixtures/before/README.md b/examples/extensions/jsx-file-view-gallery/mixed-review/fixtures/before/README.md new file mode 100644 index 00000000..a591a103 --- /dev/null +++ b/examples/extensions/jsx-file-view-gallery/mixed-review/fixtures/before/README.md @@ -0,0 +1,48 @@ +# Review Console + +Review Console is a terminal workspace for inspecting release changes before they ship. + +## Quick start + +1. Install dependencies with `bun install`. +2. Run `bun run dev`. +3. Open the review URL printed by the command. + +## Review workflow + +The default workflow loads a changeset, groups files by directory, and opens the first hunk. +Reviewers can move between hunks, leave notes, and export a text summary. + +### Keyboard controls + +- `[` selects the previous hunk. +- `]` selects the next hunk. +- `/` focuses the file filter. +- `q` exits the review. + +## Configuration + +Configuration is loaded from `review.config.json` in the current directory. +The file may define a theme, a default layout, and ignored paths. + +```json +{ + "theme": "midnight", + "layout": "split", + "ignored": ["dist/**"] +} +``` + +## CI integration + +Use `bun run review --check` in CI. The command exits non-zero when unresolved notes remain. +Generated reports are written to `artifacts/review.txt`. + +## Security + +Repository configuration is treated as data and never executes code. +Keep access tokens in the environment rather than committing them to configuration files. + +## Support + +Open an issue with the terminal type, operating system, and a minimal patch that reproduces the problem. diff --git a/examples/extensions/jsx-file-view-gallery/mixed-review/fixtures/before/scripts/deploy.py b/examples/extensions/jsx-file-view-gallery/mixed-review/fixtures/before/scripts/deploy.py new file mode 100644 index 00000000..f634aec2 --- /dev/null +++ b/examples/extensions/jsx-file-view-gallery/mixed-review/fixtures/before/scripts/deploy.py @@ -0,0 +1,72 @@ +from dataclasses import dataclass +from pathlib import Path +from subprocess import run +from typing import Iterable + + +@dataclass(frozen=True) +class Service: + name: str + manifest: Path + environment: str + + +def discover_services(root: Path) -> list[Service]: + services: list[Service] = [] + for manifest in sorted(root.glob("services/*/service.toml")): + services.append( + Service( + name=manifest.parent.name, + manifest=manifest, + environment="production", + ) + ) + return services + + +def validate_service(service: Service) -> None: + if not service.manifest.exists(): + raise ValueError(f"missing manifest for {service.name}") + if service.environment not in {"staging", "production"}: + raise ValueError(f"unsupported environment: {service.environment}") + + +def build_command(service: Service, dry_run: bool) -> list[str]: + command = [ + "deployctl", + "apply", + "--service", + service.name, + "--environment", + service.environment, + "--manifest", + str(service.manifest), + ] + if dry_run: + command.append("--dry-run") + return command + + +def deploy(service: Service, dry_run: bool = False) -> None: + validate_service(service) + completed = run(build_command(service, dry_run), check=False) + if completed.returncode != 0: + raise RuntimeError(f"deployment failed for {service.name}") + + +def deploy_all(services: Iterable[Service], dry_run: bool = False) -> None: + for service in services: + print(f"deploying {service.name} to {service.environment}") + deploy(service, dry_run=dry_run) + + +def main() -> None: + root = Path.cwd() + services = discover_services(root) + if not services: + raise SystemExit("no services discovered") + deploy_all(services) + + +if __name__ == "__main__": + main() diff --git a/examples/extensions/jsx-file-view-gallery/mixed-review/run.ts b/examples/extensions/jsx-file-view-gallery/mixed-review/run.ts new file mode 100644 index 00000000..86518dd1 --- /dev/null +++ b/examples/extensions/jsx-file-view-gallery/mixed-review/run.ts @@ -0,0 +1,83 @@ +import { copyFileSync, mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { spawnSync } from "node:child_process"; + +const galleryRoot = resolve(import.meta.dir, ".."); +const repoRoot = resolve(import.meta.dir, "../../../.."); +const demoRepo = mkdtempSync(join(tmpdir(), "hunk-jsx-mixed-review-")); + +interface DemoFile { + target: string; + before: string; + after: string; +} + +const files: DemoFile[] = [ + { + target: "README.md", + before: join(import.meta.dir, "fixtures/before/README.md"), + after: join(import.meta.dir, "fixtures/after/README.md"), + }, + { + target: "package.json", + before: join(galleryRoot, "fixtures/package-dependencies/before/package.json"), + after: join(galleryRoot, "fixtures/package-dependencies/after/package.json"), + }, + { + target: "scripts/deploy.py", + before: join(import.meta.dir, "fixtures/before/scripts/deploy.py"), + after: join(import.meta.dir, "fixtures/after/scripts/deploy.py"), + }, + { + target: "src/invoice.ts", + before: join(galleryRoot, "fixtures/change-atlas/before.ts"), + after: join(galleryRoot, "fixtures/change-atlas/after.ts"), + }, + { + target: "styles/theme.css", + before: join(galleryRoot, "fixtures/css-palette/before.css"), + after: join(galleryRoot, "fixtures/css-palette/after.css"), + }, +]; + +/** Run Git setup with deterministic local identity and actionable failure output. */ +function git(...args: string[]) { + const result = spawnSync("git", args, { cwd: demoRepo, encoding: "utf8" }); + if (result.status !== 0) { + throw new Error(result.stderr.trim() || `git ${args.join(" ")} failed`); + } +} + +/** Copy one side of every fixture into the temporary working repository. */ +function installSide(side: "before" | "after") { + for (const file of files) { + const target = join(demoRepo, file.target); + mkdirSync(dirname(target), { recursive: true }); + copyFileSync(file[side], target); + } +} + +try { + installSide("before"); + git("init", "--quiet"); + git("config", "user.name", "Hunk Demo"); + git("config", "user.email", "demo@hunk.local"); + git("add", "."); + git("-c", "commit.gpgsign=false", "commit", "--quiet", "--no-verify", "-m", "demo baseline"); + installSide("after"); + + console.log("Opening a five-file working-tree review."); + console.log("Enable previews with F8 on package.json, src/invoice.ts, and styles/theme.css."); + console.log("README.md and scripts/deploy.py intentionally remain raw diffs.\n"); + + const result = spawnSync( + process.execPath, + [join(repoRoot, "src/main.tsx"), "diff", "--extension", galleryRoot, "--mode", "stack"], + { cwd: demoRepo, stdio: "inherit", env: process.env }, + ); + if (result.error) throw result.error; + process.exitCode = result.status ?? 1; +} finally { + rmSync(demoRepo, { recursive: true, force: true }); +} diff --git a/examples/extensions/jsx-file-view-gallery/package.json b/examples/extensions/jsx-file-view-gallery/package.json new file mode 100644 index 00000000..1bd8e6ae --- /dev/null +++ b/examples/extensions/jsx-file-view-gallery/package.json @@ -0,0 +1,9 @@ +{ + "name": "hunk-jsx-file-view-gallery-extension", + "private": true, + "hunk": { + "extensions": [ + "./index.tsx" + ] + } +} diff --git a/examples/extensions/jsx-file-view/README.md b/examples/extensions/jsx-file-view/README.md new file mode 100644 index 00000000..b9003212 --- /dev/null +++ b/examples/extensions/jsx-file-view/README.md @@ -0,0 +1,13 @@ +# JSX file-view POC extension + +An opt-in proof of concept for fixed-height React/OpenTUI rows in alternate file presentations. It appears only for files with at least two parsed hunks and creates two custom rows per hunk, with stable row IDs and explicit hunk bounds. + +Run it from this checkout against a multi-hunk working-tree change: + +```bash +bun run src/main.tsx -- diff --extension ./examples/extensions/jsx-file-view +``` + +Choose **Extensions → Toggle JSX hunk cards (POC)**. The row component uses a React state hook and OpenTUI `box`/`text` elements. Its registered F8 command/menu item is the supported keyboard path. A cooperatively delivered, un-dragged left-button mouse-up toggles local detail and stops propagation; wheel, drag, and unhandled input remain host-owned. The row is a non-focusable paint surface, with no portal, renderer, focus, or input-delivery guarantee. Each component is a closure over the hunk summary; Hunk passes it only bounded paint props, including a live semantic theme palette that does not participate in layout. The `spans` on every row are the host-rendered fallback, clipped to the same declared fixed height if the component fails. Hook state survives selected-hunk updates while mounted, but is intentionally lost when windowing unmounts the row or a new layout generation replaces it. + +This example is deliberately opt-in and experimental. See [`docs/file-view-jsx-poc.md`](../../../docs/file-view-jsx-poc.md) for constraints. diff --git a/examples/extensions/jsx-file-view/index.tsx b/examples/extensions/jsx-file-view/index.tsx new file mode 100644 index 00000000..ed8db463 --- /dev/null +++ b/examples/extensions/jsx-file-view/index.tsx @@ -0,0 +1,113 @@ +import { useState, type ReactNode } from "react"; +import type { + ExtensionDiffFile, + ExtensionFileViewLayout, + ExtensionFileViewRowComponentProps, + ExtensionFileViewSourceRange, + ExtensionFileViewSpan, + ExtensionFactory, +} from "hunkdiff/extension"; + +/** Build one stateful painter as a closure over hunk semantics, not host payload. */ +function hunkCard( + title: string, + detail: string, + tone: ExtensionFileViewSpan["tone"], +): (props: ExtensionFileViewRowComponentProps) => ReactNode { + return function HunkCard({ width, height, selected, rowIndex, theme }) { + const [expanded, setExpanded] = useState(false); + const marker = selected ? "▶" : " "; + return ( + { + if (event.button !== 0 || event.isDragging) return; + event.preventDefault(); + event.stopPropagation(); + setExpanded((value) => !value); + }} + > + + + + ); + }; +} + +/** Omit the `[0, 0]` range Pierre uses for an added/deleted file's nonexistent side. */ +function hunkSourceRanges(hunk: { + oldRange?: readonly [number, number]; + newRange?: readonly [number, number]; +}): ExtensionFileViewSourceRange[] { + return [ + ...(hunk.oldRange && hunk.oldRange[0] >= 1 + ? [{ side: "old" as const, range: hunk.oldRange }] + : []), + ...(hunk.newRange && hunk.newRange[0] >= 1 + ? [{ side: "new" as const, range: hunk.newRange }] + : []), + ]; +} + +/** Build the deterministic two-row-per-hunk layout used by the live TSX example. */ +export function createJsxFileViewLayout(file: ExtensionDiffFile): ExtensionFileViewLayout | null { + const hunks = file.hunks ?? []; + if (hunks.length < 2) return null; + + const rows = hunks.flatMap((item) => { + const range = item.newRange ?? item.oldRange; + const rangeLabel = range ? `lines ${range[0]}–${range[1]}` : "unknown lines"; + return [ + { + id: `hunk-${item.index}-summary`, + spans: [ + { + text: `Hunk ${item.index + 1}: ${item.header}`, + tone: "accent" as const, + }, + ], + sourceRanges: hunkSourceRanges(item), + component: { + height: 2, + render: hunkCard(`Hunk ${item.index + 1}`, `${rangeLabel} · ${item.header}`, "added"), + }, + }, + { + id: `hunk-${item.index}-detail`, + spans: [{ text: `${rangeLabel} (symbolic fallback)`, tone: "muted" as const }], + component: { + height: 2, + render: hunkCard("Changed range", rangeLabel, "accent"), + }, + }, + ]; + }); + + return { + rows, + hunkRows: hunks.map((_, position) => ({ + startRow: position * 2, + endRow: position * 2 + 1, + })), + }; +} + +/** Register an opt-in multi-hunk TSX presentation proof of concept. */ +const register: ExtensionFactory = (hunk) => { + hunk.registerFileView({ + id: "jsx-cards", + title: "JSX hunk cards (POC)", + matches: (file) => (file.hunks?.length ?? 0) >= 2, + layout: ({ file }) => createJsxFileViewLayout(file), + }); + + hunk.registerCommand( + { id: "toggle-jsx-cards", title: "Toggle JSX hunk cards (POC)", key: "f8" }, + (ctx) => ctx.fileViews.toggle("jsx-cards"), + ); +}; + +export default register; diff --git a/examples/extensions/jsx-file-view/package.json b/examples/extensions/jsx-file-view/package.json new file mode 100644 index 00000000..ff1f1010 --- /dev/null +++ b/examples/extensions/jsx-file-view/package.json @@ -0,0 +1,9 @@ +{ + "name": "hunk-jsx-file-view-poc-extension", + "private": true, + "hunk": { + "extensions": [ + "./index.tsx" + ] + } +} diff --git a/examples/extensions/rendered-markdown/README.md b/examples/extensions/rendered-markdown/README.md new file mode 100644 index 00000000..8b6b9411 --- /dev/null +++ b/examples/extensions/rendered-markdown/README.md @@ -0,0 +1,34 @@ +# Rendered Markdown extension + +An optional Markdown preview for Hunk's experimental file-view API. It parses Markdown with [Marked](https://marked.js.org/) and presents headings, inline formatting, links, lists, quotes, tables, and fenced code as host-owned symbolic rows. + +This example is **not bundled or loaded by Hunk**. Install it explicitly if you want it. + +## Try it from this checkout + +The repository's root install supplies the example's development dependency: + +```bash +bun run src/main.tsx -- diff \ + --extension ./examples/extensions/rendered-markdown \ + before.md after.md +``` + +## Install it globally + +Copy the whole folder, then install its local dependency: + +```bash +mkdir -p ~/.config/hunk/extensions +cp -R examples/extensions/rendered-markdown ~/.config/hunk/extensions/ +cd ~/.config/hunk/extensions/rendered-markdown +bun install +``` + +Hunk discovers the folder automatically on later launches. Open **View** and choose **File presentation: Rendered Markdown**, or press `F8`. The command is named `rendered-markdown.toggle-rendered-markdown` for `[keybindings]` customization. + +Raw Pierre diff remains the default and fallback. The preview reads exact text through `input.readDocument`, returns hunk row bounds in source-hunk order, binds rendered blocks to exact new-side ranges, and retains hunk navigation and selection highlighting. Hunk inserts inline note cards at uniquely bound rows; if any visible note cannot be resolved, the complete file temporarily returns to raw rendering so review data is never hidden. + +## Why it returns rows instead of an OpenTUI Markdown component + +OpenTUI includes a capable `MarkdownRenderable`, but mounting it would make the extension own opaque layout geometry. Hunk needs exact rows before mount for review-stream measurement, windowing, scrolling, hunk navigation, and fallback. This example therefore uses the same Marked parser OpenTUI uses, then translates its tokens into the file-view contract's generic tones and text attributes. diff --git a/examples/extensions/rendered-markdown/index.ts b/examples/extensions/rendered-markdown/index.ts new file mode 100644 index 00000000..bbba39ea --- /dev/null +++ b/examples/extensions/rendered-markdown/index.ts @@ -0,0 +1,451 @@ +import { Lexer, type Token, type Tokens } from "marked"; +import type { + ExtensionFactory, + ExtensionFileChangeRange, + ExtensionFileViewRow, + ExtensionFileViewSpan, +} from "hunkdiff/extension"; + +const MAX_MARKDOWN_SOURCE_LENGTH = 200_000; + +interface SourceIndex { + lineAt(offset: number): number; + range(offset: number, length: number): [number, number]; +} + +interface RenderedMarkdownRow { + spans: ExtensionFileViewSpan[]; + sourceRange: [number, number]; +} + +type SpanPresentation = Omit; +type FileViewTextAttribute = NonNullable[number]; + +/** Index source offsets once so token-to-line mapping stays linear for large documents. */ +function createSourceIndex(source: string): SourceIndex { + const lineStarts = [0]; + for (let index = 0; index < source.length; index += 1) { + if (source[index] === "\n") lineStarts.push(index + 1); + } + + const lineAt = (offset: number) => { + const target = Math.max(0, Math.min(source.length, offset)); + let low = 0; + let high = lineStarts.length; + while (low < high) { + const middle = Math.floor((low + high) / 2); + if (lineStarts[middle]! <= target) low = middle + 1; + else high = middle; + } + return Math.max(1, low); + }; + + return { + lineAt, + range(offset, length) { + return [lineAt(offset), lineAt(offset + Math.max(0, length - 1))]; + }, + }; +} + +/** Decode the entities Markdown permits in ordinary text without creating HTML. */ +function decodeMarkdownEntities(text: string) { + return text.replace(/&(?:#(\d+)|#x([\da-f]+)|([a-z]+));/gi, (entity, decimal, hex, named) => { + if (decimal || hex) { + const value = Number.parseInt(decimal ?? hex, decimal ? 10 : 16); + return Number.isInteger(value) && value >= 0 && value <= 0x10ffff + ? String.fromCodePoint(value) + : entity; + } + const entities: Record = { + amp: "&", + apos: "'", + gt: ">", + lt: "<", + quot: '"', + }; + return entities[String(named).toLowerCase()] ?? entity; + }); +} + +/** Append terminal-safe inline text while preserving explicit Markdown line breaks. */ +function appendInlineText( + lines: ExtensionFileViewSpan[][], + text: string, + presentation: SpanPresentation, +) { + const parts = decodeMarkdownEntities(text).split("\n"); + parts.forEach((part, index) => { + if (index > 0) lines.push([]); + if (part.length > 0) lines.at(-1)!.push({ text: part, ...presentation }); + }); +} + +/** Merge nested inline output into the current line without losing hard breaks. */ +function appendInlineLines(target: ExtensionFileViewSpan[][], nested: ExtensionFileViewSpan[][]) { + target.at(-1)!.push(...(nested[0] ?? [])); + for (const line of nested.slice(1)) target.push([...line]); +} + +/** Add one generic text attribute without coupling the host to Markdown semantics. */ +function withAttribute( + presentation: SpanPresentation, + attribute: FileViewTextAttribute, +): SpanPresentation { + return { + ...presentation, + attributes: [...(presentation.attributes ?? []), attribute], + }; +} + +/** Convert Marked inline tokens into generic symbolic host-rendered spans. */ +function renderInlineTokens( + tokens: readonly Token[] | undefined, + presentation: SpanPresentation = {}, +): ExtensionFileViewSpan[][] { + const lines: ExtensionFileViewSpan[][] = [[]]; + for (const token of tokens ?? []) { + switch (token.type) { + case "br": + lines.push([]); + break; + case "codespan": + appendInlineText(lines, token.text, { tone: "syntax" }); + break; + case "strong": + appendInlineLines( + lines, + renderInlineTokens(token.tokens, withAttribute(presentation, "bold")), + ); + break; + case "em": + appendInlineLines( + lines, + renderInlineTokens(token.tokens, withAttribute(presentation, "italic")), + ); + break; + case "del": + appendInlineLines( + lines, + renderInlineTokens( + token.tokens, + withAttribute({ ...presentation, tone: "muted" }, "strikethrough"), + ), + ); + break; + case "link": { + const linkPresentation = withAttribute({ ...presentation, tone: "accent" }, "underline"); + appendInlineLines(lines, renderInlineTokens(token.tokens, linkPresentation)); + if (token.href && token.href !== token.text) { + appendInlineText(lines, ` <${token.href}>`, { tone: "muted" }); + } + break; + } + case "image": + appendInlineText(lines, `▣ ${token.text || token.href}`, { + tone: "accent", + attributes: ["underline"], + }); + break; + case "checkbox": + appendInlineText(lines, token.checked ? "[x] " : "[ ] ", presentation); + break; + case "html": { + const visible = token.text.replace(/<[^>]*>/g, ""); + if (visible) appendInlineText(lines, visible, presentation); + break; + } + default: + if ("tokens" in token && Array.isArray(token.tokens) && token.tokens.length > 0) { + appendInlineLines(lines, renderInlineTokens(token.tokens, presentation)); + } else if ("text" in token && typeof token.text === "string") { + appendInlineText(lines, token.text, presentation); + } + } + } + return lines; +} + +/** Ensure every symbolic row occupies at least one visible terminal cell. */ +function nonEmptySpans( + spans: ExtensionFileViewSpan[], + presentation: SpanPresentation = {}, +): ExtensionFileViewSpan[] { + return spans.length > 0 ? spans : [{ text: " ", ...presentation }]; +} + +/** Render one parsed Markdown block without exposing an OpenTUI renderable to extensions. */ +function renderBlock( + token: Token, + sourceRange: [number, number], + width: number, +): RenderedMarkdownRow[] { + const rows = (lines: ExtensionFileViewSpan[][], fallback?: SpanPresentation) => + lines.map((spans) => ({ spans: nonEmptySpans(spans, fallback), sourceRange })); + + switch (token.type) { + case "space": + return rows([[]]); + case "heading": { + const heading = { tone: "accent" as const, attributes: ["bold" as const] }; + return rows(renderInlineTokens((token as Tokens.Heading).tokens, heading), heading); + } + case "paragraph": + case "text": + return rows(renderInlineTokens((token as Tokens.Paragraph | Tokens.Text).tokens)); + case "hr": + return rows([[{ text: "─".repeat(Math.max(1, width)), tone: "muted" }]]); + case "code": { + const code = token as Tokens.Code; + const fenced = /^\s{0,3}(?:`{3,}|~{3,})/.test(code.raw); + const codeLines = code.text.split("\n"); + const output: RenderedMarkdownRow[] = []; + if (fenced) { + output.push({ + spans: [{ text: `┌─${code.lang ? ` ${code.lang.trim()}` : ""}`, tone: "muted" }], + sourceRange: [sourceRange[0], sourceRange[0]], + }); + } + codeLines.forEach((line, index) => { + const sourceLine = Math.min(sourceRange[1], sourceRange[0] + index + (fenced ? 1 : 0)); + output.push({ + spans: [{ text: `${fenced ? "│ " : " "}${line || " "}`, tone: "syntax" }], + sourceRange: [sourceLine, sourceLine], + }); + }); + if (fenced) { + output.push({ + spans: [{ text: "└─", tone: "muted" }], + sourceRange: [sourceRange[1], sourceRange[1]], + }); + } + return output; + } + case "blockquote": { + const quote = { tone: "accent-muted" as const }; + return rows(renderInlineTokens((token as Tokens.Blockquote).tokens, quote), quote).map( + (row) => ({ + ...row, + spans: [{ text: "│ ", ...quote }, ...row.spans], + }), + ); + } + case "list": { + const list = token as Tokens.List; + const output: RenderedMarkdownRow[] = []; + list.items.forEach((item: Tokens.ListItem, itemIndex: number) => { + const itemLines = renderInlineTokens(item.tokens); + const marker = item.task + ? item.checked + ? "[x] " + : "[ ] " + : list.ordered + ? `${Number(list.start || 1) + itemIndex}. ` + : "• "; + itemLines.forEach((spans, lineIndex) => { + output.push({ + sourceRange, + spans: [ + { text: lineIndex === 0 ? marker : " ".repeat(marker.length), tone: "muted" }, + ...nonEmptySpans(spans), + ], + }); + }); + }); + return output; + } + case "table": { + const table = token as Tokens.Table; + const cellText = (cell: Tokens.TableCell) => + renderInlineTokens(cell.tokens) + .flat() + .map((span) => span.text) + .join(""); + const output: RenderedMarkdownRow[] = []; + const header = table.header.map(cellText); + output.push({ + sourceRange: [sourceRange[0], sourceRange[0]], + spans: [{ text: header.join(" │ ") }], + }); + output.push({ + sourceRange: [ + Math.min(sourceRange[1], sourceRange[0] + 1), + Math.min(sourceRange[1], sourceRange[0] + 1), + ], + spans: [ + { + text: header.map((cell) => "─".repeat(Math.max(1, cell.length))).join("─┼─"), + tone: "muted", + }, + ], + }); + table.rows.forEach((cells: Tokens.TableCell[], index: number) => { + const sourceLine = Math.min(sourceRange[1], sourceRange[0] + index + 2); + output.push({ + sourceRange: [sourceLine, sourceLine], + spans: [{ text: cells.map(cellText).join(" │ ") }], + }); + }); + return output; + } + case "html": { + const visible = (token as Tokens.HTML).text.replace(/<[^>]*>/g, "").trim(); + return rows([[{ text: visible || " " }]]); + } + case "def": + return rows([[]]); + default: + return rows( + renderInlineTokens( + "tokens" in token && Array.isArray(token.tokens) ? token.tokens : undefined, + ), + ); + } +} + +/** Parse and render Markdown blocks while retaining internal source ranges for hunk geometry. */ +function renderMarkdown(source: string, width: number): RenderedMarkdownRow[] { + const tokens = Lexer.lex(source, { gfm: true }); + const sourceIndex = createSourceIndex(source); + const rows: RenderedMarkdownRow[] = []; + let offset = 0; + for (const token of tokens) { + const range = sourceIndex.range(offset, token.raw.length); + rows.push(...renderBlock(token, range, width)); + offset += token.raw.length; + } + return rows; +} + +/** Reject ambiguous unterminated fenced blocks instead of previewing guessed structure. */ +function hasUnterminatedFence(source: string) { + let fence: { marker: string; length: number } | null = null; + for (const line of source.split("\n")) { + const match = /^\s{0,3}(`{3,}|~{3,})(.*)$/.exec(line); + if (!match) continue; + const run = match[1]!; + if (!fence) { + fence = { marker: run[0]!, length: run.length }; + continue; + } + if ( + run[0] === fence.marker && + run.length >= fence.length && + (match[2]?.trim().length ?? 0) === 0 + ) { + fence = null; + } + } + return fence !== null; +} + +/** Check whether one rendered row overlaps an added new-side source range. */ +function rowWasAdded(row: RenderedMarkdownRow, changes: readonly ExtensionFileChangeRange[]) { + return changes.some( + (change) => + change.kind === "added" && + change.range[0] <= row.sourceRange[1] && + change.range[1] >= row.sourceRange[0], + ); +} + +/** Resolve an inclusive source range to the rendered rows that best represent it. */ +function renderedBounds( + rows: readonly RenderedMarkdownRow[], + range: readonly [number, number], +): [number, number] { + const overlapping = rows.flatMap((row, index) => + row.sourceRange[0] <= range[1] && row.sourceRange[1] >= range[0] ? [index] : [], + ); + if (overlapping.length > 0) return [overlapping[0]!, overlapping.at(-1)!]; + + const nearest = rows.reduce( + (best, row, index) => { + const distance = + range[0] < row.sourceRange[0] + ? row.sourceRange[0] - range[0] + : range[0] > row.sourceRange[1] + ? range[0] - row.sourceRange[1] + : 0; + return distance < best.distance ? { distance, index } : best; + }, + { distance: Number.POSITIVE_INFINITY, index: 0 }, + ).index; + return [nearest, nearest]; +} + +/** Build a parsed, host-rendered Markdown preview from exact source text. */ +const renderedMarkdownExtension: ExtensionFactory = (hunk) => { + hunk.registerCommand( + { + id: "toggle-rendered-markdown", + title: "Toggle rendered Markdown", + key: "f8", + }, + ({ fileViews }) => fileViews.toggle("rendered-markdown"), + ); + hunk.registerFileView({ + id: "rendered-markdown", + title: "Rendered Markdown", + matches(file) { + return /\.md(?:own)?$/i.test(file.path) && !file.isBinary && !file.isTooLarge; + }, + async layout(input) { + const source = await input.readDocument("new"); + if ( + !source || + source.length > MAX_MARKDOWN_SOURCE_LENGTH || + input.signal.aborted || + hasUnterminatedFence(source) + ) { + return null; + } + + const renderedRows = renderMarkdown(source, input.width); + if (renderedRows.length === 0) return null; + let lastBoundLine = 0; + const rows: ExtensionFileViewRow[] = renderedRows.map((row, index) => { + // Wrapped visual rows from one Markdown block share a source range. Bind the first row only + // so every source line has one unambiguous host-note anchor. + const sourceRange = row.sourceRange[0] > lastBoundLine ? row.sourceRange : undefined; + if (sourceRange) lastBoundLine = sourceRange[1]; + return { + id: `rendered:${index}`, + spans: rowWasAdded(row, input.changes) + ? row.spans.map((span) => ({ ...span, tone: "added" as const })) + : row.spans, + ...(sourceRange === undefined + ? {} + : { sourceRanges: [{ side: "new" as const, range: sourceRange }] }), + }; + }); + const hunkRows = (input.file.hunks ?? []).map((hunk) => { + const changedRanges = input.changes.filter( + (change) => change.hunkIndex === hunk.index && change.kind === "added", + ); + const sourceRange: [number, number] = changedRanges.length + ? [ + Math.min(...changedRanges.map((change) => change.range[0])), + Math.max(...changedRanges.map((change) => change.range[1])), + ] + : (hunk.newRange ?? [1, 1]); + const [startRow, endRow] = renderedBounds(renderedRows, sourceRange); + return { startRow, endRow }; + }); + + // A rendered row shared by adjacent hunk extents cannot own one note-navigation target. + const unambiguousRows = rows.map((row, rowIndex) => { + const ownerCount = hunkRows.filter( + (hunkRows) => rowIndex >= hunkRows.startRow && rowIndex <= hunkRows.endRow, + ).length; + if (ownerCount === 1 || row.sourceRanges === undefined) return row; + const { sourceRanges: _ambiguous, ...unboundRow } = row; + return unboundRow; + }); + + return { rows: unambiguousRows, hunkRows }; + }, + }); +}; + +export default renderedMarkdownExtension; diff --git a/examples/extensions/rendered-markdown/package.json b/examples/extensions/rendered-markdown/package.json new file mode 100644 index 00000000..b09f364d --- /dev/null +++ b/examples/extensions/rendered-markdown/package.json @@ -0,0 +1,12 @@ +{ + "name": "hunk-rendered-markdown-extension", + "private": true, + "dependencies": { + "marked": "17.0.1" + }, + "hunk": { + "extensions": [ + "./index.ts" + ] + } +} diff --git a/examples/extensions/review-triage/README.md b/examples/extensions/review-triage/README.md new file mode 100644 index 00000000..528f0091 --- /dev/null +++ b/examples/extensions/review-triage/README.md @@ -0,0 +1,25 @@ +# Review triage extension + +A session-local hunk review board for Hunk. It records which hunks you have visited and lets you mark the selected hunk **approved**, **investigate**, or **blocked** with an optional rationale. + +Run it directly from this checkout: + +```bash +bun run src/main.tsx -- diff --extension ./examples/extensions/review-triage +``` + +Or copy the directory to your Hunk extensions directory and keep its `package.json`; its manifest makes the folder a single `review-triage` extension. + +## Use + +Open **Extensions → Toggle review triage** (`y`). The right sidebar lists each visible file's hunks; click a hunk to navigate the review stream. Use **Extensions → Mark selected hunk…** (`x`) to choose a status and enter an optional rationale. **Set review focus…** and **Clear triage decisions** are menu-only commands. + +The board intentionally keeps state only for the running Hunk session. Reloading reconciles decisions against the newly parsed hunks and drops entries that no longer match, rather than silently transferring a decision to changed code. + +## API surface exercised + +- `registerSidebarView` renders public file/hunk summaries and navigates with sidebar actions. +- `registerCommand` supplies the Extensions-menu items and user-remappable defaults. +- `dialogs.select`, `dialogs.input`, and `dialogs.confirm` implement the review decision and clear flows. +- Lifecycle handlers track changeset loads, reloads, selection, viewed hunks, Hunk notes, filters, and pending watch reloads through a `useSyncExternalStore` bridge. +- `hunk.events` publishes decisions and listens for `review-triage:open`, so another extension can reveal the board without importing its state. diff --git a/examples/extensions/review-triage/index.tsx b/examples/extensions/review-triage/index.tsx new file mode 100644 index 00000000..6fbd0d9f --- /dev/null +++ b/examples/extensions/review-triage/index.tsx @@ -0,0 +1,321 @@ +import { useMemo, useSyncExternalStore, type ReactNode } from "react"; +import type { + ExtensionChangeset, + ExtensionDiffFile, + ExtensionReviewNote, + ExtensionSidebarViewProps, + HunkExtensionAPI, +} from "hunkdiff/extension"; + +type TriageStatus = "approved" | "investigate" | "blocked"; + +interface TriageDecision { + status: TriageStatus; + rationale?: string; +} + +interface TriageSnapshot { + decisions: ReadonlyMap; + viewed: ReadonlySet; + noteCounts: ReadonlyMap; + current: { fileId: string; hunkIndex: number } | null; + focus: string; + filter: string; + reloadPending: boolean; +} + +const initialSnapshot: TriageSnapshot = { + decisions: new Map(), + viewed: new Set(), + noteCounts: new Map(), + current: null, + focus: "", + filter: "", + reloadPending: false, +}; + +let snapshot = initialSnapshot; +const listeners = new Set<() => void>(); + +/** Build the stable, session-local key for a parsed hunk. */ +function hunkKey(fileId: string, hunkIndex: number) { + return `${fileId}:${hunkIndex}`; +} + +/** Publish an immutable store snapshot so a closed sidebar never loses its state. */ +function updateSnapshot(update: (current: TriageSnapshot) => TriageSnapshot) { + const next = update(snapshot); + if (next === snapshot) { + return; + } + + snapshot = next; + for (const listener of listeners) { + listener(); + } +} + +/** Subscribe a mounted sidebar to lifecycle state gathered outside React. */ +function useTriageSnapshot() { + return useSyncExternalStore( + (listener) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + () => snapshot, + ); +} + +/** Retain decisions only for hunks still present after a load or reload. */ +function reconcileChangeset(changeset: ExtensionChangeset) { + const knownHunks = new Set( + changeset.files.flatMap((file) => + (file.hunks ?? []).map((hunk) => hunkKey(file.id, hunk.index)), + ), + ); + + updateSnapshot((current) => ({ + ...current, + decisions: new Map([...current.decisions].filter(([key]) => knownHunks.has(key))), + viewed: new Set([...current.viewed].filter((key) => knownHunks.has(key))), + noteCounts: new Map([...current.noteCounts].filter(([key]) => knownHunks.has(key))), + current: + current.current && knownHunks.has(hunkKey(current.current.fileId, current.current.hunkIndex)) + ? current.current + : null, + reloadPending: false, + })); +} + +/** Record that the settled review selection exposed one hunk to the reviewer. */ +function markViewed(file: ExtensionDiffFile, hunkIndex: number | null) { + if (hunkIndex === null) { + return; + } + + const key = hunkKey(file.id, hunkIndex); + updateSnapshot((current) => { + if (current.viewed.has(key)) { + return current; + } + return { ...current, viewed: new Set(current.viewed).add(key) }; + }); +} + +/** Add one Hunk-authored inline-note count to the matching hunk, if still visible. */ +function recordNote(note: ExtensionReviewNote) { + const key = hunkKey(note.fileId, note.hunkIndex); + updateSnapshot((current) => { + const count = current.noteCounts.get(key) ?? 0; + return { ...current, noteCounts: new Map(current.noteCounts).set(key, count + 1) }; + }); +} + +/** Store a reviewer decision made through the extension's command flow. */ +function setDecision(fileId: string, hunkIndex: number, decision: TriageDecision) { + const key = hunkKey(fileId, hunkIndex); + updateSnapshot((current) => ({ + ...current, + decisions: new Map(current.decisions).set(key, decision), + })); +} + +/** Drop all session-local decisions without touching Hunk's own review notes. */ +function clearDecisions() { + updateSnapshot((current) => ({ ...current, decisions: new Map() })); +} + +/** Render a compact, clickable hunk triage board from public sidebar props. */ +function ReviewTriageSidebar({ + files, + selectedFileId, + selectedHunkIndex, + theme, + actions, +}: ExtensionSidebarViewProps): ReactNode { + const state = useTriageSnapshot(); + const summary = useMemo(() => { + const total = files.reduce((count, file) => count + (file.hunks?.length ?? 0), 0); + const decisions = [...state.decisions.values()]; + return { + total, + approved: decisions.filter((decision) => decision.status === "approved").length, + investigate: decisions.filter((decision) => decision.status === "investigate").length, + blocked: decisions.filter((decision) => decision.status === "blocked").length, + }; + }, [files, state.decisions]); + + return ( + + + + + {state.reloadPending ? ( + + ) : null} + {state.focus ? ( + + ) : null} + {state.filter ? ( + + ) : null} + {files.flatMap((file) => { + const hunks = file.hunks ?? []; + return [ + actions.selectFile(file.id)} + />, + ...hunks.map((hunk) => { + const key = hunkKey(file.id, hunk.index); + const decision = state.decisions.get(key); + const noteCount = state.noteCounts.get(key) ?? 0; + const selected = file.id === selectedFileId && hunk.index === selectedHunkIndex; + const marker = decision + ? { approved: "✓", investigate: "!", blocked: "×" }[decision.status] + : state.viewed.has(key) + ? "·" + : "○"; + const rationale = decision?.rationale ? ` — ${decision.rationale}` : ""; + const notes = + noteCount > 0 ? ` [${noteCount} note${noteCount === 1 ? "" : "s"}]` : ""; + + return ( + actions.selectHunk(file.id, hunk.index)} + /> + ); + }), + ]; + })} + + + ); +} + +/** Register a session-local review board that drives only documented Hunk extension APIs. */ +export default function registerReviewTriage(hunk: HunkExtensionAPI) { + hunk.registerSidebarView({ + id: "triage", + title: "Review triage", + placement: "right", + component: ReviewTriageSidebar, + }); + + hunk.registerCommand({ id: "toggle", title: "Toggle review triage", key: "y" }, (ctx) => + ctx.sidebars.toggle("triage"), + ); + + hunk.registerCommand({ id: "mark", title: "Mark selected hunk…", key: "x" }, async (ctx) => { + const { file, hunkIndex } = ctx.selection; + if (!file || hunkIndex === null) { + ctx.notify("Select a hunk before triaging it", "warning"); + return; + } + + const selectedStatus = await ctx.dialogs.select({ + title: `Triage ${file.path}, hunk ${hunkIndex + 1}`, + options: ["approved", "investigate", "blocked"], + }); + if (selectedStatus === null) { + return; + } + + const rationale = await ctx.dialogs.input({ + title: `${selectedStatus}: optional rationale`, + placeholder: "Why should a reviewer care?", + }); + if (rationale === null) { + return; + } + + const decision = { + status: selectedStatus as TriageStatus, + rationale: rationale.trim() || undefined, + }; + setDecision(file.id, hunkIndex, decision); + hunk.events.emit("review-triage:decision", { + fileId: file.id, + hunkIndex, + status: decision.status, + }); + ctx.notify(`Marked hunk ${hunkIndex + 1} ${selectedStatus}`); + }); + + hunk.registerCommand({ id: "focus", title: "Set review focus…" }, async (ctx) => { + const focus = await ctx.dialogs.input({ + title: "Review focus", + placeholder: "What are you looking for in this changeset?", + initial: snapshot.focus, + }); + if (focus === null) { + return; + } + updateSnapshot((current) => ({ ...current, focus: focus.trim() })); + ctx.sidebars.open("triage"); + }); + + hunk.registerCommand({ id: "clear", title: "Clear triage decisions" }, async (ctx) => { + if ( + !(await ctx.dialogs.confirm({ + title: "Clear review triage?", + body: "This only clears this extension's session-local decisions.", + confirmLabel: "clear", + })) + ) { + return; + } + clearDecisions(); + ctx.notify("Cleared review triage decisions"); + }); + + hunk.on("changeset_loaded", ({ changeset }) => reconcileChangeset(changeset)); + hunk.on("session_reload", ({ changeset }) => reconcileChangeset(changeset)); + hunk.on("selection_changed", ({ fileId, hunkIndex }) => { + updateSnapshot((current) => ({ + ...current, + current: fileId !== null && hunkIndex !== null ? { fileId, hunkIndex } : null, + })); + }); + hunk.on("file_viewed", ({ file, hunkIndex }) => markViewed(file, hunkIndex)); + hunk.on("note_created", ({ note }) => recordNote(note)); + hunk.on("filter_changed", ({ filter }) => { + updateSnapshot((current) => ({ ...current, filter })); + }); + hunk.on("watch_reload_pending", () => { + updateSnapshot((current) => ({ ...current, reloadPending: true })); + }); + + // Lets another extension reveal this board without importing its module state. + hunk.events.on("review-triage:open", (_payload, ctx) => ctx.sidebars.open("triage")); +} diff --git a/examples/extensions/review-triage/package.json b/examples/extensions/review-triage/package.json new file mode 100644 index 00000000..e3b715fc --- /dev/null +++ b/examples/extensions/review-triage/package.json @@ -0,0 +1,9 @@ +{ + "name": "hunk-review-triage-extension", + "private": true, + "hunk": { + "extensions": [ + "./index.tsx" + ] + } +} diff --git a/package.json b/package.json index c3b64c7a..2fac6467 100644 --- a/package.json +++ b/package.json @@ -130,6 +130,7 @@ "@types/shell-quote": "1.7.5", "@types/ws": "^8.18.1", "lint-staged": "^16.4.0", + "marked": "17.0.1", "oxfmt": "^0.41.0", "oxlint": "^1.56.0", "react": "^19.2.4", diff --git a/scripts/check-pack.ts b/scripts/check-pack.ts index e5d8630c..3f8c964e 100644 --- a/scripts/check-pack.ts +++ b/scripts/check-pack.ts @@ -23,6 +23,10 @@ import { } from "hunkdiff/extension"; import type { ExtensionChangeset, + ExtensionFileViewRow, + ExtensionFileViewRowComponentProps, + ExtensionFileViewSourceRange, + ExtensionPaintTheme, ExtensionReviewSelection, ExtensionVcsAdapter, ExtensionVcsDiffInput, @@ -46,6 +50,57 @@ export default function (hunk: HunkExtensionAPI) { hunk.registerTheme(theme); hunk.registerFileLanguage(".zig", "zig"); + const renderRow = (props: ExtensionFileViewRowComponentProps) => { + const paintTheme: ExtensionPaintTheme = props.theme; + hunk.log(paintTheme.text); + return null; + }; + const sourceRange: ExtensionFileViewSourceRange = { side: "new", range: [1, 1] }; + const componentRow: ExtensionFileViewRow = { + id: "component", + spans: [{ text: "fallback" }], + sourceRanges: [sourceRange], + component: { height: 2, render: renderRow }, + }; + const invalidComponentRow: ExtensionFileViewRow = { + id: "invalid", + spans: [], + // @ts-expect-error Height and render cannot be unpaired in a component descriptor. + component: { height: 1 }, + }; + void invalidComponentRow; + const invalidToneRow: ExtensionFileViewRow = { + id: "invalid-tone", + // @ts-expect-error Ordinary text omits tone; "text" is not a semantic tone. + spans: [{ text: "invalid", tone: "text" }], + }; + void invalidToneRow; + hunk.registerFileView({ + id: "raw", + title: "A view whose extension id is raw", + matches: (file) => file.path.endsWith(".md"), + async layout(input) { + const document: string | null = await input.readDocument("new"); + const firstRange: readonly [number, number] | undefined = input.changes[0]?.range; + const firstChange = input.changes[0]; + if (firstChange) { + // @ts-expect-error File-view ranges are immutable tuples. + firstChange.range[0] = 1; + } + // @ts-expect-error The single layout input is readonly. + input.width = 1; + hunk.log(document ?? String(firstRange?.[0] ?? input.width)); + return { + rows: [componentRow], + hunkRows: (input.file.hunks ?? []).map(() => ({ startRow: 0, endRow: 0 })), + }; + }, + }); + hunk.registerCommand({ id: "raw-view", title: "Raw view" }, (ctx) => { + ctx.fileViews.select("raw"); + ctx.fileViews.select(null); + }); + const adapter: ExtensionVcsAdapter = { id: "hg", name: "Mercurial", @@ -231,6 +286,26 @@ if (pack.name !== "hunkdiff") { throw new Error(`Expected npm package name to be hunkdiff, got ${pack.name}.`); } +const extensionTypes = readFileSync( + path.join(repoRoot, "dist", "npm", "extension", "extension-api", "types.d.ts"), + "utf8", +); +if (/^\s*import\b/m.test(extensionTypes)) { + throw new Error("The public extension-api/types declaration must remain import-free."); +} +for (const removedType of [ + "ExtensionExactFileDocument", + "ExtensionFileDocuments", + "ExtensionFileViewHunkBounds", + "ExtensionFileViewLayoutContext", + "ExtensionFileViewTextAttribute", + "ExtensionFileViewTone", +]) { + if (extensionTypes.includes(removedType)) { + throw new Error(`Removed file-view helper type was emitted: ${removedType}`); + } +} + // The allowlist above proves the published extension surface contains only what // it should. This proves it is actually *usable*: a consumer compiling against // the declarations, under both the strict Node ESM resolution and the permissive diff --git a/scripts/jsx-file-view-gallery.test.ts b/scripts/jsx-file-view-gallery.test.ts new file mode 100644 index 00000000..71bc0724 --- /dev/null +++ b/scripts/jsx-file-view-gallery.test.ts @@ -0,0 +1,234 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, test } from "bun:test"; +import type { + ExtensionCommand, + ExtensionCommandHandler, + ExtensionFileView, + HunkExtensionAPI, +} from "../src/extension-api/types"; +import { createTestDiffFile, createTestSourceFetcher } from "../test/helpers/diff-helpers"; +import galleryExtension, { + createChangeAtlasLayout, + createCssPaletteLayout, + createDependencyLayout, + impactMeter, + versionChangeHighlights, +} from "../examples/extensions/jsx-file-view-gallery"; +import { createFileViewInput, fileViewHunkCount } from "../src/ui/fileViews/host"; +import { validateFileViewLayout } from "../src/ui/fileViews/layout"; + +const galleryRoot = join(import.meta.dir, "../examples/extensions/jsx-file-view-gallery"); + +/** Build a real parsed diff input backed by one checked-in gallery fixture pair. */ +function fixtureInput(fixture: string, beforePath: string, afterPath: string, displayPath: string) { + const before = readFileSync(join(galleryRoot, "fixtures", fixture, beforePath), "utf8"); + const after = readFileSync(join(galleryRoot, "fixtures", fixture, afterPath), "utf8"); + const sourceFetcher = createTestSourceFetcher((side) => (side === "old" ? before : after)); + const file = createTestDiffFile({ + after, + before, + context: 3, + id: fixture, + path: displayPath, + sourceFetcher, + }); + return { + file, + input: createFileViewInput(file, 100, new AbortController().signal), + sourceFetcher, + }; +} + +/** Build an exact-source public input from inline text for fallback-policy coverage. */ +function sourceInput(before: string, after: string, path: string) { + const sourceFetcher = createTestSourceFetcher((side) => (side === "old" ? before : after)); + const file = createTestDiffFile({ + after, + before, + context: 3, + id: `inline:${path}`, + path, + sourceFetcher, + }); + return createFileViewInput(file, 100, new AbortController().signal); +} + +/** Capture the public registrations made by the gallery extension. */ +function registerGallery() { + const views: ExtensionFileView[] = []; + let command: ExtensionCommand | undefined; + let handler: ExtensionCommandHandler | undefined; + galleryExtension({ + registerFileView(view: ExtensionFileView) { + views.push(view); + }, + registerCommand(candidate: ExtensionCommand, candidateHandler: ExtensionCommandHandler) { + command = candidate; + handler = candidateHandler; + }, + } as HunkExtensionAPI); + return { views, command: command!, handler: handler! }; +} + +/** Assert one demo retains valid host-owned hunk geometry and fixed painters. */ +function expectValidDemoLayout( + layout: Awaited>, + hunkCount: number, +) { + expect(layout).not.toBeNull(); + if (!layout) return; + expect(layout.hunkRows).toHaveLength(hunkCount); + expect(layout.rows.every((row) => row.spans.length > 0 && row.component)).toBe(true); + expect(validateFileViewLayout(layout, hunkCount, 100).valid).toBe(true); +} + +describe("JSX file-view gallery", () => { + test("renders a responsive impact atlas for a real three-hunk TypeScript refactor", () => { + const { file, input } = fixtureInput("change-atlas", "before.ts", "after.ts", "invoice.ts"); + const layout = createChangeAtlasLayout(input); + + expect(fileViewHunkCount(file)).toBe(3); + expectValidDemoLayout(layout, 3); + expect(layout?.rows.map((row) => row.component?.height)).toEqual([3, 3, 3]); + expect(impactMeter(0, 4, 6)).toBe("░░░░░░"); + expect(layout?.rows.map((row) => row.spans[0]?.text)).toEqual([ + expect.stringContaining("Change 1:"), + expect.stringContaining("Change 2:"), + expect.stringContaining("Change 3:"), + ]); + }); + + test("omits nonexistent source sides for added and deleted files", () => { + for (const [before, after, expectedSide] of [ + ["", "export const added = true;\n", "new"], + ["export const removed = true;\n", "", "old"], + ] as const) { + const input = sourceInput(before, after, expectedSide === "new" ? "added.ts" : "deleted.ts"); + const layout = createChangeAtlasLayout(input); + const hunkCount = input.file.hunks?.length ?? 0; + + expectValidDemoLayout(layout, hunkCount); + expect( + layout?.rows.flatMap((row) => row.sourceRanges ?? []).map((range) => range.side), + ).toEqual([expectedSide]); + } + }); + + test("renders semantic old/new swatches from exact CSS documents", async () => { + const { file, input, sourceFetcher } = fixtureInput( + "css-palette", + "before.css", + "after.css", + "theme.css", + ); + const layout = await createCssPaletteLayout(input); + + expect(fileViewHunkCount(file)).toBe(2); + expectValidDemoLayout(layout, 2); + expect(layout?.rows.map((row) => row.spans[0]?.text).join("\n")).toContain( + "--accent: #7aa2f7 → #b48ead", + ); + expect(layout?.rows.map((row) => row.spans[0]?.text).join("\n")).toContain( + "--card-highlight: #24304a → #3b3150", + ); + expect(sourceFetcher.calls).toEqual(["old", "new"]); + }); + + test("highlights only the meaningful changed version segment", () => { + expect(versionChangeHighlights("1.2.3", "1.2.4")).toEqual({ + old: { before: "1.2.", changed: "3", after: "" }, + new: { before: "1.2.", changed: "4", after: "" }, + }); + expect(versionChangeHighlights("1.2.9", "1.3.0")).toEqual({ + old: { before: "1.", changed: "2", after: ".9" }, + new: { before: "1.", changed: "3", after: ".0" }, + }); + expect(versionChangeHighlights("1.9.4", "2.1.0")).toEqual({ + old: { before: "", changed: "1.9.4", after: "" }, + new: { before: "", changed: "2.1.0", after: "" }, + }); + expect(versionChangeHighlights("^1.2.3", "~1.2.4")).toEqual({ + old: { before: "", changed: "^1.2.3", after: "" }, + new: { before: "", changed: "~1.2.4", after: "" }, + }); + expect(versionChangeHighlights("1.2.3-beta.1", "1.2.4-beta.2")).toEqual({ + old: { before: "", changed: "1.2.3-beta.1", after: "" }, + new: { before: "", changed: "1.2.4-beta.2", after: "" }, + }); + }); + + test("renders highlighted versions from a real multi-hunk package update", async () => { + const { file, input } = fixtureInput( + "package-dependencies", + "before/package.json", + "after/package.json", + "package.json", + ); + const layout = await createDependencyLayout(input); + const fallback = layout?.rows.map((row) => row.spans[0]?.text).join("\n"); + + expect(fileViewHunkCount(file)).toBe(2); + expectValidDemoLayout(layout, 2); + expect(fallback).toContain("react: 19.1.0 → 19.2.0 (dependencies)"); + expect(fallback).toContain("typescript: 5.7.3 → 5.9.2 (devDependencies)"); + }); + + test("falls back when conservative semantic parsing recognizes no rows", async () => { + const unsupportedCss = sourceInput( + ":root {\n --accent: #12345;\n}\n", + ":root {\n --accent: #1234567;\n}\n", + "theme.css", + ); + const alphaCss = sourceInput( + ":root {\n --accent: #1234;\n}\n", + ":root {\n --accent: #12345678;\n}\n", + "alpha.css", + ); + const duplicateCss = sourceInput( + ".a {\n --accent: #111111;\n}\n.b {\n --accent: #222222;\n}\n", + ".a {\n --accent: #333333;\n}\n.b {\n --accent: #444444;\n}\n", + "duplicate.css", + ); + const emptyOldCss = sourceInput("", ":root {\n --accent: #123456;\n}\n", "new.css"); + const metadataOnlyPackage = sourceInput( + '{\n "name": "demo",\n "scripts": { "test": "bun test" }\n}\n', + '{\n "name": "demo",\n "scripts": { "test": "bun test --watch" }\n}\n', + "package.json", + ); + const duplicateDependency = sourceInput( + '{\n "dependencies": {\n "x": "1.0.0",\n "x": "2.0.0"\n }\n}\n', + '{\n "dependencies": {\n "x": "1.0.1",\n "x": "2.0.1"\n }\n}\n', + "package.json", + ); + + expect(await createCssPaletteLayout(unsupportedCss)).toBeNull(); + expect(await createCssPaletteLayout(alphaCss)).toBeNull(); + expect(await createCssPaletteLayout(duplicateCss)).toBeNull(); + expect(await createCssPaletteLayout(emptyOldCss)).not.toBeNull(); + expect(await createDependencyLayout(metadataOnlyPackage)).toBeNull(); + expect(await createDependencyLayout(duplicateDependency)).toBeNull(); + }); + + test("registers one contextual F8 command for precise file-specific views", () => { + const { views, command, handler } = registerGallery(); + expect(views.map((view) => view.id)).toEqual([ + "change-atlas", + "palette-delta", + "dependency-delta", + ]); + expect(command).toMatchObject({ id: "toggle-jsx-gallery", key: "f8" }); + expect(views[0]?.matches({ path: "after.ts", previousPath: "before.ts" } as never)).toBe(true); + expect(views[1]?.matches({ path: "theme.css.map" } as never)).toBe(false); + expect(views[2]?.matches({ path: "notes/package.json.md" } as never)).toBe(false); + + const toggled: string[] = []; + for (const path of ["src/after.ts", "theme.css", "fixtures/package.json"]) { + handler({ + fileViews: { toggle: (viewId: string) => toggled.push(viewId) }, + selection: { file: { path } }, + } as never); + } + expect(toggled).toEqual(["change-atlas", "palette-delta", "dependency-delta"]); + }); +}); diff --git a/scripts/rendered-markdown-extension.test.ts b/scripts/rendered-markdown-extension.test.ts new file mode 100644 index 00000000..5bec14df --- /dev/null +++ b/scripts/rendered-markdown-extension.test.ts @@ -0,0 +1,153 @@ +import { describe, expect, test } from "bun:test"; +import type { + ExtensionCommand, + ExtensionCommandHandler, + ExtensionFileView, + HunkExtensionAPI, +} from "../src/extension-api/types"; +import renderedMarkdownExtension from "../examples/extensions/rendered-markdown"; + +function registerMarkdownTestView() { + let view: ExtensionFileView | undefined; + let command: ExtensionCommand | undefined; + let commandHandler: ExtensionCommandHandler | undefined; + renderedMarkdownExtension({ + registerCommand(candidate: ExtensionCommand, handler: ExtensionCommandHandler) { + command = candidate; + commandHandler = handler; + }, + registerFileView(candidate: ExtensionFileView) { + view = candidate; + }, + } as HunkExtensionAPI); + return { view: view!, command: command!, commandHandler: commandHandler! }; +} + +describe("rendered Markdown example extension", () => { + test("uses only the public contract and renders Markdown syntax into symbolic rows", async () => { + const { view, command, commandHandler } = registerMarkdownTestView(); + expect(command).toMatchObject({ + id: "toggle-rendered-markdown", + key: "f8", + }); + const toggled: string[] = []; + commandHandler({ + fileViews: { toggle: (viewId: string) => toggled.push(viewId) }, + } as never); + expect(toggled).toEqual(["rendered-markdown"]); + expect( + view.matches({ + path: "README.md", + isBinary: false, + isTooLarge: false, + } as never), + ).toBe(true); + + const layout = await view.layout({ + file: { + id: "readme", + path: "README.md", + patch: "", + stats: { additions: 1, deletions: 0 }, + metadata: {}, + agent: null, + hunks: [{ index: 0, header: "@@", newRange: [2, 2] }], + }, + width: 80, + signal: new AbortController().signal, + changes: [{ hunkIndex: 0, range: [2, 2], kind: "added" }], + readDocument: async () => "# Hello\nnew item\n", + }); + + expect(layout?.rows).toEqual([ + { + id: "rendered:0", + spans: [{ text: "Hello", tone: "accent", attributes: ["bold"] }], + }, + { + id: "rendered:1", + spans: [{ text: "new item", tone: "added" }], + sourceRanges: [{ side: "new", range: [2, 2] }], + }, + ]); + expect(layout?.hunkRows).toEqual([{ startRow: 1, endRow: 1 }]); + }); + + test("renders lists, quotes, tables, inline emphasis, links, and fenced code", async () => { + const { view } = registerMarkdownTestView(); + const layout = await view.layout({ + file: { + id: "guide", + path: "guide.md", + patch: "", + stats: { additions: 0, deletions: 0 }, + metadata: {}, + agent: null, + hunks: [], + }, + width: 80, + signal: new AbortController().signal, + changes: [], + readDocument: async () => + [ + "# Guide", + "", + "Use **bold**, *emphasis*, and [docs](https://example.com).", + "", + "- first", + "- second", + "", + "> quoted", + "", + "| A | B |", + "|---|---|", + "| 1 | 2 |", + "", + "```ts", + "const answer = 42", + "```", + "", + ].join("\n"), + }); + + const text = layout?.rows.map((row) => row.spans.map((span) => span.text).join("")); + expect(text).toContain("Guide"); + expect(text).not.toContain("# Guide"); + expect(text).toContain("• first"); + expect(text).toContain("│ quoted"); + expect(text).toContain("A │ B"); + expect(text).toContain("┌─ ts"); + expect(text).toContain("│ const answer = 42"); + expect(text).not.toContain("```ts"); + const spans = layout?.rows.flatMap((row) => row.spans) ?? []; + expect(spans.map((span) => span.tone)).toEqual(expect.arrayContaining(["accent", "syntax"])); + expect(spans.flatMap((span) => span.attributes ?? [])).toEqual( + expect.arrayContaining(["bold", "italic", "underline"]), + ); + }); + + test("falls back to raw diff for unavailable source or malformed fences", async () => { + const { view } = registerMarkdownTestView(); + const base = { + file: { + id: "x", + path: "x.md", + patch: "", + stats: { additions: 0, deletions: 0 }, + metadata: {}, + agent: null, + hunks: [], + }, + changes: [], + } as const; + const request = { + ...base, + width: 80, + signal: new AbortController().signal, + }; + await expect(view.layout({ ...request, readDocument: async () => null })).resolves.toBeNull(); + await expect( + view.layout({ ...request, readDocument: async () => "```ts\nopen" }), + ).resolves.toBeNull(); + }); +}); diff --git a/src/core/hunkSummary.test.ts b/src/core/hunkSummary.test.ts index 65bc8f73..bf70586f 100644 --- a/src/core/hunkSummary.test.ts +++ b/src/core/hunkSummary.test.ts @@ -1,6 +1,9 @@ import { describe, expect, test } from "bun:test"; import type { Hunk } from "@pierre/diffs"; +import { createJsxFileViewLayout } from "../../examples/extensions/jsx-file-view"; import { createTestDiffFile } from "../../test/helpers/diff-helpers"; +import { createFileViewInput } from "../ui/fileViews/host"; +import { validateFileViewLayout } from "../ui/fileViews/layout"; import { formatHunkHeader } from "./hunkHeader"; import { summarizeHunk } from "./hunkSummary"; import { hunkLineRange } from "./liveComments"; @@ -18,17 +21,66 @@ describe("summarizeHunk", () => { const summaries = file.metadata.hunks.map((hunk, index) => summarizeHunk(hunk, index)); for (const [index, hunk] of file.metadata.hunks.entries()) { - // The header and spans come from the same helpers every review surface - // uses, so a summary can never disagree with rendering or navigation. + // Pierre includes a trailing line break in parsed specs. Raw formatting preserves it, + // while the public summary boundary makes the same semantic header row-safe. + expect(formatHunkHeader(hunk)).toMatch(/[\r\n]$/); expect(summaries[index]).toEqual({ index, - header: formatHunkHeader(hunk), + header: formatHunkHeader(hunk) + .replace(/[\r\n]+/g, " ") + .trimEnd(), ...hunkLineRange(hunk), }); expect(summaries[index]!.header).toMatch(/^@@ -\d/); + expect(summaries[index]!.header).not.toMatch(/[\r\n]/); expect(summaries[index]!.oldRange).toBeDefined(); expect(summaries[index]!.newRange).toBeDefined(); } + + const publicFile = createFileViewInput(file, 80, new AbortController().signal).file; + const jsxLayout = createJsxFileViewLayout(publicFile); + expect(jsxLayout).not.toBeNull(); + expect(validateFileViewLayout(jsxLayout, summaries.length, 80)).toMatchObject({ valid: true }); + }); + + test("JSX example omits nonexistent added/deleted source sides", () => { + const publicFile = createFileViewInput( + createTestDiffFile(), + 80, + new AbortController().signal, + ).file; + const template = publicFile.hunks?.[0]; + if (!template) throw new Error("Expected one parsed hunk"); + + for (const [missingSide, firstOldRange, firstNewRange, secondOldRange, secondNewRange] of [ + ["old", [0, 0], [1, 1], [0, 0], [2, 2]], + ["new", [1, 1], [0, 0], [2, 2], [0, 0]], + ] as const) { + const layout = createJsxFileViewLayout({ + ...publicFile, + hunks: [ + { + ...template, + index: 0, + oldRange: [...firstOldRange] as [number, number], + newRange: [...firstNewRange] as [number, number], + }, + { + ...template, + index: 1, + oldRange: [...secondOldRange] as [number, number], + newRange: [...secondNewRange] as [number, number], + }, + ], + }); + expect(layout).not.toBeNull(); + expect(validateFileViewLayout(layout, 2, 80)).toMatchObject({ valid: true }); + expect( + layout?.rows + .flatMap((row) => row.sourceRanges ?? []) + .some((range) => range.side === missingSide), + ).toBe(false); + } }); test("gives a synthesized hunk without line numbers no ranges instead of NaN spans", () => { @@ -39,9 +91,15 @@ describe("summarizeHunk", () => { expect(summarizeHunk(bare, 3)).toEqual({ index: 3, header: "" }); }); - test("keeps a synthesized hunk's declared header text when it has one", () => { - const declared = { hunkContent: [], hunkSpecs: "@@ synthesized @@" } as unknown as Hunk; + test("normalizes CR/LF runs and trailing whitespace in a synthesized public header", () => { + const declared = { + hunkContent: [], + hunkSpecs: "@@ synthesized @@\r\nfunction name\n\t", + } as unknown as Hunk; - expect(summarizeHunk(declared, 0)).toEqual({ index: 0, header: "@@ synthesized @@" }); + expect(summarizeHunk(declared, 0)).toEqual({ + index: 0, + header: "@@ synthesized @@ function name", + }); }); }); diff --git a/src/core/hunkSummary.ts b/src/core/hunkSummary.ts index 77479429..4d07608b 100644 --- a/src/core/hunkSummary.ts +++ b/src/core/hunkSummary.ts @@ -28,9 +28,12 @@ function hasLineNumbers(hunk: Hunk) { */ export function summarizeHunk(hunk: Hunk, index: number): ExtensionDiffHunk { const rangesDerivable = hasLineNumbers(hunk); + const formattedHeader = hunk.hunkSpecs != null || rangesDerivable ? formatHunkHeader(hunk) : ""; return { index, - header: hunk.hunkSpecs != null || rangesDerivable ? formatHunkHeader(hunk) : "", + // Public summaries are commonly embedded in terminal-safe extension rows, so keep this + // boundary single-line without changing the raw header formatter used by Hunk itself. + header: formattedHeader.replace(/[\r\n]+/g, " ").trimEnd(), ...(rangesDerivable ? hunkLineRange(hunk) : {}), }; } diff --git a/src/extension-api/index.ts b/src/extension-api/index.ts index 97405294..9f64bdeb 100644 --- a/src/extension-api/index.ts +++ b/src/extension-api/index.ts @@ -42,6 +42,16 @@ export type { ExtensionContext, ExtensionDiffFile, ExtensionDiffHunk, + ExtensionFileChangeRange, + ExtensionFileSide, + ExtensionFileView, + ExtensionFileViewControls, + ExtensionFileViewInput, + ExtensionFileViewLayout, + ExtensionFileViewRow, + ExtensionFileViewRowComponentProps, + ExtensionFileViewSourceRange, + ExtensionFileViewSpan, ExtensionCustomEventHandler, ExtensionEventBus, ExtensionEventContext, @@ -58,6 +68,7 @@ export type { ExtensionInputOptions, ExtensionSelectOptions, ExtensionNotifyType, + ExtensionPaintTheme, ExtensionLayoutMode, ExtensionResolvedLayout, ExtensionReviewNote, diff --git a/src/extension-api/types.ts b/src/extension-api/types.ts index dfd259c7..f81dc28f 100644 --- a/src/extension-api/types.ts +++ b/src/extension-api/types.ts @@ -21,7 +21,7 @@ * Extensions can branch on `hunk.apiVersion` so a newer Hunk can keep loading * older extensions without guessing at their expectations. */ -export const HUNK_EXTENSION_API_VERSION = 1; +export const HUNK_EXTENSION_API_VERSION = 2; export type HunkExtensionApiVersion = typeof HUNK_EXTENSION_API_VERSION; export type ExtensionNotifyType = "info" | "warning" | "error"; @@ -210,6 +210,115 @@ export type ChangesetTransform = ( ctx: ExtensionContext, ) => ExtensionChangeset | Promise; +/* -------------------------------------------------------------------------- */ +/* File views */ +/* -------------------------------------------------------------------------- */ + +/** A side of a reviewed source document. */ +export type ExtensionFileSide = "old" | "new"; + +/** One added or removed source-line range, inclusive on both ends. */ +export interface ExtensionFileChangeRange { + readonly hunkIndex: number; + /** Added ranges belong to the new side; removed ranges belong to the old side. */ + readonly kind: "added" | "removed"; + readonly range: readonly [number, number]; +} + +/** One exact-source range associated with a host-owned file-view row. */ +export interface ExtensionFileViewSourceRange { + readonly side: ExtensionFileSide; + /** Inclusive, one-based source line range. */ + readonly range: readonly [number, number]; +} + +/** One symbolic run in a host-rendered file-view row. */ +export interface ExtensionFileViewSpan { + readonly text: string; + /** A generic semantic color the host maps to its active terminal theme at paint time. */ + readonly tone?: "muted" | "accent" | "accent-muted" | "syntax" | "added" | "removed"; + /** Theme-independent terminal emphasis. */ + readonly attributes?: readonly ("bold" | "italic" | "underline" | "strikethrough")[]; +} + +/** Bounded paint-only props handed to a custom file-view row component. */ +export interface ExtensionFileViewRowComponentProps { + /** Available terminal columns inside the host-owned row wrapper. */ + readonly width: number; + /** Fixed terminal rows reserved by the host. */ + readonly height: number; + /** Whether this row falls inside the selected hunk bounds. */ + readonly selected: boolean; + /** Zero-based position in the validated file-view layout. */ + readonly rowIndex: number; + /** Live paint-only semantic colors; theme changes never invalidate layout geometry. */ + readonly theme: ExtensionPaintTheme; +} + +/** A row in a host-owned, terminal-safe file-view layout. */ +export interface ExtensionFileViewRow { + /** A stable identifier within this layout result. */ + readonly id: string; + /** + * Symbolic host-rendered content, also used if a custom component fails. + * Component fallback is clipped to the same declared fixed height as the painter. + */ + readonly spans: readonly ExtensionFileViewSpan[]; + /** + * Exact-source ranges this row presents. Hunk validates unambiguous, in-bounds mappings and + * uses them to place host-rendered inline notes; unresolved notes keep the whole file on raw diff. + */ + readonly sourceRanges?: readonly ExtensionFileViewSourceRange[]; + /** + * Experimental fixed-height React/OpenTUI painter, clipped inside host-owned geometry. + * Height and render are one descriptor so a typed layout cannot declare either alone. + */ + readonly component?: { + readonly height: number; + readonly render: (props: ExtensionFileViewRowComponentProps) => unknown; + }; +} + +/** The deterministic, symbolic layout returned by a file-view extension. */ +export interface ExtensionFileViewLayout { + readonly rows: readonly ExtensionFileViewRow[]; + /** Inclusive row extents ordered to correspond to `input.file.hunks`. */ + readonly hunkRows: readonly { + readonly startRow: number; + readonly endRow: number; + }[]; +} + +/** Immutable input a file-view renderer receives for one file. */ +export interface ExtensionFileViewInput { + readonly file: ExtensionDiffFile; + /** Available terminal columns. Layout must be deterministic for this width. */ + readonly width: number; + /** Aborts when a resize, reload, selection change, or extension reload supersedes this work. */ + readonly signal: AbortSignal; + readonly changes: readonly ExtensionFileChangeRange[]; + /** + * Read one exact full source document. Reads are lazy and deduplicated per + * file and side for this layout request. A missing side, unavailable source, + * read failure, or resource-limit refusal resolves to `null`. + * + * Patch text is already available as `input.file.patch`; it is deliberately + * not presented as a document because a patch is not an exact source file. + */ + readDocument(side: ExtensionFileSide): Promise; +} + +/** A host-rendered alternative presentation for an individual file in the review stream. */ +export interface ExtensionFileView { + id: string; + title: string; + matches(file: ExtensionDiffFile): boolean; + /** Return `null` whenever the view cannot safely present this file; Hunk renders raw diff. */ + layout( + input: ExtensionFileViewInput, + ): ExtensionFileViewLayout | null | Promise; +} + /* -------------------------------------------------------------------------- */ /* Theme config tables */ /* -------------------------------------------------------------------------- */ @@ -386,7 +495,7 @@ export type ExtensionVcsFileChangeType = | "deleted"; /** Which side of a change a source read asks for. */ -export type ExtensionVcsFileSide = "old" | "new"; +export type ExtensionVcsFileSide = ExtensionFileSide; /** The one file and side Hunk wants full source text for. */ export interface ExtensionVcsFileSourceRequest { @@ -608,14 +717,14 @@ export interface ExtensionVcsAdapter { /* -------------------------------------------------------------------------- */ /** - * Theme tokens a custom sidebar renders with. + * Theme tokens extension-owned React/OpenTUI painters render with. * * A curated slice of the active theme rather than the whole internal theme * model: every value is a hex color string (or the appearance flag), stable to * build UI against, and updated live when the user switches themes. Field * names match the `[themes.]` config table where a concept exists there. */ -export interface ExtensionSidebarTheme { +export interface ExtensionPaintTheme { appearance: "light" | "dark"; background: string; panel: string; @@ -639,6 +748,9 @@ export interface ExtensionSidebarTheme { noteBorder: string; } +/** Backward-compatible name for the shared extension painter theme. */ +export type ExtensionSidebarTheme = ExtensionPaintTheme; + /** * Navigation a custom sidebar can trigger, exactly as the built-in one does. * @@ -769,13 +881,7 @@ export interface ExtensionCommand { * cannot shadow one of Hunk's, whichever id an extension is installed under. */ id: string; - /** - * Human-readable name, shown as the command's item in the Extensions menu. - * - * Every registered command is listed there with the key it currently answers - * to, so a command is reachable by mouse even when it ships without a chord - * or the one it wanted was already taken. - */ + /** Human-readable name for command menus and keyboard help. */ title: string; /** * Default key chord, e.g. `"ctrl+m"`, `"F2"`, `"G"`, `"y"`, or an array of @@ -810,6 +916,16 @@ export interface ExtensionSidebarControls { isOpen(viewId: string): boolean; } +/** Select or inspect the active file presentation from an extension command. */ +export interface ExtensionFileViewControls { + /** Select this extension's matching view, or pass `null` to restore raw rendering. */ + select(viewId: string | null): void; + /** Switch this extension's view on/off, returning to raw when it was active. */ + toggle(viewId: string): void; + /** Report whether this extension's view is active for the current file. */ + isActive(viewId: string): boolean; +} + /** * The review selection at one moment, as extensions see it. * @@ -898,6 +1014,8 @@ export interface ExtensionDialogs { /** What a command handler receives when its key fires. */ export interface ExtensionCommandContext extends ExtensionContext { sidebars: ExtensionSidebarControls; + /** Host-owned selection controls for alternate file presentations. */ + fileViews: ExtensionFileViewControls; /** * Where the review was pointing when this command fired. * @@ -1031,7 +1149,15 @@ export interface HunkExtensionAPI { */ registerSidebarView(view: ExtensionSidebarView): void; /** - * Register one named command, optionally bound to a key. + * Register a host-rendered alternative presentation for matching files. + * + * The host owns row measurement, scrolling, windowing, selection, and note + * placement. Rows normally contain symbolic text; the experimental fixed-height + * row component contract may paint React/OpenTUI content inside clipped host geometry. + */ + registerFileView(view: ExtensionFileView): void; + /** + * Register one named command, optionally bound to a key, * * The handler runs when the key fires outside modal UI (dialogs, menus, * focused inputs own their keys first). Handlers receive the standard diff --git a/src/extensions/apply.test.ts b/src/extensions/apply.test.ts index 5d45bfda..cab8c482 100644 --- a/src/extensions/apply.test.ts +++ b/src/extensions/apply.test.ts @@ -17,6 +17,7 @@ import { createUnknownVcsNotice, resolveDetectedVcsIdWithExtensions, resolveExtensionCommands, + resolveExtensionFileViews, resolveExtensionSidebarViews, resolveExtensionVcsAdapters, resolveSessionVcsId, @@ -178,6 +179,22 @@ describe("extension sidebar views", () => { }); }); +describe("extension file views", () => { + test("keeps the first duplicate view identity in registration order", () => { + const result = createEmptyExtensionLoadResult(); + const plain = { id: "plain", title: "Plain", matches: () => true, layout: () => null }; + result.registry.fileViews.push( + { extensionId: "first", view: plain }, + { extensionId: "first", view: { ...plain, title: "Later" } }, + ); + + const { views, issues } = resolveExtensionFileViews(result.registry); + + expect(views).toEqual([{ extensionId: "first", view: plain }]); + expect(issues[0]?.message).toContain('duplicate file view "first:plain"'); + }); +}); + describe("extension commands", () => { test("keeps every distinct command and reports duplicate ids", () => { const result = createEmptyExtensionLoadResult(); diff --git a/src/extensions/apply.ts b/src/extensions/apply.ts index 76a9ce7f..19214110 100644 --- a/src/extensions/apply.ts +++ b/src/extensions/apply.ts @@ -9,6 +9,7 @@ import type { ExtensionLoadResult, ExtensionRegistry, RegisteredCommand, + RegisteredFileView, RegisteredSidebarView, } from "./types"; @@ -105,9 +106,24 @@ export function resolveExtensionVcsAdapters( return { adapters, issues }; } +/** + * Join one extension id and a local view id into the address every registered view is known by. + * + * Duplicate resolution, selection lookup, and command-facing id qualification must agree on this + * exactly, so the format lives here once rather than being re-templated per caller. + */ +export function qualifiedViewKey(extensionId: string, viewId: string) { + return `${extensionId}:${viewId}`; +} + +/** Derive the `:` key one registered view is addressed by. */ +export function registeredViewKey(registered: { extensionId: string; view: { id: string } }) { + return qualifiedViewKey(registered.extensionId, registered.view.id); +} + /** Derive the key one sidebar view is addressed by everywhere in the app. */ export function sidebarViewKey(registered: RegisteredSidebarView) { - return `${registered.extensionId}:${registered.view.id}`; + return registeredViewKey(registered); } /** The sidebar views one session offers, plus the registrations skipped as duplicates. */ @@ -148,6 +164,40 @@ export function resolveExtensionSidebarViews( return { views, issues }; } +/** Derive the key one file view is addressed by everywhere in the app. */ +export function fileViewKey(registered: RegisteredFileView) { + return registeredViewKey(registered); +} + +/** The file views one session offers, plus registrations skipped as duplicates. */ +export interface ResolvedExtensionFileViews { + views: RegisteredFileView[]; + issues: ExtensionApplyIssue[]; +} + +/** Resolve file-view identities while retaining registration order as the priority rule. */ +export function resolveExtensionFileViews(registry: ExtensionRegistry): ResolvedExtensionFileViews { + const views: RegisteredFileView[] = []; + const issues: ExtensionApplyIssue[] = []; + const claimed = new Set(); + + for (const registered of registry.fileViews) { + const key = fileViewKey(registered); + if (claimed.has(key)) { + issues.push({ + extensionId: registered.extensionId, + message: `Skipped duplicate file view "${key}" from extension ${registered.extensionId}`, + }); + continue; + } + + claimed.add(key); + views.push(registered); + } + + return { views, issues }; +} + /** The commands one session offers, plus the registrations skipped as duplicates. */ export interface ResolvedExtensionCommands { commands: RegisteredCommand[]; @@ -212,10 +262,17 @@ export function applyExtensionRegistrations( // duplicate registrations surface through the same notice path as every // other refusal. const sidebars = resolveExtensionSidebarViews(result.registry); + const fileViews = resolveExtensionFileViews(result.registry); const commands = resolveExtensionCommands(result.registry); return { vcsAdapters: vcs.adapters, - issues: [...languageIssues, ...vcs.issues, ...sidebars.issues, ...commands.issues], + issues: [ + ...languageIssues, + ...vcs.issues, + ...sidebars.issues, + ...fileViews.issues, + ...commands.issues, + ], }; } diff --git a/src/extensions/hostRuntimeModules.test.ts b/src/extensions/hostRuntimeModules.test.ts index d3c8a631..eba523dd 100644 --- a/src/extensions/hostRuntimeModules.test.ts +++ b/src/extensions/hostRuntimeModules.test.ts @@ -3,6 +3,7 @@ import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { pathToFileURL } from "node:url"; import { afterEach, describe, expect, test } from "bun:test"; +import { TextAttributes } from "@opentui/core"; import { isValidElement, useState } from "react"; import { HunkExtensionUserError } from "../extension-api"; import { registerHostRuntimeModules } from "./hostRuntimeModules"; @@ -85,6 +86,39 @@ describe("registerHostRuntimeModules", () => { expect(isValidElement(makeElement())).toBe(true); }); + test("loads a hook-using OpenTUI file-row component through host runtime modules", async () => { + const path = writeTempExtension( + "file-view.tsx", + `import { TextAttributes } from "@opentui/core";\n` + + `import { useState } from "react";\n` + + `const Row = ({ width, height, selected, rowIndex }) => {\n` + + ` const [label] = useState("custom");\n` + + ` return ;\n` + + `};\n` + + `const register = (hunk) => hunk.registerFileView({\n` + + ` id: "tsx", title: "TSX", matches: () => true,\n` + + ` layout: () => ({ rows: [{ id: "row", spans: [{ text: "fallback" }], component: { height: 2, render: Row } }], hunkRows: [] }),\n` + + `});\n` + + `export default { Row, TextAttributes, makeElement: () => , register, useState };\n`, + ); + + const mod = await importTempExtension(path); + const registered: { + layout: () => { rows: Array<{ component: unknown }> }; + }[] = []; + const register = mod.default.register as (hunk: { + registerFileView(view: (typeof registered)[number]): void; + }) => void; + register({ registerFileView: (view) => registered.push(view) }); + + expect(mod.default.useState).toBe(useState); + expect(mod.default.TextAttributes).toBe(TextAttributes); + expect(isValidElement((mod.default.makeElement as () => unknown)())).toBe(true); + expect( + (registered[0]?.layout().rows[0]?.component as { render?: unknown } | undefined)?.render, + ).toBe(mod.default.Row); + }); + test("serves hunkdiff/extension runtime values", async () => { const path = writeTempExtension( "ext.ts", diff --git a/src/extensions/runExtension.test.ts b/src/extensions/runExtension.test.ts index 6a4deacf..396707cf 100644 --- a/src/extensions/runExtension.test.ts +++ b/src/extensions/runExtension.test.ts @@ -159,6 +159,60 @@ describe("registerSidebarView", () => { }); }); +describe("registerFileView", () => { + test("collects a layout callback that may return bounded custom row components", () => { + const registry = createEmptyExtensionRegistry(); + const issues: ExtensionLoadIssue[] = []; + const component = () => null; + const layout = () => ({ + rows: [ + { + id: "custom", + spans: [{ text: "fallback" }], + component: { height: 2, render: component }, + }, + ], + hunkRows: [], + }); + + runExtensionFactory({ + metadata: bundledMetadata("presentation"), + registry, + issues, + factory: (hunk) => { + hunk.registerFileView({ + id: "plain", + title: "Plain", + matches: () => true, + layout, + }); + }, + }); + + expect(issues).toEqual([]); + expect(registry.fileViews).toHaveLength(1); + expect(registry.fileViews[0]?.extensionId).toBe("presentation"); + expect(registry.fileViews[0]?.view.layout).toBe(layout); + }); + + test("rejects a file view without a layout function", () => { + const registry = createEmptyExtensionRegistry(); + const issues: ExtensionLoadIssue[] = []; + + runExtensionFactory({ + metadata: bundledMetadata("broken-presentation"), + registry, + issues, + factory: (hunk) => { + hunk.registerFileView({ id: "plain", title: "Plain", matches: () => true } as never); + }, + }); + + expect(registry.fileViews).toEqual([]); + expect(issues[0]?.message).toContain("layout() function"); + }); +}); + describe("hunk.events", () => { test("registers a bus listener under its owning extension", () => { const registry = createEmptyExtensionRegistry(); diff --git a/src/extensions/runExtension.ts b/src/extensions/runExtension.ts index 36255fa4..9bac9390 100644 --- a/src/extensions/runExtension.ts +++ b/src/extensions/runExtension.ts @@ -12,6 +12,7 @@ import { type ExtensionMetadata, type ExtensionRegistry, type ExtensionSidebarView, + type ExtensionFileView, type ExtensionThemeConfig, type ExtensionVcsAdapter, type HunkExtensionAPI, @@ -216,6 +217,7 @@ interface RegistrySnapshot { vcsAdapters: number; changesetTransforms: number; sidebarViews: number; + fileViews: number; commands: number; eventHandlers: Record; customEventHandlers: number; @@ -235,6 +237,7 @@ function snapshotRegistry(registry: ExtensionRegistry): RegistrySnapshot { vcsAdapters: registry.vcsAdapters.length, changesetTransforms: registry.changesetTransforms.length, sidebarViews: registry.sidebarViews.length, + fileViews: registry.fileViews.length, commands: registry.commands.length, eventHandlers, customEventHandlers: registry.customEventHandlers.length, @@ -254,6 +257,7 @@ function rollbackRegistry(registry: ExtensionRegistry, snapshot: RegistrySnapsho registry.vcsAdapters.length = snapshot.vcsAdapters; registry.changesetTransforms.length = snapshot.changesetTransforms; registry.sidebarViews.length = snapshot.sidebarViews; + registry.fileViews.length = snapshot.fileViews; registry.commands.length = snapshot.commands; registry.customEventHandlers.length = snapshot.customEventHandlers; registry.pendingCustomEvents.length = snapshot.pendingCustomEvents; @@ -362,6 +366,19 @@ export function createExtensionApi( registry.sidebarViews.push({ extensionId: metadata.id, view }); }, + registerFileView(view: ExtensionFileView) { + assertOpen("registerFileView"); + assertNonEmptyString(view?.id, "registerFileView requires a view with a non-empty id."); + assertNonEmptyString(view?.title, "registerFileView requires a view with a non-empty title."); + if (typeof view.matches !== "function") { + throw new Error("registerFileView requires a matches() function."); + } + if (typeof view.layout !== "function") { + throw new Error("registerFileView requires a layout() function."); + } + + registry.fileViews.push({ extensionId: metadata.id, view }); + }, registerCommand(command: ExtensionCommand, handler: ExtensionCommandHandler) { assertOpen("registerCommand"); assertNonEmptyString(command?.id, "registerCommand requires a command with a non-empty id."); diff --git a/src/extensions/types.ts b/src/extensions/types.ts index 8047b893..4ad3d4c1 100644 --- a/src/extensions/types.ts +++ b/src/extensions/types.ts @@ -8,6 +8,7 @@ import type { ExtensionCustomEventHandler, ExtensionEventHandler, ExtensionEventName, + ExtensionFileView, ExtensionNotifyType, ExtensionSidebarView, ExtensionThemeConfig, @@ -37,10 +38,21 @@ export type { ExtensionEventHandler, ExtensionEventName, ExtensionEventPayloads, + ExtensionFileChangeRange, + ExtensionFileSide, + ExtensionFileView, + ExtensionFileViewControls, + ExtensionFileViewInput, + ExtensionFileViewLayout, + ExtensionFileViewRow, + ExtensionFileViewRowComponentProps, + ExtensionFileViewSourceRange, + ExtensionFileViewSpan, ExtensionFactory, ExtensionInputOptions, ExtensionReviewNote, ExtensionNotifyType, + ExtensionPaintTheme, ExtensionSelectOptions, ExtensionSidebarActions, ExtensionSidebarComponent, @@ -110,6 +122,12 @@ export interface RegisteredSidebarView { view: ExtensionSidebarView; } +/** A host-rendered alternative file presentation registered by one extension. */ +export interface RegisteredFileView { + extensionId: string; + view: ExtensionFileView; +} + export interface RegisteredCommand { extensionId: string; command: ExtensionCommand; @@ -152,6 +170,7 @@ export interface ExtensionRegistry { vcsAdapters: RegisteredVcsAdapter[]; changesetTransforms: RegisteredChangesetTransform[]; sidebarViews: RegisteredSidebarView[]; + fileViews: RegisteredFileView[]; commands: RegisteredCommand[]; eventHandlers: ExtensionEventHandlerMap; customEventHandlers: RegisteredCustomEventHandler[]; @@ -225,6 +244,7 @@ export function createEmptyExtensionRegistry(): ExtensionRegistry { vcsAdapters: [], changesetTransforms: [], sidebarViews: [], + fileViews: [], commands: [], eventHandlers: { startup: [], diff --git a/src/ui/App.tsx b/src/ui/App.tsx index d7da757c..bb57c894 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -31,7 +31,7 @@ import type { } from "../core/types"; import { canReloadInput } from "../core/watch"; import { sanitizeTerminalLine } from "../lib/terminalText"; -import { resolveExtensionCommands } from "../extensions/apply"; +import { resolveExtensionCommands, resolveExtensionFileViews } from "../extensions/apply"; import { emitExtensionCustomEvent, emitExtensionEvent, @@ -41,6 +41,7 @@ import { writeExtensionTrust } from "../extensions/trust"; import type { ExtensionCommandContext, ExtensionEventContext, + ExtensionFileViewControls, ExtensionReviewNote, ExtensionSidebarControls, RegisteredCommand, @@ -80,6 +81,17 @@ import { buildAppMenus } from "./lib/appMenus"; import { buildExtensionAppCommands, extensionCommandKeyDefaults } from "./lib/extensionCommands"; import { createExtensionDialogQueue } from "./lib/extensionDialogs"; import { buildExtensionReviewSelection } from "./lib/extensionSelection"; +import { useFileViewLayouts } from "./fileViews/useFileViews"; +import type { FileViewRowFailure } from "./components/panes/FileView"; +import { availableFileViewSelections, fileViewUnavailableReason } from "./fileViews/availability"; +import { + reconcileFileViewSelections, + registeredFileViewKey, + resolveBulkFileViewTarget, + resolveRegisteredFileView, + selectFileView, + selectFileViewForFiles, +} from "./fileViews/state"; import { createExtensionSidebarKeybindings, resolveCommandKeys } from "./lib/keymap"; import { buildSessionSidebarViews, @@ -97,6 +109,9 @@ import { resolveResponsiveLayout } from "./lib/responsive"; import { resizeSidebarWidth } from "./lib/sidebar"; import { availableThemes, resolveTheme, withTransparentSurfaces } from "./themes"; +/** Bound row-render warning metadata even if a large custom tree fails throughout scrolling. */ +const FILE_VIEW_RENDER_FAILURE_MAX_ENTRIES = 256; + type FocusArea = "files" | "filter" | "note"; type ActiveAddNoteTarget = ActiveAddNoteAffordance & { fileId: string }; type ThemeSelectorState = { @@ -338,6 +353,43 @@ export function App({ const selectedFile = review.selectedFile; const selectedHunkIndex = review.selectedHunkIndex; const selectedFileId = selectedFile?.id ?? null; + // File presentations are per-file, survive filtering, and are reconciled against a reload's + // stable ids. Raw is implicit, so an empty state is the guaranteed default/fallback. + const sessionFileViews = useMemo( + () => (extensions ? resolveExtensionFileViews(extensions.registry).views : []), + [extensions], + ); + const [fileViewSelections, setFileViewSelections] = useState>({}); + const fileViewSelectionsRef = useRef(fileViewSelections); + fileViewSelectionsRef.current = fileViewSelections; + const sessionFileViewsRef = useRef(sessionFileViews); + sessionFileViewsRef.current = sessionFileViews; + const fileViewUnavailableReasons = useMemo(() => { + const reasons = new Map(); + for (const file of filteredFiles) { + const reason = fileViewUnavailableReason({ + hasDraftNote: review.draftNote?.fileId === file.id, + }); + if (reason) reasons.set(file.id, reason); + } + return reasons; + }, [filteredFiles, review.draftNote?.fileId]); + const fileViewUnavailableReasonsRef = useRef(fileViewUnavailableReasons); + fileViewUnavailableReasonsRef.current = fileViewUnavailableReasons; + const availableFileViewSelectionState = useMemo( + () => availableFileViewSelections(fileViewSelections, fileViewUnavailableReasons), + [fileViewSelections, fileViewUnavailableReasons], + ); + useEffect(() => { + const viewKeys = new Set(sessionFileViews.map(registeredFileViewKey)); + setFileViewSelections((current) => + reconcileFileViewSelections( + current, + reviewFiles.map((file) => file.id), + viewKeys, + ), + ); + }, [reviewFiles, sessionFileViews]); // The one conversion of the visible review files into the frozen views every // extension surface sees: sidebar props and command-handler selection both // read from this list, so they can never describe the review differently. @@ -509,6 +561,84 @@ export function App({ [extensions, setSidebarOpen], ); + /** Build host-owned file-presentation controls for one extension command. */ + const createFileViewControls = useCallback( + (extensionId: string): ExtensionFileViewControls => { + const resolve = (viewId: string) => + resolveRegisteredFileView(sessionFileViewsRef.current, extensionId, viewId); + const selectedId = () => extensionSelectionInputsRef.current.selectedFileId; + const select = (viewId: string | null) => { + const fileId = selectedId(); + if (!fileId) { + showSessionNotice( + `Extension ${extensionId} cannot select a file view without a selected file`, + ); + return; + } + const unavailableReason = fileViewUnavailableReasonsRef.current.get(fileId); + if (viewId !== null && unavailableReason) { + showSessionNotice(unavailableReason); + return; + } + const registered = viewId === null ? undefined : resolve(viewId); + if (viewId !== null && !registered) { + showSessionNotice(`Extension ${extensionId} targeted unknown file view "${viewId}"`); + return; + } + if (registered) { + const selected = getExtensionSelection().file; + try { + if (!selected || !registered.view.matches(selected)) { + showSessionNotice( + `File view "${viewId}" does not match the selected file • using raw diff`, + ); + return; + } + } catch { + showSessionNotice( + `Extension ${registered.extensionId} file view "${registered.view.id}" failed matching the selected file`, + ); + return; + } + } + setFileViewSelections((current) => + selectFileView(current, fileId, registered ? registeredFileViewKey(registered) : null), + ); + }; + return { + select, + toggle(viewId: string) { + const registered = resolve(viewId); + const fileId = selectedId(); + if (fileId && fileViewUnavailableReasonsRef.current.has(fileId)) { + select(viewId); + return; + } + if ( + fileId && + registered && + fileViewSelectionsRef.current[fileId] === registeredFileViewKey(registered) + ) { + select(null); + } else { + select(viewId); + } + }, + isActive(viewId: string) { + const registered = resolve(viewId); + const fileId = selectedId(); + return Boolean( + fileId && + !fileViewUnavailableReasonsRef.current.has(fileId) && + registered && + fileViewSelectionsRef.current[fileId] === registeredFileViewKey(registered), + ); + }, + }; + }, + [getExtensionSelection, showSessionNotice], + ); + /** * Reveal the sidebar area, assigned each render once the responsive layout * is known (the controls above are created before it is computed). @@ -627,6 +757,7 @@ export function App({ cwd: extensions?.context.cwd ?? process.cwd(), notify: (message, type) => extensions?.context.notify(message, type), sidebars: createSidebarControls(registered.extensionId), + fileViews: createFileViewControls(registered.extensionId), // Snapshot semantics: built when the key fires, so the handler sees // where the review was at that moment, even if it awaits and the user // navigates on. @@ -649,7 +780,13 @@ export function App({ // `getExtensionSelection` is identity-stable (it reads refs), so the // dispatch table, keymap, and Extensions menu derived from this callback // do not rebuild on every `[`/`]` press. - [createSidebarControls, extensionDialogQueue, extensions, getExtensionSelection], + [ + createFileViewControls, + createSidebarControls, + extensionDialogQueue, + extensions, + getExtensionSelection, + ], ); const registeredExtensionCommands = useMemo( @@ -827,6 +964,56 @@ export function App({ layout: resolvedLayout, width: diffContentWidth, }); + const reportedFileViewRowFailuresRef = useRef( + new Map(), + ); + /** Attribute synchronous row failures through the existing extension warning surface. */ + const reportFileViewRowFailure = useCallback( + (failure: FileViewRowFailure) => { + const dedupeKey = [ + failure.extensionId, + failure.viewId, + failure.fileId, + failure.rowId, + failure.layoutGeneration, + failure.message, + ].join("\u0000"); + const reported = reportedFileViewRowFailuresRef.current; + if (reported.has(dedupeKey)) return; + reported.set(dedupeKey, { + fileId: failure.fileId, + layoutGeneration: failure.layoutGeneration, + }); + if (reported.size > FILE_VIEW_RENDER_FAILURE_MAX_ENTRIES) { + const oldest = reported.keys().next().value; + if (oldest !== undefined) reported.delete(oldest); + } + extensions?.context.notify( + `Extension ${failure.extensionId} file view "${failure.viewId}" row "${failure.rowId}" failed rendering ${failure.filePath} • ${failure.message}`, + "warning", + ); + }, + [extensions], + ); + const fileViewLayouts = useFileViewLayouts({ + files: filteredFiles, + selections: availableFileViewSelectionState, + views: sessionFileViews, + width: diffContentWidth, + onIssue: showSessionNotice, + }); + useEffect(() => { + const activeGenerations = new Set( + Array.from(fileViewLayouts, ([fileId, layout]) => + [fileId, layout.layoutGeneration].join("\u0000"), + ), + ); + for (const [key, failure] of reportedFileViewRowFailuresRef.current) { + if (!activeGenerations.has([failure.fileId, failure.layoutGeneration].join("\u0000"))) { + reportedFileViewRowFailuresRef.current.delete(key); + } + } + }, [fileViewLayouts]); useHunkSessionBridge({ addLiveComment: review.addLiveComment, @@ -1470,12 +1657,52 @@ export function App({ setFocusArea("files"); }, [review.cancelDraftNote]); + const reviewFileViewsForBulkSelection = useMemo( + () => toReadOnlyFileViews(reviewFiles), + [reviewFiles], + ); + const selectedFileViewBulkTarget = useMemo(() => { + if (!selectedFile || fileViewUnavailableReasons.has(selectedFile.id)) return null; + const key = fileViewSelections[selectedFile.id]; + if (!key) return null; + const registered = sessionFileViews.find((view) => registeredFileViewKey(view) === key); + if (!registered) return null; + + const target = resolveBulkFileViewTarget({ + current: fileViewSelections, + files: reviewFileViewsForBulkSelection, + registered, + selectedFileId: selectedFile.id, + }); + return target + ? { key: target.key, matchingFileIds: target.fileIds, title: registered.view.title } + : null; + }, [ + fileViewSelections, + fileViewUnavailableReasons, + reviewFileViewsForBulkSelection, + selectedFile, + sessionFileViews, + ]); + const applyFilePresentationToAllMatching = useCallback(() => { + if (!selectedFileViewBulkTarget) return; + setFileViewSelections((current) => + selectFileViewForFiles( + current, + selectedFileViewBulkTarget.matchingFileIds, + selectedFileViewBulkTarget.key, + ), + ); + }, [selectedFileViewBulkTarget]); + // One dispatch table for every app-level shortcut: the built-in commands // over App's live callbacks, then extension commands, so built-ins always // win a key and extension order follows load order. const appCommands = [ ...buildAppCommands({ + canApplyFilePresentationToAllMatching: selectedFileViewBulkTarget !== null, canRefreshCurrentInput, + applyFilePresentationToAllMatching, focusFilter, moveToAnnotatedFile, moveToAnnotatedHunk, @@ -1505,6 +1732,48 @@ export function App({ ...extensionAppCommands.commands, ]; + const selectedFileViewEntries = useMemo(() => { + if (!selectedFile) return []; + const publicFile = getExtensionFileViews().find((file) => file.id === selectedFile.id); + if (!publicFile) return []; + const unavailableReason = fileViewUnavailableReasons.get(selectedFile.id); + const active = unavailableReason ? undefined : fileViewSelections[selectedFile.id]; + const entries = [ + { + kind: "item" as const, + label: "File presentation: Raw diff", + commandId: "hunk.view.filePresentation.raw", + checked: active === undefined, + action: () => + setFileViewSelections((current) => selectFileView(current, selectedFile.id, null)), + }, + ]; + if (unavailableReason) return entries; + for (const registered of sessionFileViews) { + try { + if (!registered.view.matches(publicFile)) continue; + } catch { + continue; + } + const key = registeredFileViewKey(registered); + entries.push({ + kind: "item" as const, + label: `File presentation: ${registered.view.title}`, + commandId: `hunk.view.filePresentation.${key}`, + checked: active === key, + action: () => + setFileViewSelections((current) => selectFileView(current, selectedFile.id, key)), + }); + } + return entries; + }, [ + fileViewSelections, + fileViewUnavailableReasons, + getExtensionFileViews, + selectedFile, + sessionFileViews, + ]); + // Menus name commands rather than repeating them: every item's key hint and // action come from the table above, so a remapped shortcut shows its new key // and a menu item can never drift from the command it claims to run. Built @@ -1513,6 +1782,10 @@ export function App({ const menus = buildAppMenus({ commands: appCommands, extensionCommands: extensionAppCommands.commands, + fileViewEntries: selectedFileViewEntries, + fileViewApplyAllLabel: selectedFileViewBulkTarget + ? `Apply “${selectedFileViewBulkTarget.title}” to all matching files` + : undefined, copyDecorations, layoutMode, renderSidebar, @@ -1763,6 +2036,7 @@ export function App({ copyDecorations={copyDecorations} diffContentWidth={diffContentWidth} expandedGapsByFileId={review.expandedGapsByFileId} + fileViews={fileViewLayouts} files={filteredFiles} pagerMode={pagerMode} screenLeft={diffPaneScreenLeft} @@ -1803,6 +2077,7 @@ export function App({ scrollCodeHorizontally(delta * FAST_CODE_HORIZONTAL_SCROLL_COLUMNS); }} onCopyFeedback={showTransientNotice} + onFileViewRowFailure={reportFileViewRowFailure} onSelectFile={jumpToFile} onToggleGap={review.toggleGap} onViewportCenteredHunkChange={(fileId, hunkIndex) => diff --git a/src/ui/AppHost.file-views.test.tsx b/src/ui/AppHost.file-views.test.tsx new file mode 100644 index 00000000..a8e93672 --- /dev/null +++ b/src/ui/AppHost.file-views.test.tsx @@ -0,0 +1,331 @@ +import { cpSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, setDefaultTimeout, test } from "bun:test"; +import { testRender } from "@opentui/react/test-utils"; +import { act } from "react"; +import { createTestVcsAppBootstrap } from "../../test/helpers/app-bootstrap"; +import { createTestDiffFile, createTestSourceFetcher } from "../../test/helpers/diff-helpers"; +import { loadStartupExtensions } from "../extensions/startup"; +import { AppHost } from "./AppHost"; + +const JSX_FILE_VIEW_EXTENSION = join(import.meta.dir, "../../examples/extensions/jsx-file-view"); +const tempDirs: string[] = []; +setDefaultTimeout(20_000); + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +/** Copy the real folder extension to a fresh import root so Bun cannot reuse another test's module. */ +function copyJsxFileViewExtension() { + const root = mkdtempSync(join(tmpdir(), "hunk-apphost-jsx-view-")); + tempDirs.push(root); + const extension = join(root, "jsx-runtime-proof"); + cpSync(JSX_FILE_VIEW_EXTENSION, extension, { recursive: true }); + return { extension, root }; +} + +/** Write a real folder extension whose custom painter fails synchronously. */ +function createBrokenFileViewExtension() { + const root = mkdtempSync(join(tmpdir(), "hunk-apphost-broken-view-")); + tempDirs.push(root); + const extension = join(root, "broken-row"); + mkdirSync(extension, { recursive: true }); + writeFileSync( + join(extension, "package.json"), + JSON.stringify({ name: "broken-row", private: true, hunk: { extensions: ["./index.ts"] } }), + ); + writeFileSync( + join(extension, "index.ts"), + `export default function (hunk) { + hunk.registerFileView({ + id: "broken", + title: "Broken row", + matches: () => true, + layout: ({ file }) => ({ + rows: [{ + id: "broken-row", + spans: [{ text: "SAFE ROW FALLBACK" }], + component: { height: 1, render: () => { throw new Error("paint exploded"); } }, + }], + hunkRows: (file.hunks ?? []).map(() => ({ startRow: 0, endRow: 0 })), + }), + }); + hunk.registerCommand( + { id: "toggle-broken", title: "Toggle broken row", key: "f8" }, + (ctx) => ctx.fileViews.toggle("broken"), + ); +} +`, + ); + return { extension, root }; +} + +/** Write a matching-files preview used to prove the host-owned bulk View action. */ +function createBulkFileViewExtension() { + const root = mkdtempSync(join(tmpdir(), "hunk-apphost-bulk-view-")); + tempDirs.push(root); + const extension = join(root, "bulk-view"); + mkdirSync(extension, { recursive: true }); + writeFileSync( + join(extension, "package.json"), + JSON.stringify({ name: "bulk-view", private: true, hunk: { extensions: ["./index.ts"] } }), + ); + writeFileSync( + join(extension, "index.ts"), + `export default function (hunk) { + hunk.registerFileView({ + id: "preview", + title: "Bulk preview", + matches: (file) => file.path.endsWith(".ts"), + layout: ({ file }) => ({ + rows: [{ id: "preview", spans: [{ text: "PREVIEW " + file.path }] }], + hunkRows: (file.hunks ?? []).map(() => ({ startRow: 0, endRow: 0 })), + }), + }); + hunk.registerCommand( + { id: "toggle-preview", title: "Toggle bulk preview", key: "f8" }, + (ctx) => ctx.fileViews.toggle("preview"), + ); +} +`, + ); + return { extension, root }; +} + +/** Build the separated changes that exercise public summaries and cross-hunk selection. */ +function createTwoHunkFile() { + const beforeLines = Array.from( + { length: 80 }, + (_, index) => `export const line${index + 1} = ${index + 1};`, + ); + const afterLines = [...beforeLines]; + afterLines[0] = "export const line1 = 100;"; + afterLines[59] = "export const line60 = 6000;"; + return createTestDiffFile({ + after: `${afterLines.join("\n")}\n`, + before: `${beforeLines.join("\n")}\n`, + context: 3, + id: "jsx-runtime-proof", + path: "runtime-proof.ts", + sourceFetcher: createTestSourceFetcher(async (side) => + side === "old" ? `${beforeLines.join("\n")}\n` : `${afterLines.join("\n")}\n`, + ), + }); +} + +/** Paint frames until live extension layout work reaches the renderer. */ +async function waitForFrame( + setup: Awaited>, + predicate: (frame: string) => boolean, +) { + for (let attempt = 0; attempt < 80; attempt += 1) { + await act(async () => { + await setup.renderOnce(); + await Bun.sleep(20); + }); + const frame = setup.captureCharFrame(); + if (predicate(frame)) return frame; + } + throw new Error(`Timed out waiting for AppHost frame:\n${setup.captureCharFrame()}`); +} + +describe("AppHost file views", () => { + test("attributes one synchronous row-render warning and keeps the symbolic fallback", async () => { + const { extension, root } = createBrokenFileViewExtension(); + const extensions = await loadStartupExtensions({ + cliExtensionPaths: [extension], + cwd: root, + env: { XDG_CONFIG_HOME: root } as NodeJS.ProcessEnv, + extensions: { enabled: true, extensionConfigs: {}, paths: [], repoPaths: [] }, + }); + expect(extensions.issues).toEqual([]); + + const notices: string[] = []; + const notify = extensions.context.notify; + extensions.context.notify = (message, type) => { + notices.push(String(message)); + notify(message, type); + }; + const bootstrap = createTestVcsAppBootstrap({ + changesetId: "changeset:broken-row", + files: [createTwoHunkFile()], + initialMode: "stack", + inputMode: "stack", + vcsOptions: { extensionPaths: [extension] }, + }); + bootstrap.extensions = extensions; + + const originalConsoleError = console.error; + console.error = () => {}; + const setup = await testRender( {}} />, { + width: 120, + height: 24, + }); + + try { + await waitForFrame(setup, (frame) => frame.includes("runtime-proof.ts")); + await act(async () => setup.mockInput.pressKey("F8")); + await waitForFrame(setup, (frame) => frame.includes("SAFE ROW FALLBACK")); + await waitForFrame(setup, () => notices.some((notice) => notice.includes("paint exploded"))); + await act(async () => setup.renderOnce()); + + expect(notices.filter((notice) => notice.includes("paint exploded"))).toEqual([ + expect.stringContaining( + 'Extension broken-row file view "broken" row "broken-row" failed rendering runtime-proof.ts', + ), + ]); + } finally { + console.error = originalConsoleError; + await act(async () => setup.renderer.destroy()); + } + }); + + test("applies the active presentation changeset-wide, including filter-hidden matches", async () => { + const { extension, root } = createBulkFileViewExtension(); + const extensions = await loadStartupExtensions({ + cliExtensionPaths: [extension], + cwd: root, + env: { XDG_CONFIG_HOME: root } as NodeJS.ProcessEnv, + extensions: { enabled: true, extensionConfigs: {}, paths: [], repoPaths: [] }, + }); + expect(extensions.issues).toEqual([]); + const files = [ + createTestDiffFile({ id: "alpha", path: "alpha.ts" }), + createTestDiffFile({ id: "beta", path: "beta.ts" }), + createTestDiffFile({ id: "notes", path: "notes.md" }), + ]; + const bootstrap = createTestVcsAppBootstrap({ + changesetId: "changeset:bulk-view", + files, + initialMode: "stack", + inputMode: "stack", + vcsOptions: { extensionPaths: [extension] }, + }); + bootstrap.extensions = extensions; + const setup = await testRender( {}} />, { + width: 120, + height: 24, + }); + + try { + await waitForFrame(setup, (frame) => frame.includes("alpha.ts")); + await act(async () => { + await setup.mockInput.pressTab(); + await setup.mockInput.typeText("alpha"); + await setup.mockInput.pressTab(); + await setup.mockInput.pressKey("F8"); + }); + await waitForFrame(setup, (frame) => frame.includes("PREVIEW alpha.ts")); + + await act(async () => setup.mockInput.pressKey("F10")); + await waitForFrame(setup, (frame) => frame.includes("Toggle files/filter focus")); + await act(async () => setup.mockInput.pressArrow("right")); + const menu = await waitForFrame(setup, (frame) => + frame.includes("Apply “Bulk preview” to all matching files"), + ); + const lines = menu.split("\n"); + const targetY = lines.findIndex((line) => + line.includes("Apply “Bulk preview” to all matching files"), + ); + const targetX = lines[targetY]!.indexOf("Apply “Bulk preview”"); + await act(async () => setup.mockMouse.click(targetX, targetY)); + + await act(async () => { + await setup.mockInput.pressTab(); + await setup.mockInput.pressEscape(); + await setup.mockInput.pressTab(); + }); + const expanded = await waitForFrame( + setup, + (frame) => frame.includes("PREVIEW alpha.ts") && frame.includes("PREVIEW beta.ts"), + ); + expect(expanded).not.toContain("PREVIEW notes.md"); + } finally { + await act(async () => setup.renderer.destroy()); + } + }); + + test("runs the real folder TSX view with row-safe summaries, navigation, and mouse-up state", async () => { + const { extension, root } = copyJsxFileViewExtension(); + const extensions = await loadStartupExtensions({ + cliExtensionPaths: [extension], + cwd: root, + env: { XDG_CONFIG_HOME: root } as NodeJS.ProcessEnv, + extensions: { enabled: true, extensionConfigs: {}, paths: [], repoPaths: [] }, + }); + expect(extensions.issues).toEqual([]); + + const notices: string[] = []; + extensions.notifications.subscribe((notice) => notices.push(notice.message)); + const bootstrap = createTestVcsAppBootstrap({ + changesetId: "changeset:jsx-runtime-proof", + files: [createTwoHunkFile()], + initialMode: "stack", + inputMode: "stack", + vcsOptions: { extensionPaths: [extension] }, + }); + bootstrap.extensions = extensions; + + const setup = await testRender( {}} />, { + width: 120, + height: 24, + }); + const copied: string[] = []; + setup.renderer.isOsc52Supported = () => true; + setup.renderer.copyToClipboardOSC52 = (text: string) => { + copied.push(text); + return true; + }; + + try { + await waitForFrame(setup, (frame) => frame.includes("runtime-proof.ts")); + await act(async () => { + await setup.mockInput.pressKey("F8"); + }); + let frame = await waitForFrame( + setup, + (nextFrame) => nextFrame.includes("Hunk 1") && nextFrame.includes("Hunk 2"), + ); + expect(frame).toContain("▶ Hunk 1"); + expect(notices.some((notice) => notice.includes("invalid span"))).toBe(false); + + const cardY = frame.split("\n").findIndex((line) => line.includes("▶ Hunk 1")); + const cardX = frame.split("\n")[cardY]!.indexOf("Hunk 1"); + expect(cardY).toBeGreaterThanOrEqual(0); + expect(cardX).toBeGreaterThanOrEqual(0); + + await act(async () => { + await setup.mockMouse.pressDown(cardX, cardY); + }); + frame = setup.captureCharFrame(); + expect(frame).toContain("click for detail"); + + await act(async () => { + await setup.mockMouse.release(cardX, cardY); + }); + frame = await waitForFrame(setup, (nextFrame) => nextFrame.includes("lines 1–4 · @@")); + expect(frame).not.toContain("row 0 · click for detail"); + expect(copied).toEqual([]); + + await act(async () => { + await setup.mockInput.typeText("]"); + }); + frame = await waitForFrame(setup, (nextFrame) => nextFrame.includes("▶ Hunk 2")); + expect(frame).not.toContain("▶ Hunk 1"); + + await act(async () => { + await setup.mockInput.pressKey("F8"); + }); + frame = await waitForFrame(setup, (nextFrame) => nextFrame.includes("line60 = 6000")); + expect(frame).not.toContain("Hunk 1"); + } finally { + await act(async () => { + setup.renderer.destroy(); + }); + } + }); +}); diff --git a/src/ui/components/panes/DiffPane.tsx b/src/ui/components/panes/DiffPane.tsx index 9461b0cd..80508ee8 100644 --- a/src/ui/components/panes/DiffPane.tsx +++ b/src/ui/components/panes/DiffPane.tsx @@ -56,9 +56,13 @@ import { } from "../../lib/viewportAnchor"; import type { AppTheme } from "../../themes"; import { DiffSection } from "./DiffSection"; +import type { FileViewRowFailure } from "./FileView"; import { DiffFileHeaderRow } from "./DiffFileHeaderRow"; import { VerticalScrollbar, type VerticalScrollbarHandle } from "../scrollbar/VerticalScrollbar"; import type { VisibleBodyBounds } from "../../diff/rowWindowing"; +import type { ResolvedFileViewLayout } from "../../fileViews/useFileViews"; +import { measureFileViewGeometry } from "../../fileViews/geometry"; +import { buildFileViewRenderPlan } from "../../fileViews/renderPlan"; import { prefetchHighlightedDiff } from "../../diff/useHighlightedDiff"; import { buildFileRenderWindow, @@ -174,6 +178,7 @@ function buildHighlightPrefetchFileIds({ const EMPTY_EXPANDED_GAP_KEYS: ReadonlySet = new Set(); const EMPTY_EXPANDED_GAPS_BY_FILE_ID: Record> = {}; +const EMPTY_FILE_VIEWS: ReadonlyMap = new Map(); const EMPTY_SOURCE_STATUS_BY_FILE_ID: Record = {}; const NOOP_TOGGLE_GAP = () => {}; @@ -182,6 +187,7 @@ export function DiffPane({ codeHorizontalOffset = 0, diffContentWidth, expandedGapsByFileId = EMPTY_EXPANDED_GAPS_BY_FILE_ID, + fileViews = EMPTY_FILE_VIEWS, files, headerLabelWidth, headerStatsWidth, @@ -222,6 +228,7 @@ export function DiffPane({ onFocusDraftNote, onCopyFeedback, onCopySelectionText, + onFileViewRowFailure, onScrollCodeHorizontally = () => {}, onSelectFile, onToggleGap = NOOP_TOGGLE_GAP, @@ -230,6 +237,8 @@ export function DiffPane({ codeHorizontalOffset?: number; diffContentWidth: number; expandedGapsByFileId?: Record>; + /** Validated alternate layouts, keyed by file id; raw Pierre remains the fallback. */ + fileViews?: ReadonlyMap; files: DiffFile[]; headerLabelWidth: number; headerStatsWidth: number; @@ -272,6 +281,7 @@ export function DiffPane({ onFocusDraftNote?: () => void; onCopyFeedback?: (text: string) => void; onCopySelectionText?: (text: string) => void | boolean; + onFileViewRowFailure?: (failure: FileViewRowFailure) => void; onScrollCodeHorizontally?: (delta: number) => void; onSelectFile: (fileId: string) => void; onToggleGap?: (fileId: string, gapKey: string) => void; @@ -482,6 +492,26 @@ export function DiffPane({ showAgentNotes, ]); + const fileViewRenderPlans = useMemo(() => { + const next = new Map< + string, + { fileView: ResolvedFileViewLayout; rows: ReturnType["rows"] } + >(); + for (const file of files) { + const fileView = fileViews.get(file.id); + if (!fileView) continue; + const plan = buildFileViewRenderPlan( + fileView.layout, + allAgentNotesByFile.get(file.id) ?? EMPTY_VISIBLE_AGENT_NOTES, + ); + // Review data is never partially hidden: one unresolved note keeps this file on raw diff. + if (plan.unresolvedNoteIds.length === 0) { + next.set(file.id, { fileView, rows: plan.rows }); + } + } + return next; + }, [allAgentNotesByFile, fileViews, files]); + // Keep the full file-section path for wrapped lines, where exact wrapped heights depend on // mounting each section; nowrap reviews can window offscreen files behind exact spacers. const windowingEnabled = !wrapLines; @@ -675,8 +705,16 @@ export function DiffPane({ const baseSectionGeometry = useMemo( () => - files.map((file) => - measureDiffSectionGeometry( + files.map((file) => { + const plannedFileView = fileViewRenderPlans.get(file.id); + if (plannedFileView) { + return measureFileViewGeometry({ + resolved: plannedFileView.fileView, + plannedRows: plannedFileView.rows, + width: diffContentWidth, + }); + } + return measureDiffSectionGeometry( file, layout, showHunkHeaders, @@ -689,11 +727,12 @@ export function DiffPane({ sourceStatusByFileId[file.id], reserveAddNoteColumn, tabWidth, - ), - ), + ); + }), [ diffContentWidth, expandedGapsByFileId, + fileViewRenderPlans, files, layout, reserveAddNoteColumn, @@ -715,10 +754,11 @@ export function DiffPane({ const sectionGeometry = useMemo( () => files.map((file, index) => { - const notes = allAgentNotesByFile.get(file.id) ?? EMPTY_VISIBLE_AGENT_NOTES; - if (notes.length === 0) { + if (fileViewRenderPlans.has(file.id)) { return baseSectionGeometry[index]!; } + const notes = allAgentNotesByFile.get(file.id) ?? EMPTY_VISIBLE_AGENT_NOTES; + if (notes.length === 0) return baseSectionGeometry[index]!; return measureDiffSectionGeometry( file, @@ -740,6 +780,7 @@ export function DiffPane({ baseSectionGeometry, diffContentWidth, expandedGapsByFileId, + fileViewRenderPlans, files, layout, reserveAddNoteColumn, @@ -1867,6 +1908,7 @@ export function DiffPane({ codeHorizontalOffset={codeHorizontalOffset} expandedGapKeys={expandedGapsByFileId[file.id] ?? EMPTY_EXPANDED_GAP_KEYS} file={file} + fileView={fileViewRenderPlans.get(file.id)?.fileView} headerLabelWidth={headerLabelWidth} headerStatsWidth={headerStatsWidth} layout={layout} @@ -1898,6 +1940,7 @@ export function DiffPane({ visibleBodyBounds={visibleBodyBoundsByFile.get(file.id)} onHover={() => setHoveredFileForRowActions(file.id)} onMouseScroll={clearAddNoteHoverForScroll} + onFileViewRowFailure={onFileViewRowFailure} onActiveAddNoteAffordanceChange={ onActiveAddNoteAffordanceChange ? activeAddNoteAffordanceCallback(file.id) diff --git a/src/ui/components/panes/DiffSection.tsx b/src/ui/components/panes/DiffSection.tsx index 1541d1b3..b3846d80 100644 --- a/src/ui/components/panes/DiffSection.tsx +++ b/src/ui/components/panes/DiffSection.tsx @@ -10,11 +10,14 @@ import { diffSectionId } from "../../lib/ids"; import { fitText } from "../../lib/text"; import type { AppTheme } from "../../themes"; import { DiffFileHeaderRow } from "./DiffFileHeaderRow"; +import { FileView, type FileViewRowFailure } from "./FileView"; +import type { ResolvedFileViewLayout } from "../../fileViews/useFileViews"; interface DiffSectionProps { codeHorizontalOffset: number; expandedGapKeys: ReadonlySet; file: DiffFile; + fileView?: ResolvedFileViewLayout; headerLabelWidth: number; headerStatsWidth: number; layout: Exclude; @@ -39,6 +42,7 @@ interface DiffSectionProps { hoverClearSignal?: number; onHover: () => void; onMouseScroll?: () => void; + onFileViewRowFailure?: (failure: FileViewRowFailure) => void; onActiveAddNoteAffordanceChange?: (affordance: ActiveAddNoteAffordance | null) => void; onStartUserNoteAtHunk?: (hunkIndex: number, target?: UserNoteLineTarget) => void; onSelect: () => void; @@ -50,6 +54,7 @@ function DiffSectionComponent({ codeHorizontalOffset, expandedGapKeys, file, + fileView, headerLabelWidth, headerStatsWidth, layout, @@ -74,6 +79,7 @@ function DiffSectionComponent({ hoverClearSignal = 0, onHover, onMouseScroll, + onFileViewRowFailure, onActiveAddNoteAffordanceChange, onStartUserNoteAtHunk, onSelect, @@ -115,34 +121,58 @@ function DiffSectionComponent({ /> ) : null} - + {fileView ? ( + + ) : ( + + )} ); } @@ -156,6 +186,7 @@ export const DiffSection = memo(DiffSectionComponent, (previous, next) => { previous.codeHorizontalOffset === next.codeHorizontalOffset && previous.expandedGapKeys === next.expandedGapKeys && previous.file === next.file && + previous.fileView === next.fileView && previous.headerLabelWidth === next.headerLabelWidth && previous.headerStatsWidth === next.headerStatsWidth && previous.layout === next.layout && @@ -175,6 +206,7 @@ export const DiffSection = memo(DiffSectionComponent, (previous, next) => { previous.hoverActive === next.hoverActive && previous.hoverClearSignal === next.hoverClearSignal && previous.onMouseScroll === next.onMouseScroll && + previous.onFileViewRowFailure === next.onFileViewRowFailure && previous.onActiveAddNoteAffordanceChange === next.onActiveAddNoteAffordanceChange && previous.onStartUserNoteAtHunk === next.onStartUserNoteAtHunk && previous.theme === next.theme && diff --git a/src/ui/components/panes/ExtensionSidebarPane.tsx b/src/ui/components/panes/ExtensionSidebarPane.tsx index 2d462104..bde670ba 100644 --- a/src/ui/components/panes/ExtensionSidebarPane.tsx +++ b/src/ui/components/panes/ExtensionSidebarPane.tsx @@ -4,13 +4,13 @@ import type { ExtensionNotifyType, ExtensionSidebarActions, ExtensionSidebarKeybindings, - ExtensionSidebarTheme, ExtensionSidebarViewProps, } from "../../../extension-api/types"; import { BuiltInSidebarView } from "../../../extensions/default/ui/sidebar"; import type { ExtensionNotifySink, RegisteredSidebarView } from "../../../extensions/types"; import type { DiffFile } from "../../../core/types"; import type { AppTheme } from "../../themes"; +import { toExtensionPaintTheme } from "../../lib/extensionPaintTheme"; /** Read an error's message without assuming extension components throw `Error` instances. */ function describeError(error: unknown) { @@ -69,31 +69,6 @@ class ExtensionSidebarErrorBoundary extends Component< } } -/** Project the active theme onto the public token slice custom sidebars render with. */ -function toSidebarTheme(theme: AppTheme): ExtensionSidebarTheme { - return { - appearance: theme.appearance, - background: theme.background, - panel: theme.panel, - panelAlt: theme.panelAlt, - border: theme.border, - accent: theme.accent, - accentMuted: theme.accentMuted, - text: theme.text, - muted: theme.muted, - selectedHunk: theme.selectedHunk, - badgeAdded: theme.badgeAdded, - badgeRemoved: theme.badgeRemoved, - badgeNeutral: theme.badgeNeutral, - fileNew: theme.fileNew, - fileDeleted: theme.fileDeleted, - fileRenamed: theme.fileRenamed, - fileModified: theme.fileModified, - fileUntracked: theme.fileUntracked, - noteBorder: theme.noteBorder, - }; -} - /** * Mount the active sidebar view — bundled or extension-contributed. * @@ -153,7 +128,7 @@ export function ExtensionSidebarPane({ onRenderFailure?: () => void; }) { const { extensionId } = registered; - const publicTheme = useMemo(() => Object.freeze(toSidebarTheme(theme)), [theme]); + const publicTheme = useMemo(() => toExtensionPaintTheme(theme), [theme]); const actions = useMemo(() => { /** Resolve a navigation target, or report one the review stream cannot show. */ diff --git a/src/ui/components/panes/FileView.test.tsx b/src/ui/components/panes/FileView.test.tsx new file mode 100644 index 00000000..245078ab --- /dev/null +++ b/src/ui/components/panes/FileView.test.tsx @@ -0,0 +1,539 @@ +import { describe, expect, test } from "bun:test"; +import { testRender } from "@opentui/react/test-utils"; +import { act, useState } from "react"; +import { createTestDiffFile } from "../../../../test/helpers/diff-helpers"; +import type { + ExtensionFileViewLayout, + ExtensionFileViewRowComponentProps, +} from "../../../extension-api/types"; +import { measureFileViewGeometry } from "../../fileViews/geometry"; +import { validateFileViewLayout } from "../../fileViews/layout"; +import { buildFileViewRenderPlan } from "../../fileViews/renderPlan"; +import type { ResolvedFileViewLayout } from "../../fileViews/useFileViews"; +import { reviewRowId } from "../../lib/ids"; +import { resolveTheme } from "../../themes"; +import { FileView, isFileViewRowSelected } from "./FileView"; + +/** Validate a test layout and add the host identity carried by accepted runtime layouts. */ +function resolveTestLayout( + layout: ExtensionFileViewLayout, + width: number, + generation = 1, +): ResolvedFileViewLayout { + const checked = validateFileViewLayout(layout, layout.hunkRows.length, width); + if (!checked.valid) throw new Error(checked.issue); + return { + ...checked.value, + key: "test:view", + extensionId: "test", + viewId: "view", + registrationIdentity: 1, + layoutGeneration: generation, + }; +} + +/** Measure a note-less presentation at one explicit content width. */ +function measureTestGeometry(fileView: ResolvedFileViewLayout, width: number) { + return measureFileViewGeometry({ + resolved: fileView, + plannedRows: buildFileViewRenderPlan(fileView.layout, []).rows, + width, + }); +} + +const layout: ExtensionFileViewLayout = { + rows: [ + { id: "heading", spans: [{ text: "Heading" }] }, + { id: "body", spans: [{ text: "Body" }] }, + { id: "tail", spans: [{ text: "Tail" }] }, + ], + hunkRows: [ + { startRow: 0, endRow: 0 }, + { startRow: 0, endRow: 0 }, + { startRow: 1, endRow: 2 }, + ], +}; + +describe("FileView hunk selection", () => { + test("highlights every rendered row inside the selected hunk bounds", () => { + expect(isFileViewRowSelected(layout, 0, 2)).toBe(false); + expect(isFileViewRowSelected(layout, 1, 2)).toBe(true); + expect(isFileViewRowSelected(layout, 2, 2)).toBe(true); + expect(isFileViewRowSelected(layout, 1, 1)).toBe(false); + }); +}); + +describe("FileView custom rows", () => { + test("preserves the symbolic-only renderer", async () => { + const file = createTestDiffFile({ + id: "symbolic", + path: "symbolic.ts", + before: "a", + after: "b", + }); + const fileView = resolveTestLayout(layout, 20); + const geometry = measureTestGeometry(fileView, 20); + const setup = await testRender( + , + { width: 20, height: 4 }, + ); + + try { + await act(async () => setup.renderOnce()); + const frame = setup.captureCharFrame(); + expect(frame).toContain("Heading"); + expect(frame).toContain("Body"); + expect(frame).toContain("Tail"); + expect(setup.renderer.root.findDescendantById(reviewRowId("file-view:body"))?.height).toBe(1); + } finally { + await act(async () => setup.renderer.destroy()); + } + }); + + test("renders a host-owned note immediately before its bound alternate row", async () => { + const file = createTestDiffFile({ id: "noted", path: "noted.ts" }); + const fileView = resolveTestLayout( + { + rows: [ + { + id: "bound", + spans: [{ text: "BOUND PRESENTATION" }], + sourceRanges: [{ side: "new", range: [1, 2] }], + }, + ], + hunkRows: [{ startRow: 0, endRow: 0 }], + }, + 60, + ); + const plan = buildFileViewRenderPlan(fileView.layout, [ + { + id: "note", + annotation: { id: "note", summary: "Review bound output", newRange: [1, 1] }, + }, + ]); + const geometry = measureFileViewGeometry({ + resolved: fileView, + plannedRows: plan.rows, + width: 60, + }); + const setup = await testRender( + , + { width: 60, height: geometry.bodyHeight }, + ); + + try { + await act(async () => setup.renderOnce()); + const frame = setup.captureCharFrame(); + expect(frame).toContain("Review bound output"); + expect(frame).toContain("BOUND PRESENTATION"); + expect(frame.indexOf("Review bound output")).toBeLessThan( + frame.indexOf("BOUND PRESENTATION"), + ); + expect( + setup.renderer.root.findDescendantById(reviewRowId("inline-note:note:file-view:bound:0")), + ).not.toBeNull(); + } finally { + await act(async () => setup.renderer.destroy()); + } + }); + + test("mounts hook-using components only inside the host row window with bounded props", async () => { + const paintProps: ExtensionFileViewRowComponentProps[] = []; + const customRow = (label: string) => + function CustomRow(props: ExtensionFileViewRowComponentProps) { + const [captured] = useState(label); + paintProps.push(props); + return ; + }; + const customLayout: ExtensionFileViewLayout = { + rows: [ + { id: "before", spans: [{ text: "BEFORE" }] }, + { + id: "custom-a", + spans: [{ text: "FALLBACK A" }], + component: { height: 2, render: customRow("A") }, + }, + { + id: "custom-b", + spans: [{ text: "FALLBACK B" }], + component: { height: 2, render: customRow("B") }, + }, + ], + hunkRows: [ + { startRow: 1, endRow: 1 }, + { startRow: 2, endRow: 2 }, + ], + }; + const file = createTestDiffFile({ + id: "custom", + path: "custom.ts", + before: "a", + after: "b", + }); + const fileView = resolveTestLayout(customLayout, 20); + const geometry = measureTestGeometry(fileView, 20); + const setup = await testRender( + , + { width: 20, height: 5 }, + ); + + try { + await act(async () => { + await setup.renderOnce(); + }); + const frame = setup.captureCharFrame(); + expect(frame).toContain("CUSTOM A"); + expect(frame).not.toContain("CUSTOM B"); + expect(frame).not.toContain("BEFORE"); + expect(paintProps.at(-1)).toEqual({ + width: 20, + height: 2, + selected: true, + rowIndex: 1, + theme: expect.objectContaining({ appearance: "dark", text: expect.any(String) }), + }); + expect(Object.isFrozen(paintProps.at(-1)?.theme)).toBe(true); + } finally { + await act(async () => setup.renderer.destroy()); + } + }); + + test("repaints live semantic theme props without remounting or relayout", async () => { + let mountSequence = 0; + const paints: Array<{ appearance: string; text: string; token: number }> = []; + const themedLayout: ExtensionFileViewLayout = { + rows: [ + { + id: "themed", + spans: [{ text: "fallback" }], + component: { + height: 1, + render: ({ theme }) => { + const [token] = useState(() => ++mountSequence); + paints.push({ appearance: theme.appearance, text: theme.text, token }); + return ; + }, + }, + }, + ], + hunkRows: [{ startRow: 0, endRow: 0 }], + }; + const file = createTestDiffFile({ id: "themed", path: "themed.ts" }); + const fileView = resolveTestLayout(themedLayout, 20); + const geometry = measureTestGeometry(fileView, 20); + let switchTheme = () => {}; + + function Harness() { + const [themeId, setThemeId] = useState("github-dark-default"); + switchTheme = () => setThemeId("github-light-default"); + return ( + + ); + } + + const setup = await testRender(, { width: 20, height: 2 }); + try { + await act(async () => setup.renderOnce()); + expect(paints.at(-1)).toMatchObject({ appearance: "dark", token: 1 }); + const darkText = paints.at(-1)?.text; + + await act(async () => { + switchTheme(); + await setup.renderOnce(); + }); + expect(paints.at(-1)).toMatchObject({ appearance: "light", token: 1 }); + expect(paints.at(-1)?.text).not.toBe(darkText); + expect(fileView.layoutGeneration).toBe(1); + } finally { + await act(async () => setup.renderer.destroy()); + } + }); + + test("retains ephemeral hook state across selection props but loses it on unmount and generation", async () => { + let mountSequence = 0; + const renders: Array<{ selected: boolean; token: number }> = []; + const statefulLayout: ExtensionFileViewLayout = { + rows: [ + { + id: "stateful", + spans: [{ text: "fallback" }], + component: { + height: 1, + render: ({ selected }) => { + const [token] = useState(() => ++mountSequence); + renders.push({ selected, token }); + return ; + }, + }, + }, + ], + hunkRows: [{ startRow: 0, endRow: 0 }], + }; + const file = createTestDiffFile({ id: "stateful", path: "state.ts", before: "a", after: "b" }); + const initial = resolveTestLayout(statefulLayout, 20); + let selectHunk: (index: number) => void = () => {}; + let showRow: (visible: boolean) => void = () => {}; + let replaceGeneration: () => void = () => {}; + + function Harness() { + const [selectedHunkIndex, setSelectedHunkIndex] = useState(0); + const [visible, setVisible] = useState(true); + const [fileView, setFileView] = useState(initial); + selectHunk = setSelectedHunkIndex; + showRow = setVisible; + replaceGeneration = () => + setFileView((current) => ({ + ...current, + layoutGeneration: current.layoutGeneration + 1, + })); + return ( + + ); + } + + const setup = await testRender(, { width: 20, height: 2 }); + try { + await act(async () => setup.renderOnce()); + expect(renders.at(-1)).toEqual({ selected: true, token: 1 }); + + await act(async () => { + selectHunk(-1); + await setup.renderOnce(); + }); + expect(renders.at(-1)).toEqual({ selected: false, token: 1 }); + + await act(async () => { + showRow(false); + await setup.renderOnce(); + }); + await act(async () => { + showRow(true); + await setup.renderOnce(); + }); + expect(renders.at(-1)?.token).toBe(2); + + await act(async () => { + replaceGeneration(); + await setup.renderOnce(); + }); + expect(renders.at(-1)?.token).toBe(3); + } finally { + await act(async () => setup.renderer.destroy()); + } + }); + + test("mounts only visible painters from a 1,000-row component layout", async () => { + const mounted: number[] = []; + const largeLayout: ExtensionFileViewLayout = { + rows: Array.from({ length: 1_000 }, (_, index) => ({ + id: `row-${index}`, + spans: [{ text: `fallback ${index}` }], + ...(index === 500 + ? { sourceRanges: [{ side: "new" as const, range: [500, 500] as const }] } + : {}), + component: { + height: 1, + render: () => { + mounted.push(index); + return ; + }, + }, + })), + hunkRows: [{ startRow: 0, endRow: 999 }], + }; + const file = createTestDiffFile({ + id: "large", + path: "large.ts", + before: "a", + after: "b", + }); + const fileView = resolveTestLayout(largeLayout, 20); + const plan = buildFileViewRenderPlan(fileView.layout, [ + { + id: "windowed-note", + annotation: { summary: "WINDOWED NOTE", newRange: [500, 500] }, + }, + ]); + const geometry = measureFileViewGeometry({ + resolved: fileView, + plannedRows: plan.rows, + width: 20, + }); + const setup = await testRender( + , + { width: 20, height: 8 }, + ); + + try { + await act(async () => setup.renderOnce()); + expect( + setup.renderer.root.findDescendantById( + reviewRowId("inline-note:windowed-note:file-view:row-500:0"), + ), + ).not.toBeNull(); + expect(new Set(mounted)).toEqual(new Set([500, 501, 502, 503])); + expect(mounted).not.toContain(499); + expect(mounted).not.toContain(504); + } finally { + await act(async () => setup.renderer.destroy()); + } + }); + + test("clips oversized custom output to fixed host geometry and retains stable row ids", async () => { + const clippedLayout: ExtensionFileViewLayout = { + rows: [ + { + id: "clipped", + spans: [{ text: "CLIPPED FALLBACK" }], + component: { + height: 1, + render: () => ( + + + + + + ), + }, + }, + { id: "after", spans: [{ text: "AFTER ROW" }] }, + ], + hunkRows: [{ startRow: 0, endRow: 1 }], + }; + const file = createTestDiffFile({ + id: "clipped", + path: "clipped.ts", + before: "a", + after: "b", + }); + const fileView = resolveTestLayout(clippedLayout, 20); + const geometry = measureTestGeometry(fileView, 20); + const setup = await testRender( + , + { width: 20, height: 3 }, + ); + + try { + await act(async () => setup.renderOnce()); + const frame = setup.captureCharFrame(); + expect(frame).toContain("VISIBLE CUSTOM"); + expect(frame).not.toContain("HIDDEN OVERFLOW"); + expect(frame.split("\n")[1]).toContain("AFTER ROW"); + expect(setup.renderer.root.findDescendantById(reviewRowId("file-view:clipped"))?.height).toBe( + 1, + ); + expect(setup.renderer.root.findDescendantById(reviewRowId("file-view:after"))?.height).toBe( + 1, + ); + } finally { + await act(async () => setup.renderer.destroy()); + } + }); + + test("contains a component render error to its symbolic row fallback", async () => { + const brokenLayout: ExtensionFileViewLayout = { + rows: [ + { + id: "broken", + spans: [{ text: "SAFE FALLBACK" }], + component: { + height: 2, + render: () => { + throw new Error("broken custom row"); + }, + }, + }, + ], + hunkRows: [{ startRow: 0, endRow: 0 }], + }; + const file = createTestDiffFile({ + id: "broken", + path: "broken.ts", + before: "a", + after: "b", + }); + const originalConsoleError = console.error; + console.error = () => {}; + const failures: Array<{ message: string; rowId: string; layoutGeneration: number }> = []; + const fileView = resolveTestLayout(brokenLayout, 20, 7); + const setup = await testRender( + failures.push(failure)} + />, + { width: 20, height: 3 }, + ); + + try { + await act(async () => setup.renderOnce()); + expect(setup.captureCharFrame()).toContain("SAFE FALLBACK"); + expect(failures).toEqual([ + expect.objectContaining({ + message: "broken custom row", + rowId: "broken", + layoutGeneration: 7, + }), + ]); + } finally { + console.error = originalConsoleError; + await act(async () => setup.renderer.destroy()); + } + }); +}); diff --git a/src/ui/components/panes/FileView.tsx b/src/ui/components/panes/FileView.tsx new file mode 100644 index 00000000..f2d6e6ff --- /dev/null +++ b/src/ui/components/panes/FileView.tsx @@ -0,0 +1,265 @@ +import { TextAttributes } from "@opentui/core"; +import { Component, memo, useMemo, type ReactNode } from "react"; +import type { DiffFile } from "../../../core/types"; +import type { + ExtensionFileViewLayout, + ExtensionFileViewRow, + ExtensionFileViewRowComponentProps, + ExtensionFileViewSpan, +} from "../../../extension-api/types"; +import type { AppTheme } from "../../themes"; +import type { DiffSectionGeometry } from "../../diff/diffSectionGeometry"; +import { resolveVisibleRowIndexWindow, type VisibleBodyBounds } from "../../diff/rowWindowing"; +import { reviewRowId } from "../../lib/ids"; +import { toExtensionPaintTheme } from "../../lib/extensionPaintTheme"; +import type { ResolvedFileViewLayout } from "../../fileViews/useFileViews"; +import { AgentInlineNote } from "./AgentInlineNote"; + +type FileViewTone = ExtensionFileViewSpan["tone"]; +type FileViewTextAttribute = NonNullable[number]; + +export interface FileViewRowFailure { + extensionId: string; + viewId: string; + fileId: string; + filePath: string; + rowId: string; + layoutGeneration: number; + message: string; +} + +/** Resolve a generic file-view tone only at paint time, keeping layout theme-independent. */ +function fileViewToneColor(tone: FileViewTone, theme: AppTheme) { + switch (tone) { + case "muted": + return theme.muted; + case "accent": + return theme.accent; + case "accent-muted": + return theme.accentMuted; + case "syntax": + return theme.syntaxColors.default; + case "added": + return theme.fileNew; + case "removed": + return theme.fileDeleted; + default: + return theme.text; + } +} + +const FILE_VIEW_ATTRIBUTE_BITS: Record = { + bold: TextAttributes.BOLD, + italic: TextAttributes.ITALIC, + underline: TextAttributes.UNDERLINE, + strikethrough: TextAttributes.STRIKETHROUGH, +}; + +/** Combine generic emphasis attributes into OpenTUI's terminal bitmask. */ +function fileViewTextAttributes(attributes: readonly FileViewTextAttribute[] | undefined) { + return (attributes ?? []).reduce( + (combined, attribute) => combined | FILE_VIEW_ATTRIBUTE_BITS[attribute], + TextAttributes.NONE, + ); +} + +/** Report whether one symbolic row belongs to the currently selected hunk. */ +export function isFileViewRowSelected( + layout: ExtensionFileViewLayout, + rowIndex: number, + selectedHunkIndex: number, +) { + const selectedHunk = layout.hunkRows[selectedHunkIndex]; + return Boolean( + selectedHunk && rowIndex >= selectedHunk.startRow && rowIndex <= selectedHunk.endRow, + ); +} + +/** Paint one row through the original symbolic host-rendered path. */ +function SymbolicFileViewRow({ row, theme }: { row: ExtensionFileViewRow; theme: AppTheme }) { + return row.spans.map((span, spanIndex) => ( + + {span.text} + + )); +} + +/** Contain synchronous render/lifecycle failures to one row and attribute them to the host. */ +class FileViewRowErrorBoundary extends Component< + { children: ReactNode; fallback: ReactNode; onError: (error: unknown) => void }, + { failed: boolean } +> { + override state = { failed: false }; + + static getDerivedStateFromError() { + return { failed: true }; + } + + override componentDidCatch(error: unknown) { + this.props.onError(error); + } + + override render() { + return this.state.failed ? this.props.fallback : this.props.children; + } +} + +/** Render host-windowed symbolic and custom rows without surrendering outer geometry. */ +function FileViewComponent({ + file, + fileView, + geometry, + selectedHunkIndex, + theme, + visibleBodyBounds, + width, + onRowFailure, +}: { + file: DiffFile; + fileView: ResolvedFileViewLayout; + geometry: DiffSectionGeometry; + selectedHunkIndex: number; + theme: AppTheme; + visibleBodyBounds?: VisibleBodyBounds; + width: number; + onRowFailure?: (failure: FileViewRowFailure) => void; +}) { + const { layout } = fileView; + const publicTheme = useMemo(() => toExtensionPaintTheme(theme), [theme]); + const plannedRows = + geometry.fileViewRows ?? + layout.rows.map((row, rowIndex) => ({ + kind: "file-view-row" as const, + key: `file-view:${row.id}`, + stableKey: `file-view:${row.id}`, + row, + rowIndex, + })); + const rowWindow = useMemo(() => { + if (!visibleBodyBounds) { + return { + bottomSpacerHeight: 0, + endIndex: plannedRows.length, + startIndex: 0, + topSpacerHeight: 0, + }; + } + return resolveVisibleRowIndexWindow({ + bodyHeight: geometry.bodyHeight, + rowBounds: geometry.rowBounds, + visibleBodyBounds, + }); + }, [geometry.bodyHeight, geometry.rowBounds, plannedRows.length, visibleBodyBounds]); + + const mountedRows = plannedRows.slice(rowWindow.startIndex, rowWindow.endIndex); + return ( + + {rowWindow.topSpacerHeight > 0 ? ( + + ) : null} + {mountedRows.map((plannedRow) => { + if (plannedRow.kind === "inline-note") { + return ( + + + + ); + } + + const row = plannedRow.row; + const index = plannedRow.rowIndex; + const selected = isFileViewRowSelected(layout, index, selectedHunkIndex); + const fixedHeight = row.component?.height; + const View = row.component?.render as + | ((props: ExtensionFileViewRowComponentProps) => ReactNode) + | undefined; + const fallback = ; + // Selection is deliberately absent: hook state survives ordinary selected-prop updates. + // Window unmount or any accepted layout/registration generation creates a fresh identity. + const paintIdentity = `${file.id}:${fileView.registrationIdentity}:${fileView.layoutGeneration}:${row.id}`; + return ( + + {View && fixedHeight !== undefined ? ( + + onRowFailure?.({ + extensionId: fileView.extensionId, + viewId: fileView.viewId, + fileId: file.id, + filePath: file.path, + rowId: row.id, + layoutGeneration: fileView.layoutGeneration, + message: error instanceof Error ? error.message || error.name : String(error), + }) + } + > + + + + + ) : ( + fallback + )} + + ); + })} + {rowWindow.bottomSpacerHeight > 0 ? ( + + ) : null} + + ); +} + +export const FileView = memo(FileViewComponent); diff --git a/src/ui/diff/diffSectionGeometry.ts b/src/ui/diff/diffSectionGeometry.ts index 278f7872..9cd59c38 100644 --- a/src/ui/diff/diffSectionGeometry.ts +++ b/src/ui/diff/diffSectionGeometry.ts @@ -12,6 +12,7 @@ import { plannedReviewRowContributesToHunkBounds, type PlannedHunkBounds, } from "./plannedReviewRows"; +import type { PlannedFileViewRow } from "../fileViews/renderPlan"; import type { PlannedReviewRow } from "./reviewRenderPlan"; import { measureRenderedRowHeight } from "./renderRows"; @@ -33,6 +34,8 @@ export interface DiffSectionRowBounds extends VerticalBounds { export interface DiffSectionGeometry extends SectionGeometry { lineNumberDigits: number; plannedRows: PlannedReviewRow[]; + /** Alternate-view rows consume the same measured bounds while raw copy remains unavailable. */ + fileViewRows?: readonly PlannedFileViewRow[]; rowBounds: DiffSectionRowBounds[]; rowBoundsByKey: Map; rowBoundsByStableKey: Map; diff --git a/src/ui/diff/rowWindowing.test.ts b/src/ui/diff/rowWindowing.test.ts index af39a2e1..1ba9eb79 100644 --- a/src/ui/diff/rowWindowing.test.ts +++ b/src/ui/diff/rowWindowing.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test"; import type { DiffSectionGeometry } from "./diffSectionGeometry"; import type { PlannedReviewRow } from "./reviewRenderPlan"; -import { resolveVisiblePlannedRowWindow } from "./rowWindowing"; +import { resolveVisiblePlannedRowWindow, resolveVisibleRowIndexWindow } from "./rowWindowing"; /** Build one minimal planned row for row-window slicing tests. */ function createTestPlannedRow(key: string): PlannedReviewRow { @@ -140,6 +140,36 @@ describe("resolveVisiblePlannedRowWindow", () => { expect(window.plannedRows).toHaveLength(0); }); + test("bounds indexed geometry access for a 10,000-row alternate layout", () => { + const source = Array.from({ length: 10_000 }, (_, index) => ({ + key: `row:${index}`, + stableKey: `row:${index}`, + stableKeys: [`row:${index}`], + top: index * 2, + height: 2, + })); + let indexedAccesses = 0; + const rowBounds = new Proxy(source, { + get(target, property, receiver) { + if (typeof property === "string" && /^\d+$/.test(property)) indexedAccesses += 1; + return Reflect.get(target, property, receiver); + }, + }); + + const window = resolveVisibleRowIndexWindow({ + bodyHeight: 20_000, + rowBounds, + visibleBodyBounds: { top: 12_000, height: 10 }, + }); + + expect(window.endIndex - window.startIndex).toBe(5); + expect(indexedAccesses).toBeLessThan(80); + const mountedHeight = source + .slice(window.startIndex, window.endIndex) + .reduce((sum, row) => sum + row.height, 0); + expect(window.topSpacerHeight + mountedHeight + window.bottomSpacerHeight).toBe(20_000); + }); + test("finds visible rows in a very large row-bound array", () => { const rowBounds = Array.from({ length: 50_000 }, (_, index) => ({ key: `row:${index}`, diff --git a/src/ui/diff/rowWindowing.ts b/src/ui/diff/rowWindowing.ts index 075ea952..ba1917de 100644 --- a/src/ui/diff/rowWindowing.ts +++ b/src/ui/diff/rowWindowing.ts @@ -13,6 +13,14 @@ export interface VisiblePlannedRowWindow { topSpacerHeight: number; } +/** Index-only visible slice shared by Pierre plans and alternate file-view rows. */ +export interface VisibleRowIndexWindow { + bottomSpacerHeight: number; + endIndex: number; + startIndex: number; + topSpacerHeight: number; +} + /** * Find the first row whose bottom edge is after the visible top boundary. * Requires row bounds to be sorted by non-decreasing row bottom. @@ -75,73 +83,51 @@ function rowOverlapsVisibleRange( return rowBottom > minVisibleTop && rowBounds.top < maxVisibleBottom; } -/** - * Slice planned rows down to the visible body range while preserving total section height. - * - * The geometry row bounds come from the same render plan as `plannedRows`, so their array order is - * intentionally aligned and can be sliced by index. - */ -export function resolveVisiblePlannedRowWindow({ - plannedRows, - sectionGeometry, +/** Resolve a measured visible slice in O(log n + edge structural rows). */ +export function resolveVisibleRowIndexWindow({ + bodyHeight, + rowBounds, visibleBodyBounds, }: { - plannedRows: PlannedReviewRow[]; - sectionGeometry: DiffSectionGeometry; + bodyHeight: number; + rowBounds: DiffSectionGeometry["rowBounds"]; visibleBodyBounds: VisibleBodyBounds; -}): VisiblePlannedRowWindow { - if (plannedRows.length === 0 || sectionGeometry.rowBounds.length !== plannedRows.length) { - return { - bottomSpacerHeight: 0, - plannedRows, - topSpacerHeight: 0, - }; - } - - // Convert the requested visible window into one closed-open interval within this file body: - // [minVisibleTop, maxVisibleBottom). Rows above/below that interval become spacer height. +}): VisibleRowIndexWindow { const minVisibleTop = Math.max(0, visibleBodyBounds.top); const maxVisibleBottom = Math.min( - sectionGeometry.bodyHeight, + bodyHeight, visibleBodyBounds.top + Math.max(0, visibleBodyBounds.height), ); - let firstVisibleIndex = findFirstRowWithBottomAfter(sectionGeometry.rowBounds, minVisibleTop); + let firstVisibleIndex = findFirstRowWithBottomAfter(rowBounds, minVisibleTop); while ( - firstVisibleIndex < sectionGeometry.rowBounds.length && - !rowOverlapsVisibleRange( - sectionGeometry.rowBounds[firstVisibleIndex]!, - minVisibleTop, - maxVisibleBottom, - ) + firstVisibleIndex < rowBounds.length && + !rowOverlapsVisibleRange(rowBounds[firstVisibleIndex]!, minVisibleTop, maxVisibleBottom) ) { firstVisibleIndex += 1; } - let lastVisibleIndex = findLastRowWithTopBefore(sectionGeometry.rowBounds, maxVisibleBottom); + let lastVisibleIndex = findLastRowWithTopBefore(rowBounds, maxVisibleBottom); while ( lastVisibleIndex >= 0 && - !rowOverlapsVisibleRange( - sectionGeometry.rowBounds[lastVisibleIndex]!, - minVisibleTop, - maxVisibleBottom, - ) + !rowOverlapsVisibleRange(rowBounds[lastVisibleIndex]!, minVisibleTop, maxVisibleBottom) ) { lastVisibleIndex -= 1; } - if (firstVisibleIndex >= sectionGeometry.rowBounds.length) { + if (firstVisibleIndex >= rowBounds.length) { firstVisibleIndex = -1; } // firstVisibleIndex > lastVisibleIndex should not happen with sorted row bounds, but keep the // empty-window fallback defensive in case an upstream geometry invariant is ever broken. if (firstVisibleIndex < 0 || lastVisibleIndex < 0 || firstVisibleIndex > lastVisibleIndex) { - const topSpacerHeight = Math.min(sectionGeometry.bodyHeight, minVisibleTop); + const topSpacerHeight = Math.min(bodyHeight, minVisibleTop); return { - bottomSpacerHeight: Math.max(0, sectionGeometry.bodyHeight - topSpacerHeight), - plannedRows: [], + bottomSpacerHeight: Math.max(0, bodyHeight - topSpacerHeight), + endIndex: 0, + startIndex: 0, topSpacerHeight, }; } @@ -149,28 +135,54 @@ export function resolveVisiblePlannedRowWindow({ let startIndex = firstVisibleIndex; // Zero-height rows still matter structurally: for example, hidden hunk headers keep anchor ids // and stable row ordering. If one sits immediately before the visible slice, keep it attached. - while (startIndex > 0 && sectionGeometry.rowBounds[startIndex - 1]?.height === 0) { + while (startIndex > 0 && rowBounds[startIndex - 1]?.height === 0) { startIndex -= 1; } let endIndex = lastVisibleIndex + 1; // Do the same on the trailing edge so hidden structural rows continue to travel with the last // visible rendered row instead of being stranded in the spacer region. - while (endIndex < plannedRows.length && sectionGeometry.rowBounds[endIndex]?.height === 0) { + while (endIndex < rowBounds.length && rowBounds[endIndex]?.height === 0) { endIndex += 1; } - const startRowBounds = sectionGeometry.rowBounds[startIndex]!; - const endRowBounds = sectionGeometry.rowBounds[endIndex - 1]!; + const startRowBounds = rowBounds[startIndex]!; + const endRowBounds = rowBounds[endIndex - 1]!; return { // The top spacer is exactly the skipped body height before the first mounted row. topSpacerHeight: startRowBounds.top, - plannedRows: plannedRows.slice(startIndex, endIndex), + startIndex, + endIndex, // The bottom spacer is the remaining body height after the last mounted row's bottom edge. - bottomSpacerHeight: Math.max( - 0, - sectionGeometry.bodyHeight - (endRowBounds.top + endRowBounds.height), - ), + bottomSpacerHeight: Math.max(0, bodyHeight - (endRowBounds.top + endRowBounds.height)), + }; +} + +/** + * Slice planned rows down to the visible body range while preserving total section height. + * Geometry and planned rows share array order, so only the final visible slice is allocated. + */ +export function resolveVisiblePlannedRowWindow({ + plannedRows, + sectionGeometry, + visibleBodyBounds, +}: { + plannedRows: PlannedReviewRow[]; + sectionGeometry: DiffSectionGeometry; + visibleBodyBounds: VisibleBodyBounds; +}): VisiblePlannedRowWindow { + if (plannedRows.length === 0 || sectionGeometry.rowBounds.length !== plannedRows.length) { + return { bottomSpacerHeight: 0, plannedRows, topSpacerHeight: 0 }; + } + const window = resolveVisibleRowIndexWindow({ + bodyHeight: sectionGeometry.bodyHeight, + rowBounds: sectionGeometry.rowBounds, + visibleBodyBounds, + }); + return { + bottomSpacerHeight: window.bottomSpacerHeight, + plannedRows: plannedRows.slice(window.startIndex, window.endIndex), + topSpacerHeight: window.topSpacerHeight, }; } diff --git a/src/ui/fileViews/availability.test.ts b/src/ui/fileViews/availability.test.ts new file mode 100644 index 00000000..31d744f3 --- /dev/null +++ b/src/ui/fileViews/availability.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, test } from "bun:test"; +import { + availableFileViewSelections, + FILE_VIEW_DRAFT_UNAVAILABLE_REASON, + fileViewUnavailableReason, +} from "./availability"; + +describe("file-view availability", () => { + test("leaves committed note placement to validated alternate-view bindings", () => { + // A file carrying agent or user notes no longer forces raw: placement is decided by the + // validated source bindings in the render plan, so drafting is the only host constraint left. + expect(fileViewUnavailableReason({ hasDraftNote: false })).toBeNull(); + }); + + test("requires raw diff while a draft note is being edited", () => { + expect(fileViewUnavailableReason({ hasDraftNote: true })).toBe( + FILE_VIEW_DRAFT_UNAVAILABLE_REASON, + ); + }); + + test("masks unavailable selections without discarding stored choices", () => { + const selections = { readme: "preview:rendered", other: "ext:view" }; + expect( + availableFileViewSelections( + selections, + new Map([["readme", FILE_VIEW_DRAFT_UNAVAILABLE_REASON]]), + ), + ).toEqual({ other: "ext:view" }); + expect(selections).toEqual({ readme: "preview:rendered", other: "ext:view" }); + }); +}); diff --git a/src/ui/fileViews/availability.ts b/src/ui/fileViews/availability.ts new file mode 100644 index 00000000..0d98d18e --- /dev/null +++ b/src/ui/fileViews/availability.ts @@ -0,0 +1,19 @@ +export const FILE_VIEW_DRAFT_UNAVAILABLE_REASON = + "File presentations are unavailable while drafting an inline review note • using raw diff"; + +/** Draft editing remains raw-only; committed notes are resolved from validated source bindings. */ +export function fileViewUnavailableReason({ hasDraftNote }: { hasDraftNote: boolean }) { + return hasDraftNote ? FILE_VIEW_DRAFT_UNAVAILABLE_REASON : null; +} + +/** Mask stored choices only while a host constraint requires raw rendering. */ +export function availableFileViewSelections( + selections: Readonly>, + unavailableReasons: ReadonlyMap, +) { + const available: Record = {}; + for (const [fileId, viewKey] of Object.entries(selections)) { + if (!unavailableReasons.has(fileId)) available[fileId] = viewKey; + } + return available; +} diff --git a/src/ui/fileViews/geometry.test.ts b/src/ui/fileViews/geometry.test.ts new file mode 100644 index 00000000..92c41e04 --- /dev/null +++ b/src/ui/fileViews/geometry.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, test } from "bun:test"; +import type { ExtensionFileViewLayout } from "../../extension-api/types"; +import { measureAgentInlineNoteHeight } from "../components/panes/AgentInlineNote"; +import { measureFileViewGeometry } from "./geometry"; +import { validateFileViewLayout } from "./layout"; +import { buildFileViewRenderPlan } from "./renderPlan"; + +describe("file-view geometry", () => { + test("uses declared component heights while retaining stable row ids and hunk bounds", () => { + const layout: ExtensionFileViewLayout = { + rows: [ + { id: "intro", spans: [{ text: "intro" }] }, + { + id: "custom-a", + spans: [{ text: "custom fallback" }], + component: { height: 3, render: () => null }, + }, + { + id: "custom-b", + spans: [{ text: "custom fallback" }], + component: { height: 2, render: () => null }, + }, + ], + hunkRows: [ + { startRow: 0, endRow: 1 }, + { startRow: 2, endRow: 2 }, + ], + }; + + const checked = validateFileViewLayout(layout, 2, 80); + if (!checked.valid) throw new Error(checked.issue); + const geometry = measureFileViewGeometry({ + resolved: checked.value, + plannedRows: buildFileViewRenderPlan(checked.value.layout, []).rows, + width: 80, + }); + + expect(geometry.rowBounds.map((row) => row.height)).toEqual([...checked.value.rowHeights]); + expect(geometry.bodyHeight).toBe(6); + expect( + geometry.rowBounds.map(({ stableKey, top, height }) => ({ + stableKey, + top, + height, + })), + ).toEqual([ + { stableKey: "file-view:intro", top: 0, height: 1 }, + { stableKey: "file-view:custom-a", top: 1, height: 3 }, + { stableKey: "file-view:custom-b", top: 4, height: 2 }, + ]); + expect(geometry.hunkAnchorRows).toEqual( + new Map([ + [0, 0], + [1, 4], + ]), + ); + expect(geometry.hunkBounds.get(0)).toMatchObject({ top: 0, height: 4 }); + expect(geometry.hunkBounds.get(1)).toMatchObject({ top: 4, height: 2 }); + }); + + test("measures 10,000 rows and hunks through linear retained extents", () => { + const checked = validateFileViewLayout( + { + rows: Array.from({ length: 10_000 }, (_, index) => ({ + id: `row-${index}`, + spans: [{ text: `${index}` }], + })), + hunkRows: Array.from({ length: 10_000 }, (_, index) => ({ + startRow: index, + endRow: index, + })), + }, + 10_000, + 80, + ); + if (!checked.valid) throw new Error(checked.issue); + const geometry = measureFileViewGeometry({ + resolved: checked.value, + plannedRows: buildFileViewRenderPlan(checked.value.layout, []).rows, + width: 80, + }); + + expect(geometry.bodyHeight).toBe(10_000); + expect(geometry.hunkAnchorRows.get(9_999)).toBe(9_999); + expect(geometry.hunkBounds.get(9_999)).toMatchObject({ top: 9_999, height: 1 }); + }); + + test("measures host notes and underlying rows from one planned stream", () => { + const checked = validateFileViewLayout( + { + rows: [ + { + id: "summary", + spans: [{ text: "summary" }], + sourceRanges: [{ side: "new", range: [1, 2] }], + component: { height: 2, render: () => null }, + }, + ], + hunkRows: [{ startRow: 0, endRow: 0 }], + }, + 1, + 80, + ); + if (!checked.valid) throw new Error(checked.issue); + const annotation = { + id: "note", + summary: "Review this range", + newRange: [1, 1] as [number, number], + }; + const plan = buildFileViewRenderPlan(checked.value.layout, [{ id: "note", annotation }]); + const noteHeight = measureAgentInlineNoteHeight({ + annotation, + anchorSide: "new", + layout: "stack", + width: 80, + }); + const geometry = measureFileViewGeometry({ + resolved: checked.value, + plannedRows: plan.rows, + width: 80, + }); + + expect(geometry.fileViewRows).toBe(plan.rows); + expect(geometry.rowBounds.map((row) => row.height)).toEqual([noteHeight, 2]); + expect(geometry.bodyHeight).toBe(noteHeight + 2); + expect(geometry.hunkAnchorRows.get(0)).toBe(noteHeight); + expect(geometry.hunkBounds.get(0)).toMatchObject({ top: 0, height: noteHeight + 2 }); + }); +}); diff --git a/src/ui/fileViews/geometry.ts b/src/ui/fileViews/geometry.ts new file mode 100644 index 00000000..bc6ff815 --- /dev/null +++ b/src/ui/fileViews/geometry.ts @@ -0,0 +1,108 @@ +import { measureAgentInlineNoteHeight } from "../components/panes/AgentInlineNote"; +import { reviewRowId } from "../lib/ids"; +import type { PlannedHunkBounds } from "../diff/plannedReviewRows"; +import type { DiffSectionGeometry, DiffSectionRowBounds } from "../diff/diffSectionGeometry"; +import type { ValidatedFileViewLayout } from "./layout"; +import type { PlannedFileViewRow } from "./renderPlan"; + +/** Measure one extension or host-note row in the alternate presentation stream. */ +function plannedFileViewRowHeight( + row: PlannedFileViewRow, + resolved: ValidatedFileViewLayout, + width: number, +) { + if (row.kind === "file-view-row") { + return resolved.rowHeights[row.rowIndex]!; + } + + return measureAgentInlineNoteHeight({ + annotation: row.annotation, + anchorSide: row.anchorSide, + // Alternate presentations are one full-width stack even when raw code uses split columns. + layout: "stack", + width, + }); +} + +/** + * Build host-owned scroll, note, and hunk geometry for one alternate file presentation. + * + * `plannedRows` and `width` are required together: note heights depend on the same content width the + * rows are painted at, so a defaulted width would silently measure notes for the wrong terminal. + */ +export function measureFileViewGeometry({ + resolved, + plannedRows, + width, +}: { + resolved: ValidatedFileViewLayout; + plannedRows: readonly PlannedFileViewRow[]; + width: number; +}): DiffSectionGeometry { + const { layout } = resolved; + const rowBounds: DiffSectionRowBounds[] = []; + const rowBoundsByKey = new Map(); + const rowBoundsByStableKey = new Map(); + let bodyHeight = 0; + + for (const row of plannedRows) { + const entry: DiffSectionRowBounds = { + key: row.key, + stableKey: row.stableKey, + stableKeys: [row.stableKey], + top: bodyHeight, + height: plannedFileViewRowHeight(row, resolved, width), + }; + rowBounds.push(entry); + rowBoundsByKey.set(entry.key, entry); + if (!rowBoundsByStableKey.has(entry.stableKey)) { + rowBoundsByStableKey.set(entry.stableKey, entry); + } + bodyHeight += entry.height; + } + + const planExtentsByRow = Array.from({ length: layout.rows.length }, () => ({ + anchor: -1, + first: -1, + last: -1, + })); + for (const [planIndex, row] of plannedRows.entries()) { + const rowIndex = row.kind === "file-view-row" ? row.rowIndex : row.anchorRowIndex; + const extent = planExtentsByRow[rowIndex]!; + if (extent.first < 0) extent.first = planIndex; + extent.last = planIndex; + if (row.kind === "file-view-row") extent.anchor = planIndex; + } + + const hunkAnchorRows = new Map(); + const hunkBounds = new Map(); + for (const [hunkIndex, hunk] of layout.hunkRows.entries()) { + const startExtent = planExtentsByRow[hunk.startRow]!; + const endExtent = planExtentsByRow[hunk.endRow]!; + if (startExtent.anchor < 0 || startExtent.first < 0 || endExtent.last < 0) continue; + + const anchor = rowBounds[startExtent.anchor]!; + const start = rowBounds[startExtent.first]!; + const end = rowBounds[endExtent.last]!; + hunkAnchorRows.set(hunkIndex, anchor.top); + hunkBounds.set(hunkIndex, { + top: start.top, + height: end.top + end.height - start.top, + startRowId: reviewRowId(start.key), + endRowId: reviewRowId(end.key), + }); + } + + return { + bodyHeight, + hunkAnchorRows, + hunkBounds, + lineNumberDigits: 1, + // Alternate rows are not Pierre rows, so raw copy selection intentionally remains unavailable. + plannedRows: [], + fileViewRows: plannedRows, + rowBounds, + rowBoundsByKey, + rowBoundsByStableKey, + }; +} diff --git a/src/ui/fileViews/host.test.ts b/src/ui/fileViews/host.test.ts new file mode 100644 index 00000000..9f31c92d --- /dev/null +++ b/src/ui/fileViews/host.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, test } from "bun:test"; +import { + createTestDeferred, + createTestDiffFile, + createTestSourceFetcher, +} from "../../../test/helpers/diff-helpers"; +import { createFileViewInput, createFileViewInputSnapshot, fileViewChanges } from "./host"; + +describe("file-view host input", () => { + test("exposes only deeply frozen added and removed ranges", () => { + const changes = fileViewChanges( + createTestDiffFile({ + before: "before\nstable\nremoved\n", + after: "after\nstable\nadded\n", + context: 1, + }), + ); + + expect(changes.length).toBeGreaterThan(0); + expect(new Set(changes.map((change) => change.kind))).toEqual(new Set(["added", "removed"])); + expect(changes.every((change) => !("side" in change))).toBe(true); + expect(Object.isFrozen(changes)).toBe(true); + expect( + changes.every((change) => Object.isFrozen(change) && Object.isFrozen(change.range)), + ).toBe(true); + }); + + test("reuses one immutable file-and-change snapshot for matching and layout", () => { + const diffFile = createTestDiffFile(); + const snapshot = createFileViewInputSnapshot(diffFile); + const input = createFileViewInput(diffFile, 72, new AbortController().signal, snapshot); + + expect(input.file).toBe(snapshot.file); + expect(input.changes).toBe(snapshot.changes); + expect(Object.isFrozen(snapshot)).toBe(true); + }); + + test("exposes one frozen input, deduplicates string reads, and binds cancellation", async () => { + const deferred = createTestDeferred(); + const sourceFetcher = createTestSourceFetcher(() => deferred.promise); + const controller = new AbortController(); + const input = createFileViewInput(createTestDiffFile({ sourceFetcher }), 72, controller.signal); + + const first = input.readDocument("new"); + const second = input.readDocument("new"); + expect(sourceFetcher.calls).toEqual(["new"]); + expect(input.width).toBe(72); + expect(input.signal).toBe(controller.signal); + expect(Object.isFrozen(input)).toBe(true); + + controller.abort(); + await expect(first).rejects.toMatchObject({ name: "AbortError" }); + await expect(second).rejects.toMatchObject({ name: "AbortError" }); + deferred.resolve("after"); + }); + + test("returns exact document text directly", async () => { + const input = createFileViewInput( + createTestDiffFile({ + sourceFetcher: createTestSourceFetcher(async () => "after"), + }), + 80, + new AbortController().signal, + ); + + await expect(input.readDocument("new")).resolves.toBe("after"); + }); +}); diff --git a/src/ui/fileViews/host.ts b/src/ui/fileViews/host.ts new file mode 100644 index 00000000..c2e57a31 --- /dev/null +++ b/src/ui/fileViews/host.ts @@ -0,0 +1,116 @@ +import type { DiffFile } from "../../core/types"; +import type { + ExtensionDiffFile, + ExtensionFileChangeRange, + ExtensionFileSide, + ExtensionFileViewInput, +} from "../../extension-api/types"; +import { readMetadataHunkSummaries, toReadOnlyFileViews } from "../../extensions/events"; + +/** Abort one caller's wait without cancelling the host's shared source read. */ +function waitWithSignal(promise: Promise, signal: AbortSignal): Promise { + if (signal.aborted) { + return Promise.reject(new DOMException("The file-view request was aborted.", "AbortError")); + } + + return new Promise((resolve, reject) => { + const abort = () => + reject(new DOMException("The file-view request was aborted.", "AbortError")); + signal.addEventListener("abort", abort, { once: true }); + promise.then( + (value) => { + signal.removeEventListener("abort", abort); + resolve(value); + }, + (error: unknown) => { + signal.removeEventListener("abort", abort); + reject(error); + }, + ); + }); +} + +/** Build public added/removed ranges from parsed hunks, without leaking Pierre types. */ +export function fileViewChanges(file: DiffFile): readonly ExtensionFileChangeRange[] { + const changes: ExtensionFileChangeRange[] = []; + for (const [hunkIndex, hunk] of file.metadata.hunks.entries()) { + let oldLine = hunk.deletionStart; + let newLine = hunk.additionStart; + for (const chunk of hunk.hunkContent) { + if (chunk.type === "context") { + oldLine += chunk.lines; + newLine += chunk.lines; + continue; + } + if (chunk.deletions > 0) { + changes.push({ + hunkIndex, + kind: "removed", + range: [oldLine, oldLine + chunk.deletions - 1], + }); + } + if (chunk.additions > 0) { + changes.push({ + hunkIndex, + kind: "added", + range: [newLine, newLine + chunk.additions - 1], + }); + } + oldLine += chunk.deletions; + newLine += chunk.additions; + } + } + return Object.freeze( + changes.map((change) => + Object.freeze({ + ...change, + range: Object.freeze([...change.range]) as readonly [number, number], + }), + ), + ); +} + +/** Immutable public file data derived once for matching and layout. */ +export interface FileViewInputSnapshot { + readonly file: ExtensionDiffFile; + readonly changes: readonly ExtensionFileChangeRange[]; +} + +/** Derive immutable file data shared by matching and one subsequent layout request. */ +export function createFileViewInputSnapshot(file: DiffFile): FileViewInputSnapshot { + return Object.freeze({ + file: toReadOnlyFileViews([file])[0]!, + changes: fileViewChanges(file), + }); +} + +/** Build the frozen public input for one layout request from reusable immutable file data. */ +export function createFileViewInput( + file: DiffFile, + width: number, + signal: AbortSignal, + snapshot: FileViewInputSnapshot = createFileViewInputSnapshot(file), +): ExtensionFileViewInput { + const reads = new Map>(); + return Object.freeze({ + file: snapshot.file, + width, + signal, + changes: snapshot.changes, + readDocument(side: ExtensionFileSide) { + let read = reads.get(side); + if (!read) { + read = file.sourceFetcher + ? file.sourceFetcher.getFullText(side).catch(() => null) + : Promise.resolve(null); + reads.set(side, read); + } + return waitWithSignal(read, signal); + }, + }); +} + +/** Read the hunk count through the public conversion boundary. */ +export function fileViewHunkCount(file: DiffFile) { + return readMetadataHunkSummaries(file.metadata).length; +} diff --git a/src/ui/fileViews/layout.test.ts b/src/ui/fileViews/layout.test.ts new file mode 100644 index 00000000..52a97bf8 --- /dev/null +++ b/src/ui/fileViews/layout.test.ts @@ -0,0 +1,351 @@ +import { describe, expect, test } from "bun:test"; +import { validateFileViewLayout, validateFileViewSourceRanges } from "./layout"; + +describe("file-view layout validation", () => { + test("accepts deterministic symbolic rows and measures terminal-width wrapping", () => { + const result = validateFileViewLayout( + { + rows: [ + { + id: "heading", + spans: [{ text: "# title", tone: "accent", attributes: ["bold"] }], + }, + { id: "wide", spans: [{ text: "界界" }] }, + ], + hunkRows: [{ startRow: 0, endRow: 1 }], + }, + 1, + 3, + ); + + expect(result).toMatchObject({ valid: true }); + if (result.valid) expect(result.value.rowHeights).toEqual([3, 2]); + }); + + test("returns a deeply immutable host snapshot detached from extension mutation", () => { + const firstRender = () => "first"; + const secondRender = () => "second"; + const attributes = ["bold"] as ("bold" | "italic")[]; + const span: { + text: string; + tone: "accent" | "removed"; + attributes: ("bold" | "italic")[]; + } = { text: "original", tone: "accent", attributes }; + const component = { height: 2, render: firstRender }; + const sourceRange = { side: "new" as const, range: [1, 2] }; + const row = { id: "original-row", spans: [span], sourceRanges: [sourceRange], component }; + const hunk = { startRow: 0, endRow: 0 }; + const source = { rows: [row], hunkRows: [hunk] }; + + const result = validateFileViewLayout(source, 1, 80); + expect(result.valid).toBe(true); + if (!result.valid) return; + + row.id = "mutated-row"; + span.text = "mutated"; + span.tone = "removed"; + attributes[0] = "italic"; + component.height = 9; + component.render = secondRender; + sourceRange.range[0] = 99; + hunk.startRow = 99; + source.rows.length = 0; + source.hunkRows.length = 0; + + expect(result.value).toEqual({ + layout: { + rows: [ + { + id: "original-row", + spans: [{ text: "original", tone: "accent", attributes: ["bold"] }], + sourceRanges: [{ side: "new", range: [1, 2] }], + component: { height: 2, render: firstRender }, + }, + ], + hunkRows: [{ startRow: 0, endRow: 0 }], + }, + rowHeights: [2], + }); + expect( + [ + result.value, + result.value.layout, + result.value.layout.rows, + result.value.layout.rows[0], + result.value.layout.rows[0]?.spans, + result.value.layout.rows[0]?.spans[0], + result.value.layout.rows[0]?.spans[0]?.attributes, + result.value.layout.rows[0]?.sourceRanges, + result.value.layout.rows[0]?.sourceRanges?.[0], + result.value.layout.rows[0]?.sourceRanges?.[0]?.range, + result.value.layout.rows[0]?.component, + result.value.layout.hunkRows, + result.value.layout.hunkRows[0], + result.value.rowHeights, + ].every(Object.isFrozen), + ).toBe(true); + }); + + test("validates exact source bindings and rejects ambiguous mappings", () => { + const valid = validateFileViewLayout( + { + rows: [ + { id: "old", spans: [], sourceRanges: [{ side: "old", range: [1, 2] }] }, + { id: "new", spans: [], sourceRanges: [{ side: "new", range: [2, 3] }] }, + ], + hunkRows: [{ startRow: 0, endRow: 1 }], + }, + 1, + 80, + ); + expect(valid).toMatchObject({ valid: true }); + if (!valid.valid) return; + expect( + validateFileViewSourceRanges(valid.value.layout, { old: "a\nb\n", new: "a\nb\nc" }), + ).toBeNull(); + // Unreadable source and a wrong binding are reported as different kinds so the host can + // attribute an environment condition separately from an extension mistake. + expect(validateFileViewSourceRanges(valid.value.layout, { old: "a\nb\n", new: null })).toEqual({ + kind: "unavailable-source", + detail: "rows[1].sourceRanges[0] targets unavailable new source", + }); + expect( + validateFileViewSourceRanges(valid.value.layout, { old: "a\n", new: "a\nb\nc" }), + ).toEqual({ + kind: "out-of-bounds", + detail: "rows[0].sourceRanges[0] exceeds the old source bounds", + }); + + expect( + validateFileViewLayout( + { + rows: [ + { + id: "aggregate", + spans: [], + sourceRanges: [ + { side: "new", range: [1, 2] }, + { side: "new", range: [2, 3] }, + ], + }, + ], + hunkRows: [{ startRow: 0, endRow: 0 }], + }, + 1, + 80, + ), + ).toMatchObject({ valid: true }); + + expect( + validateFileViewLayout( + { + rows: [ + { id: "one", spans: [], sourceRanges: [{ side: "new", range: [1, 3] }] }, + { id: "two", spans: [], sourceRanges: [{ side: "new", range: [3, 4] }] }, + ], + hunkRows: [], + }, + 0, + 80, + ), + ).toEqual({ + valid: false, + issue: "new-side source ranges overlap between rows[0] and rows[1]", + }); + expect( + validateFileViewLayout( + { + rows: [{ id: "shared", spans: [], sourceRanges: [{ side: "new", range: [1, 1] }] }], + hunkRows: [ + { startRow: 0, endRow: 0 }, + { startRow: 0, endRow: 0 }, + ], + }, + 2, + 80, + ), + ).toEqual({ + valid: false, + issue: "rows[0].sourceRanges must belong to exactly one hunkRows range", + }); + + expect( + validateFileViewLayout( + { + rows: [{ id: "bad", spans: [], sourceRanges: [{ side: "new", range: [0, 1] }] }], + hunkRows: [], + }, + 0, + 80, + ), + ).toEqual({ + valid: false, + issue: "rows[0].sourceRanges[0] is not a valid one-based source range", + }); + }); + + test("validates the maximum source-range count against each document once", () => { + const sourceRanges = Array.from({ length: 40_000 }, () => ({ + side: "new" as const, + range: [1, 1] as const, + })); + const result = validateFileViewLayout( + { + rows: [{ id: "aggregate", spans: [], sourceRanges }], + hunkRows: [{ startRow: 0, endRow: 0 }], + }, + 1, + 80, + ); + expect(result).toMatchObject({ valid: true }); + if (result.valid) { + expect(validateFileViewSourceRanges(result.value.layout, { new: "line\n" })).toBeNull(); + } + }); + + test("accepts bounded custom row painters with an atomic fixed-height descriptor", () => { + const render = () => null; + const result = validateFileViewLayout( + { + rows: [ + { + id: "custom", + spans: [{ text: "fallback" }], + component: { height: 4, render }, + }, + ], + hunkRows: [{ startRow: 0, endRow: 0 }], + }, + 1, + 80, + ); + + expect(result).toMatchObject({ valid: true }); + if (result.valid) { + expect(result.value.rowHeights).toEqual([4]); + expect(result.value.layout.rows[0]?.component?.render).toBe(render); + } + }); + + test("rejects invalid and resource-heavy custom row descriptors", () => { + expect( + validateFileViewLayout( + { + rows: [{ id: "invalid", spans: [], component: "not an object" }], + hunkRows: [], + }, + 0, + 80, + ), + ).toEqual({ valid: false, issue: "rows[0].component is not an object" }); + + expect( + validateFileViewLayout( + { + rows: [ + { + id: "invalid", + spans: [], + component: { height: 2, render: "nope" }, + }, + ], + hunkRows: [], + }, + 0, + 80, + ), + ).toEqual({ + valid: false, + issue: "rows[0].component.render is not a function", + }); + + expect( + validateFileViewLayout( + { + rows: [ + { + id: "tall", + spans: [], + component: { height: 257, render: () => null }, + }, + ], + hunkRows: [], + }, + 0, + 80, + ), + ).toEqual({ + valid: false, + issue: "rows[0].component.height must be an integer from 1 to 256", + }); + + const rows = Array.from({ length: 391 }, (_, index) => ({ + id: `row-${index}`, + spans: [], + component: { height: 256, render: () => null }, + })); + expect(validateFileViewLayout({ rows, hunkRows: [] }, 0, 80)).toEqual({ + valid: false, + issue: "layout exceeds 100000 terminal rows", + }); + + const symbolicRows = Array.from({ length: 10_000 }, (_, index) => ({ + id: `symbolic-${index}`, + spans: [{ text: "xxxxxxxxxxx" }], + })); + expect(validateFileViewLayout({ rows: symbolicRows, hunkRows: [] }, 0, 1)).toEqual({ + valid: false, + issue: "layout exceeds 100000 terminal rows", + }); + }); + + test("rejects layouts that cannot supply positional host-owned hunk geometry", () => { + const result = validateFileViewLayout( + { + rows: [{ id: "one", spans: [{ text: "one" }] }], + hunkRows: [{ startRow: 0, endRow: 0 }], + }, + 2, + 80, + ); + + expect(result).toEqual({ + valid: false, + issue: "layout has 1 hunk bounds for 2 hunks", + }); + }); + + test("rejects duplicate row ids and non-generic presentation values", () => { + const duplicate = validateFileViewLayout( + { + rows: [ + { id: "same", spans: [{ text: "one" }] }, + { id: "same", spans: [{ text: "two" }] }, + ], + hunkRows: [], + }, + 0, + 80, + ); + expect(duplicate).toMatchObject({ + valid: false, + issue: 'rows[1] repeats id "same"', + }); + + for (const tone of ["heading", "text"]) { + expect( + validateFileViewLayout( + { + rows: [{ id: "one", spans: [{ text: "one", tone }] }], + hunkRows: [], + }, + 0, + 80, + ), + ).toEqual({ + valid: false, + issue: "rows[0] contains an invalid span tone", + }); + } + }); +}); diff --git a/src/ui/fileViews/layout.ts b/src/ui/fileViews/layout.ts new file mode 100644 index 00000000..7e71400c --- /dev/null +++ b/src/ui/fileViews/layout.ts @@ -0,0 +1,350 @@ +import type { + ExtensionFileSide, + ExtensionFileViewLayout, + ExtensionFileViewRow, + ExtensionFileViewSourceRange, +} from "../../extension-api/types"; +import { measureSanitizedTextWidth, wrapSanitizedTextByWidth } from "../lib/text"; + +/** Resource limits keep one extension layout from exhausting the review stream. */ +export const FILE_VIEW_MAX_ROWS = 10_000; +export const FILE_VIEW_MAX_SPANS = 40_000; +export const FILE_VIEW_MAX_TEXT_LENGTH = 1_000_000; +export const FILE_VIEW_MAX_COMPONENT_ROW_HEIGHT = 256; +export const FILE_VIEW_MAX_SOURCE_RANGES = 40_000; +/** Maximum measured terminal height across every symbolic and component row. */ +export const FILE_VIEW_MAX_LAYOUT_HEIGHT = 100_000; + +const FILE_VIEW_TONES = new Set(["muted", "accent", "accent-muted", "syntax", "added", "removed"]); +const FILE_VIEW_TEXT_ATTRIBUTES = new Set(["bold", "italic", "underline", "strikethrough"]); + +export interface ValidatedFileViewLayout { + layout: ExtensionFileViewLayout; + /** Number of terminal rows each symbolic row occupies at the requested width. */ + rowHeights: readonly number[]; +} + +/** Validate finite zero-based row coordinates. */ +function isRowIndex(value: unknown, rowCount: number): value is number { + return Number.isInteger(value) && (value as number) >= 0 && (value as number) < rowCount; +} + +/** Explain why an extension result cannot safely join the host-owned review stream. */ +export function validateFileViewLayout( + value: unknown, + hunkCount: number, + width: number, +): { valid: true; value: ValidatedFileViewLayout } | { valid: false; issue: string } { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return { valid: false, issue: "layout is not an object" }; + } + + const layout = value as ExtensionFileViewLayout; + if (!Array.isArray(layout.rows) || !Array.isArray(layout.hunkRows)) { + return { + valid: false, + issue: "layout must include rows and hunkRows arrays", + }; + } + if (layout.rows.length > FILE_VIEW_MAX_ROWS) { + return { + valid: false, + issue: `layout has more than ${FILE_VIEW_MAX_ROWS} rows`, + }; + } + + const ids = new Set(); + let spanCount = 0; + let sourceRangeCount = 0; + let textLength = 0; + let layoutHeight = 0; + const sourceRangesBySide: Record< + ExtensionFileSide, + Array<{ range: readonly [number, number]; rowIndex: number }> + > = { old: [], new: [] }; + const rows: ExtensionFileViewRow[] = []; + const rowHeights: number[] = []; + const usableWidth = Math.max(1, Math.floor(width)); + + for (const [index, row] of layout.rows.entries()) { + if (!row || typeof row !== "object" || typeof row.id !== "string" || row.id.length === 0) { + return { valid: false, issue: `rows[${index}] has no non-empty id` }; + } + if (ids.has(row.id)) { + return { valid: false, issue: `rows[${index}] repeats id "${row.id}"` }; + } + ids.add(row.id); + const component = row.component; + let componentSnapshot: ExtensionFileViewRow["component"]; + if (component !== undefined) { + if (!component || typeof component !== "object" || Array.isArray(component)) { + return { + valid: false, + issue: `rows[${index}].component is not an object`, + }; + } + const render = component.render; + const height = component.height; + if (typeof render !== "function") { + return { + valid: false, + issue: `rows[${index}].component.render is not a function`, + }; + } + if (!Number.isInteger(height) || height < 1 || height > FILE_VIEW_MAX_COMPONENT_ROW_HEIGHT) { + return { + valid: false, + issue: `rows[${index}].component.height must be an integer from 1 to ${FILE_VIEW_MAX_COMPONENT_ROW_HEIGHT}`, + }; + } + componentSnapshot = Object.freeze({ height, render }); + } + if (!Array.isArray(row.spans)) { + return { valid: false, issue: `rows[${index}].spans is not an array` }; + } + + let rowText = ""; + const spans: ExtensionFileViewRow["spans"][number][] = []; + for (const span of row.spans) { + spanCount += 1; + if (spanCount > FILE_VIEW_MAX_SPANS) { + return { + valid: false, + issue: `layout has more than ${FILE_VIEW_MAX_SPANS} spans`, + }; + } + if (!span || typeof span.text !== "string" || span.text.includes("\n")) { + return { + valid: false, + issue: `rows[${index}] contains an invalid span`, + }; + } + if (span.tone !== undefined && !FILE_VIEW_TONES.has(span.tone)) { + return { + valid: false, + issue: `rows[${index}] contains an invalid span tone`, + }; + } + if ( + span.attributes !== undefined && + (!Array.isArray(span.attributes) || + span.attributes.some( + (attribute: unknown) => + typeof attribute !== "string" || !FILE_VIEW_TEXT_ATTRIBUTES.has(attribute), + )) + ) { + return { + valid: false, + issue: `rows[${index}] contains invalid span attributes`, + }; + } + const text = span.text; + const tone = span.tone; + const attributes = span.attributes ? Object.freeze([...span.attributes]) : undefined; + textLength += text.length; + if (textLength > FILE_VIEW_MAX_TEXT_LENGTH) { + return { + valid: false, + issue: `layout text exceeds ${FILE_VIEW_MAX_TEXT_LENGTH} characters`, + }; + } + rowText += text; + spans.push( + Object.freeze({ + text, + ...(tone === undefined ? {} : { tone }), + ...(attributes === undefined ? {} : { attributes }), + }), + ); + } + let sourceRangesSnapshot: readonly ExtensionFileViewSourceRange[] | undefined; + if (row.sourceRanges !== undefined) { + if (!Array.isArray(row.sourceRanges)) { + return { valid: false, issue: `rows[${index}].sourceRanges is not an array` }; + } + const sourceRanges: ExtensionFileViewSourceRange[] = []; + for (const [rangeIndex, sourceRange] of row.sourceRanges.entries()) { + sourceRangeCount += 1; + if (sourceRangeCount > FILE_VIEW_MAX_SOURCE_RANGES) { + return { + valid: false, + issue: `layout has more than ${FILE_VIEW_MAX_SOURCE_RANGES} source ranges`, + }; + } + const side: unknown = sourceRange?.side; + const range: unknown = sourceRange?.range; + if ( + !sourceRange || + (side !== "old" && side !== "new") || + !Array.isArray(range) || + range.length !== 2 || + !Number.isInteger(range[0]) || + !Number.isInteger(range[1]) || + range[0] < 1 || + range[0] > range[1] + ) { + return { + valid: false, + issue: `rows[${index}].sourceRanges[${rangeIndex}] is not a valid one-based source range`, + }; + } + const rangeSnapshot = Object.freeze([range[0], range[1]]) as readonly [number, number]; + const validatedSide: ExtensionFileSide = side; + sourceRangesBySide[validatedSide].push({ range: rangeSnapshot, rowIndex: index }); + sourceRanges.push(Object.freeze({ side: validatedSide, range: rangeSnapshot })); + } + sourceRangesSnapshot = Object.freeze(sourceRanges); + } + + // Measure exactly once at validation. Geometry consumes this retained value without rewrapping. + const rowHeight = componentSnapshot + ? componentSnapshot.height + : Math.max(1, wrapSanitizedTextByWidth(rowText, usableWidth).length); + layoutHeight += rowHeight; + if (layoutHeight > FILE_VIEW_MAX_LAYOUT_HEIGHT) { + return { + valid: false, + issue: `layout exceeds ${FILE_VIEW_MAX_LAYOUT_HEIGHT} terminal rows`, + }; + } + rowHeights.push(rowHeight); + rows.push( + Object.freeze({ + id: row.id, + spans: Object.freeze(spans), + ...(sourceRangesSnapshot === undefined ? {} : { sourceRanges: sourceRangesSnapshot }), + ...(componentSnapshot === undefined ? {} : { component: componentSnapshot }), + }), + ); + } + + for (const side of ["old", "new"] as const) { + const sorted = sourceRangesBySide[side].sort( + (left, right) => left.range[0] - right.range[0] || left.range[1] - right.range[1], + ); + let furthest = sorted[0]; + for (let index = 1; index < sorted.length; index += 1) { + const current = sorted[index]!; + if ( + furthest && + current.range[0] <= furthest.range[1] && + current.rowIndex !== furthest.rowIndex + ) { + return { + valid: false, + issue: `${side}-side source ranges overlap between rows[${furthest.rowIndex}] and rows[${current.rowIndex}]`, + }; + } + if (!furthest || current.range[1] > furthest.range[1]) furthest = current; + } + } + + if (layout.hunkRows.length !== hunkCount) { + return { + valid: false, + issue: `layout has ${layout.hunkRows.length} hunk bounds for ${hunkCount} hunks`, + }; + } + const hunkRows: ExtensionFileViewLayout["hunkRows"][number][] = []; + for (const [position, hunk] of layout.hunkRows.entries()) { + const startRow = hunk?.startRow; + const endRow = hunk?.endRow; + if ( + !hunk || + !isRowIndex(startRow, layout.rows.length) || + !isRowIndex(endRow, layout.rows.length) || + startRow > endRow + ) { + return { + valid: false, + issue: `hunkRows[${position}] is not an in-bounds row range`, + }; + } + hunkRows.push(Object.freeze({ startRow, endRow })); + } + + const hunkOwnerDeltas = new Int32Array(rows.length + 1); + for (const hunk of hunkRows) { + hunkOwnerDeltas[hunk.startRow]! += 1; + hunkOwnerDeltas[hunk.endRow + 1]! -= 1; + } + let hunkOwnerCount = 0; + for (const [rowIndex, row] of rows.entries()) { + hunkOwnerCount += hunkOwnerDeltas[rowIndex]!; + if ((row.sourceRanges?.length ?? 0) > 0 && hunkOwnerCount !== 1) { + return { + valid: false, + issue: `rows[${rowIndex}].sourceRanges must belong to exactly one hunkRows range`, + }; + } + } + + const snapshot = Object.freeze({ + rows: Object.freeze(rows), + hunkRows: Object.freeze(hunkRows), + }); + return { + valid: true, + value: Object.freeze({ layout: snapshot, rowHeights: Object.freeze(rowHeights) }), + }; +} + +/** Count one-based source lines without inventing a line after a trailing newline. */ +function exactSourceLineCount(source: string) { + if (source.length === 0) return 0; + const withoutTerminator = source.endsWith("\n") ? source.slice(0, -1) : source; + return withoutTerminator.split("\n").length; +} + +/** + * Why one accepted layout's row bindings could not be verified. + * + * `unavailable-source` is an environment condition the host could not resolve; `out-of-bounds` is a + * binding the extension got wrong. Callers report them differently so attribution stays honest. + */ +export interface FileViewSourceBindingIssue { + readonly kind: "unavailable-source" | "out-of-bounds"; + readonly detail: string; +} + +/** Validate accepted row bindings against the exact source documents the host can read. */ +export function validateFileViewSourceRanges( + layout: ExtensionFileViewLayout, + documents: Readonly>>, +): FileViewSourceBindingIssue | null { + const lineCounts: Partial> = {}; + for (const side of ["old", "new"] as const) { + const source = documents[side]; + lineCounts[side] = typeof source === "string" ? exactSourceLineCount(source) : null; + } + + for (const [rowIndex, row] of layout.rows.entries()) { + for (const [rangeIndex, sourceRange] of (row.sourceRanges ?? []).entries()) { + const lineCount = lineCounts[sourceRange.side]; + if (lineCount === undefined || lineCount === null) { + return { + kind: "unavailable-source", + detail: `rows[${rowIndex}].sourceRanges[${rangeIndex}] targets unavailable ${sourceRange.side} source`, + }; + } + if (sourceRange.range[1] > lineCount) { + return { + kind: "out-of-bounds", + detail: `rows[${rowIndex}].sourceRanges[${rangeIndex}] exceeds the ${sourceRange.side} source bounds`, + }; + } + } + } + return null; +} + +/** Return a terminal-safe line representation for a symbolic row. */ +export function fileViewRowText(row: ExtensionFileViewRow, width: number) { + const text = row.spans.map((span) => span.text).join(""); + return wrapSanitizedTextByWidth(text, Math.max(1, width)); +} + +/** Exposed only for tests that ensure terminal measurement treats wide text as cells, not UTF-16. */ +export function measureFileViewRowWidth(row: ExtensionFileViewRow) { + return measureSanitizedTextWidth(row.spans.map((span) => span.text).join("")); +} diff --git a/src/ui/fileViews/renderPlan.test.ts b/src/ui/fileViews/renderPlan.test.ts new file mode 100644 index 00000000..63f70d55 --- /dev/null +++ b/src/ui/fileViews/renderPlan.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, test } from "bun:test"; +import type { ExtensionFileViewLayout } from "../../extension-api/types"; +import type { VisibleAgentNote } from "../lib/agentAnnotations"; +import { buildFileViewRenderPlan } from "./renderPlan"; + +const layout: ExtensionFileViewLayout = { + rows: [ + { + id: "old-summary", + spans: [{ text: "old" }], + sourceRanges: [{ side: "old", range: [2, 4] }], + }, + { + id: "new-summary", + spans: [{ text: "new" }], + sourceRanges: [{ side: "new", range: [5, 8] }], + }, + ], + hunkRows: [{ startRow: 0, endRow: 1 }], +}; + +/** Build the minimal host note payload used by alternate-view planning. */ +function note( + id: string, + ranges: { oldRange?: [number, number]; newRange?: [number, number] }, +): VisibleAgentNote { + return { + id, + annotation: { id, summary: id, ...ranges }, + }; +} + +describe("file-view render plan", () => { + test("inserts notes before the uniquely bound preferred-side row", () => { + const plan = buildFileViewRenderPlan(layout, [ + note("both", { oldRange: [3, 3], newRange: [6, 7] }), + ]); + + expect(plan.unresolvedNoteIds).toEqual([]); + expect(plan.rows.map((row) => `${row.kind}:${row.key}`)).toEqual([ + "file-view-row:file-view:old-summary", + "inline-note:inline-note:both:file-view:new-summary:0", + "file-view-row:file-view:new-summary", + ]); + expect(plan.rows[1]).toMatchObject({ + kind: "inline-note", + anchorRowIndex: 1, + anchorSide: "new", + hunkIndex: 0, + }); + }); + + test("groups notes at one anchor in stable input order", () => { + const plan = buildFileViewRenderPlan(layout, [ + note("first", { newRange: [5, 5] }), + note("second", { newRange: [8, 8] }), + ]); + const notes = plan.rows.filter((row) => row.kind === "inline-note"); + + expect(notes.map((row) => row.note.id)).toEqual(["first", "second"]); + expect(notes.map((row) => [row.noteIndex, row.noteCount])).toEqual([ + [0, 2], + [1, 2], + ]); + }); + + test("reports every range-less, unbound, or out-of-hunk note instead of guessing", () => { + const outsideHunk: ExtensionFileViewLayout = { + ...layout, + hunkRows: [{ startRow: 0, endRow: 0 }], + }; + const plan = buildFileViewRenderPlan(outsideHunk, [ + note("range-less", {}), + note("unbound", { newRange: [20, 20] }), + note("outside-hunk", { newRange: [6, 6] }), + ]); + + expect(plan.unresolvedNoteIds).toEqual(["range-less", "unbound", "outside-hunk"]); + expect(plan.rows.every((row) => row.kind === "file-view-row")).toBe(true); + + const overlappingHunks = buildFileViewRenderPlan( + { + ...layout, + hunkRows: [ + { startRow: 0, endRow: 1 }, + { startRow: 1, endRow: 1 }, + ], + }, + [note("ambiguous-hunk", { newRange: [6, 6] })], + ); + expect(overlappingHunks.unresolvedNoteIds).toEqual(["ambiguous-hunk"]); + }); +}); diff --git a/src/ui/fileViews/renderPlan.ts b/src/ui/fileViews/renderPlan.ts new file mode 100644 index 00000000..b705063d --- /dev/null +++ b/src/ui/fileViews/renderPlan.ts @@ -0,0 +1,120 @@ +import type { AgentAnnotation } from "../../core/types"; +import type { ExtensionFileViewLayout, ExtensionFileViewRow } from "../../extension-api/types"; +import { annotationAnchor, type VisibleAgentNote } from "../lib/agentAnnotations"; + +/** One validated extension row or host-owned inline note in an alternate file presentation. */ +export type PlannedFileViewRow = + | { + readonly kind: "file-view-row"; + readonly key: string; + readonly stableKey: string; + readonly row: ExtensionFileViewRow; + readonly rowIndex: number; + } + | { + readonly kind: "inline-note"; + readonly key: string; + readonly stableKey: string; + readonly annotation: AgentAnnotation; + readonly anchorRowIndex: number; + readonly anchorSide: "old" | "new"; + readonly hunkIndex: number; + readonly note: VisibleAgentNote; + readonly noteCount: number; + readonly noteIndex: number; + }; + +export interface FileViewRenderPlan { + readonly rows: readonly PlannedFileViewRow[]; + /** Notes without one exact bound anchor force the file back to raw diff. */ + readonly unresolvedNoteIds: readonly string[]; +} + +/** Resolve unique hunk ownership for every row in one bounded sweep. */ +function hunkOwnersByRow(layout: ExtensionFileViewLayout) { + const starts = Array.from({ length: layout.rows.length + 1 }, () => [] as number[]); + const ends = Array.from({ length: layout.rows.length + 1 }, () => [] as number[]); + for (const [hunkIndex, hunkRows] of layout.hunkRows.entries()) { + starts[hunkRows.startRow]!.push(hunkIndex); + ends[hunkRows.endRow + 1]!.push(hunkIndex); + } + + const active = new Set(); + return layout.rows.map((_, rowIndex) => { + for (const hunkIndex of ends[rowIndex]!) active.delete(hunkIndex); + for (const hunkIndex of starts[rowIndex]!) active.add(hunkIndex); + return active.size === 1 ? active.values().next().value! : -1; + }); +} + +/** Find the unique validated presentation row containing one note's preferred source anchor. */ +function boundRowIndex(layout: ExtensionFileViewLayout, annotation: AgentAnnotation) { + const anchor = annotationAnchor(annotation); + if (!anchor) return -1; + + return layout.rows.findIndex((row) => + (row.sourceRanges ?? []).some( + (sourceRange) => + sourceRange.side === anchor.side && + sourceRange.range[0] <= anchor.lineNumber && + anchor.lineNumber <= sourceRange.range[1], + ), + ); +} + +/** + * Insert host-owned notes into one immutable alternate-view row stream. + * + * Placement is all-or-raw: if any visible note lacks one exact bound anchor inside a declared hunk, + * callers must render the raw Pierre diff rather than dropping or guessing at note placement. + */ +export function buildFileViewRenderPlan( + layout: ExtensionFileViewLayout, + visibleAgentNotes: readonly VisibleAgentNote[], +): FileViewRenderPlan { + const notesByRow = new Map< + number, + Array<{ note: VisibleAgentNote; anchorSide: "old" | "new"; hunkIndex: number }> + >(); + const unresolvedNoteIds: string[] = []; + const hunkOwnerByRow = hunkOwnersByRow(layout); + + for (const note of visibleAgentNotes) { + const anchor = annotationAnchor(note.annotation); + const rowIndex = boundRowIndex(layout, note.annotation); + const hunkIndex = rowIndex < 0 ? -1 : hunkOwnerByRow[rowIndex]!; + if (!anchor || rowIndex < 0 || hunkIndex < 0) { + unresolvedNoteIds.push(note.id); + continue; + } + const notes = notesByRow.get(rowIndex) ?? []; + notes.push({ note, anchorSide: anchor.side, hunkIndex }); + notesByRow.set(rowIndex, notes); + } + + const rows: PlannedFileViewRow[] = []; + for (const [rowIndex, row] of layout.rows.entries()) { + const anchoredNotes = notesByRow.get(rowIndex) ?? []; + for (const [noteIndex, placement] of anchoredNotes.entries()) { + rows.push({ + kind: "inline-note", + key: `inline-note:${placement.note.id}:file-view:${row.id}:${noteIndex}`, + stableKey: `inline-note:${placement.note.id}`, + annotation: placement.note.annotation, + anchorRowIndex: rowIndex, + anchorSide: placement.anchorSide, + hunkIndex: placement.hunkIndex, + note: placement.note, + noteCount: anchoredNotes.length, + noteIndex, + }); + } + const key = `file-view:${row.id}`; + rows.push({ kind: "file-view-row", key, stableKey: key, row, rowIndex }); + } + + return { + rows: Object.freeze(rows), + unresolvedNoteIds: Object.freeze(unresolvedNoteIds), + }; +} diff --git a/src/ui/fileViews/state.test.ts b/src/ui/fileViews/state.test.ts new file mode 100644 index 00000000..9faa7f74 --- /dev/null +++ b/src/ui/fileViews/state.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, test } from "bun:test"; +import type { ExtensionDiffFile } from "../../extension-api/types"; +import type { RegisteredFileView } from "../../extensions/types"; +import { + reconcileFileViewSelections, + registeredFileViewKey, + resolveBulkFileViewTarget, + resolveRegisteredFileView, + selectFileView, + selectFileViewForFiles, +} from "./state"; + +describe("file-view selection state", () => { + test("keeps valid per-file choices across reload while dropping stale ids and views", () => { + expect( + reconcileFileViewSelections( + { + readme: "preview:rendered", + gone: "other:view", + stale: "removed:view", + }, + ["readme", "stale"], + new Set(["preview:rendered"]), + ), + ).toEqual({ readme: "preview:rendered" }); + }); + + test("stores raw implicitly and avoids needless state changes", () => { + const active = selectFileView({}, "readme", "preview:rendered"); + expect(active).toEqual({ readme: "preview:rendered" }); + expect(selectFileView(active, "readme", "preview:rendered")).toBe(active); + expect(selectFileView(active, "readme", null)).toEqual({}); + }); + + test("offers a bulk target only while the selected file still matches", () => { + const registered = { + extensionId: "preview", + view: { + id: "rendered", + title: "Rendered", + matches: (file) => file.path.endsWith(".md"), + layout: () => null, + }, + } satisfies RegisteredFileView; + const files = [ + { id: "selected", path: "selected.md" }, + { id: "other", path: "other.md" }, + { id: "source", path: "source.ts" }, + ] as unknown as ExtensionDiffFile[]; + expect( + resolveBulkFileViewTarget({ + current: { selected: "preview:rendered" }, + files, + registered, + selectedFileId: "selected", + }), + ).toEqual({ key: "preview:rendered", fileIds: ["selected", "other"] }); + + expect( + resolveBulkFileViewTarget({ + current: { selected: "preview:rendered" }, + files: [ + { id: "selected", path: "selected.bin" }, + ...files.slice(1), + ] as unknown as ExtensionDiffFile[], + registered, + selectedFileId: "selected", + }), + ).toBeNull(); + }); + + test("applies one view to matching files without changing nonmatches", () => { + const current = { first: "preview:old", second: "other:view", untouched: "raw:custom" }; + const selected = selectFileViewForFiles(current, ["first", "second"], "preview:new"); + + expect(selected).toEqual({ + first: "preview:new", + second: "preview:new", + untouched: "raw:custom", + }); + expect(selectFileViewForFiles(selected, ["first", "second"], "preview:new")).toBe(selected); + expect(selectFileViewForFiles(current, [], "preview:new")).toBe(current); + }); + + test("allows an extension view id named raw because only null is the raw sentinel", () => { + const rawNamedView = { + extensionId: "preview", + view: { id: "raw" }, + } as RegisteredFileView; + + expect(registeredFileViewKey(rawNamedView)).toBe("preview:raw"); + expect(resolveRegisteredFileView([rawNamedView], "preview", "raw")).toBe(rawNamedView); + expect(resolveRegisteredFileView([rawNamedView], "other", "preview:raw")).toBe(rawNamedView); + }); +}); diff --git a/src/ui/fileViews/state.ts b/src/ui/fileViews/state.ts new file mode 100644 index 00000000..f5a3cf74 --- /dev/null +++ b/src/ui/fileViews/state.ts @@ -0,0 +1,96 @@ +import { fileViewKey, qualifiedViewKey } from "../../extensions/apply"; +import type { ExtensionDiffFile } from "../../extension-api/types"; +import type { RegisteredFileView } from "../../extensions/types"; + +/** Raw is implicit: only files explicitly switched away from raw have an entry. */ +export type FileViewSelectionState = Readonly>; + +/** Resolve one registered view key as `:`. */ +export function registeredFileViewKey(view: RegisteredFileView) { + // Selection lookup and duplicate resolution must agree, so both derive the key from one policy. + return fileViewKey(view); +} + +/** Resolve a bare local or qualified file-view id without reserving extension ids. */ +export function resolveRegisteredFileView( + views: readonly RegisteredFileView[], + extensionId: string, + viewId: string, +) { + const key = viewId.includes(":") ? viewId : qualifiedViewKey(extensionId, viewId); + return views.find((view) => registeredFileViewKey(view) === key); +} + +/** Reconcile per-file selections after filtering/reload removes files or views. */ +export function reconcileFileViewSelections( + current: FileViewSelectionState, + fileIds: readonly string[], + viewKeys: ReadonlySet, +): FileViewSelectionState { + const validFileIds = new Set(fileIds); + const next: Record = {}; + for (const [fileId, viewKey] of Object.entries(current)) { + if (validFileIds.has(fileId) && viewKeys.has(viewKey)) { + next[fileId] = viewKey; + } + } + return next; +} + +/** Select raw or a named view for one file without retaining a redundant raw entry. */ +export function selectFileView( + current: FileViewSelectionState, + fileId: string, + viewKey: string | null, +): FileViewSelectionState { + if (viewKey === null) { + if (!(fileId in current)) return current; + const { [fileId]: _removed, ...next } = current; + return next; + } + if (current[fileId] === viewKey) return current; + return { ...current, [fileId]: viewKey }; +} + +export interface BulkFileViewTarget { + readonly key: string; + readonly fileIds: readonly string[]; +} + +/** Resolve the changeset-wide matching set only while the selected file still uses and matches it. */ +export function resolveBulkFileViewTarget({ + current, + files, + registered, + selectedFileId, +}: { + current: FileViewSelectionState; + files: readonly ExtensionDiffFile[]; + registered: RegisteredFileView; + selectedFileId: string; +}): BulkFileViewTarget | null { + const key = registeredFileViewKey(registered); + if (current[selectedFileId] !== key) return null; + const fileIds: string[] = []; + for (const file of files) { + try { + if (registered.view.matches(file)) fileIds.push(file.id); + } catch { + // One cooperative matcher failure excludes only that file from the host-owned batch. + } + } + if (!fileIds.includes(selectedFileId)) return null; + return fileIds.some((fileId) => current[fileId] !== key) ? { key, fileIds } : null; +} + +/** Apply one presentation to a host-resolved set of matching files without touching nonmatches. */ +export function selectFileViewForFiles( + current: FileViewSelectionState, + fileIds: readonly string[], + viewKey: string, +): FileViewSelectionState { + if (fileIds.every((fileId) => current[fileId] === viewKey)) return current; + const next = { ...current }; + for (const fileId of fileIds) next[fileId] = viewKey; + return next; +} diff --git a/src/ui/fileViews/useFileViews.test.tsx b/src/ui/fileViews/useFileViews.test.tsx new file mode 100644 index 00000000..86875c7f --- /dev/null +++ b/src/ui/fileViews/useFileViews.test.tsx @@ -0,0 +1,667 @@ +import { describe, expect, test } from "bun:test"; +import { testRender } from "@opentui/react/test-utils"; +import { act, createElement, useState } from "react"; +import { createTestDiffFile, createTestSourceFetcher } from "../../../test/helpers/diff-helpers"; +import type { RegisteredFileView } from "../../extensions/types"; +import { registeredFileViewKey } from "./state"; +import { + FILE_VIEW_LAYOUT_CACHE_MAX_ENTRIES, + FILE_VIEW_LAYOUT_RESIZE_DEBOUNCE_MS, + runFileViewLayoutRequest, + useFileViewLayouts, + type ResolvedFileViewLayout, +} from "./useFileViews"; + +/** Build one registration with a test-controlled layout callback. */ +function createTestView(layout: RegisteredFileView["view"]["layout"]): RegisteredFileView { + return { + extensionId: "test-extension", + view: { + id: "test-view", + title: "Test view", + matches: () => true, + layout, + }, + }; +} + +const file = createTestDiffFile({ + id: "request", + path: "request.ts", + before: "old\n", + after: "new\n", +}); +const files = [file]; +const ignoreIssue = () => {}; + +describe("file-view layout request lifetime", () => { + test("aborts its child signal after successful completion", async () => { + let signal: AbortSignal | undefined; + const view = createTestView((input) => { + signal = input.signal; + return null; + }); + + expect( + await runFileViewLayoutRequest(view, file, 80, new AbortController().signal, 50), + ).toBeNull(); + expect(signal?.aborted).toBe(true); + }); + + test("reads only bound exact-source sides before accepting a layout", async () => { + const sourceFetcher = createTestSourceFetcher(async (side) => + side === "new" ? "new\n" : "old\n", + ); + const sourceFile = createTestDiffFile({ + id: "bound", + path: "bound.ts", + before: "old\n", + after: "new\n", + sourceFetcher, + }); + const view = createTestView(({ file: inputFile }) => ({ + rows: [ + { + id: "bound-row", + spans: [{ text: "bound" }], + sourceRanges: [{ side: "new", range: [1, 1] }], + }, + ], + hunkRows: (inputFile.hunks ?? []).map(() => ({ startRow: 0, endRow: 0 })), + })); + + await expect( + runFileViewLayoutRequest(view, sourceFile, 80, new AbortController().signal, 50), + ).resolves.toMatchObject({ layout: { rows: [{ id: "bound-row" }] } }); + expect(sourceFetcher.calls).toEqual(["new"]); + }); + + test("times out a hung binding read instead of holding the preparation slot", async () => { + let resolveRead: ((value: string | null) => void) | undefined; + const sourceFetcher = createTestSourceFetcher( + () => + new Promise((resolve) => { + resolveRead = resolve; + }), + ); + const sourceFile = createTestDiffFile({ + id: "hung", + path: "hung.ts", + before: "old\n", + after: "new\n", + sourceFetcher, + }); + // The extension itself returns immediately; only the host read its bindings require hangs. + const view = createTestView(({ file: inputFile }) => ({ + rows: [ + { + id: "bound-row", + spans: [{ text: "bound" }], + sourceRanges: [{ side: "new", range: [1, 1] }], + }, + ], + hunkRows: (inputFile.hunks ?? []).map(() => ({ startRow: 0, endRow: 0 })), + })); + + let settlements = 0; + await runFileViewLayoutRequest(view, sourceFile, 80, new AbortController().signal, 5).then( + () => settlements++, + () => settlements++, + ); + expect(settlements).toBe(1); + // The read was issued and is still pending, so the budget — not the read — released the slot. + expect(sourceFetcher.calls).toEqual(["new"]); + expect(resolveRead).toBeDefined(); + + resolveRead?.("new\n"); + await Promise.resolve(); + expect(settlements).toBe(1); + }); + + test("aborts on timeout and ignores a late extension result", async () => { + let signal: AbortSignal | undefined; + let resolveLate: ((value: null) => void) | undefined; + const late = new Promise((resolve) => { + resolveLate = resolve; + }); + const view = createTestView((input) => { + signal = input.signal; + return late; + }); + + let settlements = 0; + const request = runFileViewLayoutRequest(view, file, 80, new AbortController().signal, 5).then( + () => settlements++, + () => settlements++, + ); + await request; + expect(signal?.aborted).toBe(true); + expect(settlements).toBe(1); + + resolveLate?.(null); + await Promise.resolve(); + expect(settlements).toBe(1); + }); + + test("links parent supersession into the child request signal", async () => { + let signal: AbortSignal | undefined; + const parent = new AbortController(); + const view = createTestView((input) => { + signal = input.signal; + return new Promise(() => {}); + }); + const request = runFileViewLayoutRequest(view, file, 80, parent.signal, 50).catch(() => null); + + await Promise.resolve(); + parent.abort(); + expect(signal?.aborted).toBe(true); + await request; + }); +}); + +describe("file-view layout cache identity", () => { + test("retains exact unaffected files while suppressing another file's changed selection", async () => { + const secondFile = createTestDiffFile({ + id: "second-request", + path: "second-request.ts", + before: "before\n", + after: "after\n", + }); + const primary = createTestView(({ file: inputFile }) => ({ + rows: [{ id: "row", spans: [{ text: inputFile.id }] }], + hunkRows: (inputFile.hunks ?? []).map(() => ({ startRow: 0, endRow: 0 })), + })); + const delayed = createTestView(() => new Promise(() => {})); + delayed.view.id = "delayed-view"; + const primaryKey = registeredFileViewKey(primary); + const delayedKey = registeredFileViewKey(delayed); + const allFiles = [file, secondFile]; + const views = [primary, delayed]; + let selectDelayed = () => {}; + let filterSecond = () => {}; + let latest: ReadonlyMap = new Map(); + + function Harness() { + const [visibleFiles, setVisibleFiles] = useState(allFiles); + const [selections, setSelections] = useState>({ + [file.id]: primaryKey, + [secondFile.id]: primaryKey, + }); + selectDelayed = () => + setSelections((current) => ({ ...current, [secondFile.id]: delayedKey })); + filterSecond = () => setVisibleFiles([file]); + latest = useFileViewLayouts({ + files: visibleFiles, + selections, + views, + width: 80, + onIssue: ignoreIssue, + }); + return null; + } + + const setup = await testRender(createElement(Harness), { width: 10, height: 2 }); + try { + for (let attempt = 0; attempt < 20 && latest.size !== 2; attempt += 1) { + await act(async () => { + await Promise.resolve(); + await setup.renderOnce(); + }); + } + expect([...latest.keys()]).toEqual([file.id, secondFile.id]); + + await act(async () => { + selectDelayed(); + await setup.renderOnce(); + }); + expect([...latest.keys()]).toEqual([file.id]); + + await act(async () => { + filterSecond(); + await setup.renderOnce(); + }); + expect([...latest.keys()]).toEqual([file.id]); + } finally { + await act(async () => setup.renderer.destroy()); + } + }); + + test("synchronously hides a stale width generation before effects clean it up", async () => { + const renderedWidths: [number, string][] = []; + const view = createTestView(({ width }) => ({ + rows: [{ id: "row", spans: [{ text: String(width) }] }], + hunkRows: file.metadata.hunks.map(() => ({ startRow: 0, endRow: 0 })), + })); + const selections = { [file.id]: registeredFileViewKey(view) }; + const views = [view]; + let changeWidth = (_width: number) => {}; + let latest: ReadonlyMap = new Map(); + + function Harness() { + const [width, setWidth] = useState(80); + changeWidth = setWidth; + latest = useFileViewLayouts({ files, selections, views, width, onIssue: ignoreIssue }); + const text = latest.get(file.id)?.layout.rows[0]?.spans[0]?.text; + if (text) renderedWidths.push([width, text]); + return null; + } + + const setup = await testRender(createElement(Harness), { width: 10, height: 2 }); + const settleAt = async (expectedWidth: string) => { + for (let attempt = 0; attempt < 20; attempt += 1) { + await act(async () => { + await Promise.resolve(); + await setup.renderOnce(); + }); + if (latest.get(file.id)?.layout.rows[0]?.spans[0]?.text === expectedWidth) return; + } + throw new Error(`layout did not settle at width ${expectedWidth}`); + }; + + try { + await settleAt("80"); + renderedWidths.length = 0; + await act(async () => { + changeWidth(40); + await setup.renderOnce(); + }); + expect(renderedWidths).not.toContainEqual([40, "80"]); + await act(async () => { + await Bun.sleep(FILE_VIEW_LAYOUT_RESIZE_DEBOUNCE_MS + 10); + await setup.renderOnce(); + }); + await settleAt("40"); + expect(renderedWidths).toContainEqual([40, "40"]); + } finally { + await act(async () => setup.renderer.destroy()); + } + }); + + test("coalesces rapid width changes without exposing stale geometry", async () => { + const layoutWidths: number[] = []; + const view = createTestView(({ width }) => { + layoutWidths.push(width); + return { + rows: [{ id: "row", spans: [{ text: String(width) }] }], + hunkRows: file.metadata.hunks.map(() => ({ startRow: 0, endRow: 0 })), + }; + }); + const selections = { [file.id]: registeredFileViewKey(view) }; + const views = [view]; + let changeWidth = (_width: number) => {}; + let latest: ReadonlyMap = new Map(); + + function Harness() { + const [width, setWidth] = useState(80); + changeWidth = setWidth; + latest = useFileViewLayouts({ files, selections, views, width, onIssue: ignoreIssue }); + return null; + } + + const setup = await testRender(createElement(Harness), { width: 10, height: 2 }); + try { + for (let attempt = 0; attempt < 20 && latest.size === 0; attempt += 1) { + await act(async () => { + await Promise.resolve(); + await setup.renderOnce(); + }); + } + expect(layoutWidths).toEqual([80]); + + for (const width of [79, 78, 77]) { + await act(async () => { + changeWidth(width); + await setup.renderOnce(); + }); + expect(latest.size).toBe(0); + } + await act(async () => { + await Bun.sleep(FILE_VIEW_LAYOUT_RESIZE_DEBOUNCE_MS + 10); + await setup.renderOnce(); + }); + for (let attempt = 0; attempt < 20 && latest.size === 0; attempt += 1) { + await act(async () => { + await Promise.resolve(); + await setup.renderOnce(); + }); + } + + expect(layoutWidths).toEqual([80, 77]); + expect(latest.get(file.id)?.layout.rows[0]?.spans[0]?.text).toBe("77"); + } finally { + await act(async () => setup.renderer.destroy()); + } + }); + + test("replaces stale width variants for one file/view/registration", async () => { + const layoutWidths: number[] = []; + const view = createTestView(({ width }) => { + layoutWidths.push(width); + return { + rows: [{ id: "row", spans: [{ text: String(width) }] }], + hunkRows: file.metadata.hunks.map(() => ({ startRow: 0, endRow: 0 })), + }; + }); + const selections = { [file.id]: registeredFileViewKey(view) }; + const views = [view]; + let changeWidth = (_width: number) => {}; + let latest: ReadonlyMap = new Map(); + + function Harness() { + const [width, setWidth] = useState(80); + changeWidth = setWidth; + latest = useFileViewLayouts({ files, selections, views, width, onIssue: ignoreIssue }); + return null; + } + + const setup = await testRender(createElement(Harness), { width: 10, height: 2 }); + const settleAt = async (expectedWidth: string) => { + for (let attempt = 0; attempt < 20; attempt += 1) { + await act(async () => { + await Promise.resolve(); + await setup.renderOnce(); + }); + if (latest.get(file.id)?.layout.rows[0]?.spans[0]?.text === expectedWidth) return; + } + throw new Error(`layout did not settle at width ${expectedWidth}`); + }; + + try { + await settleAt("80"); + await act(async () => { + changeWidth(40); + await setup.renderOnce(); + }); + await act(async () => { + await Bun.sleep(FILE_VIEW_LAYOUT_RESIZE_DEBOUNCE_MS + 10); + await setup.renderOnce(); + }); + await settleAt("40"); + await act(async () => { + changeWidth(80); + await setup.renderOnce(); + }); + await act(async () => { + await Bun.sleep(FILE_VIEW_LAYOUT_RESIZE_DEBOUNCE_MS + 10); + await setup.renderOnce(); + }); + await settleAt("80"); + expect(layoutWidths).toEqual([80, 40, 80]); + } finally { + await act(async () => setup.renderer.destroy()); + } + }); + + test("evicts the oldest prepared tree after the cache limit", async () => { + const candidates = Array.from({ length: FILE_VIEW_LAYOUT_CACHE_MAX_ENTRIES + 1 }, (_, index) => + createTestDiffFile({ + id: `cache-${index}`, + path: `cache-${index}.ts`, + before: "old\n", + after: "new\n", + }), + ); + const layoutCalls = new Map(); + const view = createTestView(({ file: inputFile }) => { + layoutCalls.set(inputFile.id, (layoutCalls.get(inputFile.id) ?? 0) + 1); + return { + rows: [{ id: "row", spans: [{ text: inputFile.id }] }], + hunkRows: (inputFile.hunks ?? []).map(() => ({ startRow: 0, endRow: 0 })), + }; + }); + const key = registeredFileViewKey(view); + const candidateFiles = candidates.map((candidate) => [candidate] as const); + const candidateSelections = candidates.map((candidate) => ({ [candidate.id]: key })); + const views = [view]; + let choose = (_index: number) => {}; + let latest: ReadonlyMap = new Map(); + + function Harness() { + const [index, setIndex] = useState(0); + choose = setIndex; + latest = useFileViewLayouts({ + files: candidateFiles[index]!, + selections: candidateSelections[index]!, + views, + width: 80, + onIssue: ignoreIssue, + }); + return null; + } + + const setup = await testRender(createElement(Harness), { width: 10, height: 2 }); + const settleAt = async (index: number) => { + const expectedId = candidates[index]!.id; + for (let attempt = 0; attempt < 20; attempt += 1) { + await act(async () => { + await Promise.resolve(); + await setup.renderOnce(); + }); + if (latest.has(expectedId)) return; + } + throw new Error(`layout did not settle for ${expectedId}`); + }; + + try { + await settleAt(0); + for (let index = 1; index < candidates.length; index += 1) { + await act(async () => { + choose(index); + await setup.renderOnce(); + }); + await settleAt(index); + } + await act(async () => { + choose(0); + await setup.renderOnce(); + }); + await settleAt(0); + expect(layoutCalls.get(candidates[0]!.id)).toBe(2); + expect(layoutCalls.get(candidates.at(-1)!.id)).toBe(1); + } finally { + await act(async () => setup.renderer.destroy()); + } + }); + + test("deduplicates deterministic failures across widths but reports a replacement registration", async () => { + const issues: string[] = []; + let layoutCalls = 0; + const buildBrokenRegistration = () => + createTestView(() => { + layoutCalls += 1; + throw new Error("deterministic failure"); + }); + const first = buildBrokenRegistration(); + const selections = { [file.id]: registeredFileViewKey(first) }; + const reportIssue = (message: string) => issues.push(message); + let resize = (_width: number) => {}; + let reload = () => {}; + + function Harness() { + const [width, setWidth] = useState(80); + const [views, setViews] = useState([first]); + resize = setWidth; + reload = () => setViews([buildBrokenRegistration()]); + useFileViewLayouts({ files, selections, views, width, onIssue: reportIssue }); + return null; + } + + const setup = await testRender(createElement(Harness), { width: 10, height: 2 }); + const settleCalls = async (expected: number, expectedIssues: number) => { + for ( + let attempt = 0; + attempt < 20 && (layoutCalls < expected || issues.length < expectedIssues); + attempt += 1 + ) { + await act(async () => { + await Promise.resolve(); + await setup.renderOnce(); + }); + } + expect(layoutCalls).toBe(expected); + expect(issues).toHaveLength(expectedIssues); + }; + + try { + await settleCalls(1, 1); + expect(issues).toHaveLength(1); + for (const [index, width] of [79, 78, 77].entries()) { + await act(async () => { + resize(width); + await setup.renderOnce(); + }); + await act(async () => { + await Bun.sleep(FILE_VIEW_LAYOUT_RESIZE_DEBOUNCE_MS + 10); + await setup.renderOnce(); + }); + await settleCalls(index + 2, 1); + } + expect(issues).toHaveLength(1); + + await act(async () => { + reload(); + await setup.renderOnce(); + }); + await settleCalls(5, 2); + expect(issues).toHaveLength(2); + } finally { + await act(async () => setup.renderer.destroy()); + } + }); + + test("synchronously suppresses a stale concrete registration", async () => { + const first = createTestView(() => ({ + rows: [{ id: "first", spans: [{ text: "first" }] }], + hunkRows: file.metadata.hunks.map(() => ({ startRow: 0, endRow: 0 })), + })); + let resolveReplacement: + | ((layout: ReturnType) => void) + | undefined; + const replacement = createTestView( + () => + new Promise((resolve) => { + resolveReplacement = resolve; + }), + ); + const selections = { [file.id]: registeredFileViewKey(first) }; + let reload = () => {}; + let latest: ReadonlyMap = new Map(); + + function Harness() { + const [views, setViews] = useState([first]); + reload = () => setViews([replacement]); + latest = useFileViewLayouts({ files, selections, views, width: 80, onIssue: ignoreIssue }); + return null; + } + + const setup = await testRender(createElement(Harness), { width: 10, height: 2 }); + try { + for (let attempt = 0; attempt < 20 && latest.size === 0; attempt += 1) { + await act(async () => { + await Promise.resolve(); + await setup.renderOnce(); + }); + } + expect(latest.get(file.id)?.layout.rows[0]?.id).toBe("first"); + + await act(async () => { + reload(); + await setup.renderOnce(); + }); + expect(latest.size).toBe(0); + + resolveReplacement?.({ + rows: [{ id: "replacement", spans: [{ text: "replacement" }] }], + hunkRows: file.metadata.hunks.map(() => ({ startRow: 0, endRow: 0 })), + }); + for (let attempt = 0; attempt < 20 && latest.size === 0; attempt += 1) { + await act(async () => { + await Promise.resolve(); + await setup.renderOnce(); + }); + } + expect(latest.get(file.id)?.layout.rows[0]?.id).toBe("replacement"); + } finally { + await act(async () => setup.renderer.destroy()); + } + }); + + test("uses a valid cache before matches and invalidates a replaced registration", async () => { + let matchesCalls = 0; + let layoutCalls = 0; + const buildRegistration = () => + createTestView(() => { + layoutCalls += 1; + return { + rows: [{ id: "row", spans: [{ text: "row" }] }], + hunkRows: file.metadata.hunks.map(() => ({ startRow: 0, endRow: 0 })), + }; + }); + const firstRegistration = buildRegistration(); + firstRegistration.view.matches = () => { + matchesCalls += 1; + return true; + }; + let refreshSelection = () => {}; + let replaceRegistration = () => {}; + let latest = new Map() as ReadonlyMap< + string, + ResolvedFileViewLayout + >; + const issue = () => {}; + + function Harness() { + const [selections, setSelections] = useState>({ + [file.id]: registeredFileViewKey(firstRegistration), + }); + const [views, setViews] = useState([firstRegistration]); + refreshSelection = () => setSelections((current) => ({ ...current })); + replaceRegistration = () => { + const replacement = buildRegistration(); + replacement.view.matches = () => { + matchesCalls += 1; + return true; + }; + setViews([replacement]); + }; + latest = useFileViewLayouts({ files, selections, views, width: 80, onIssue: issue }); + return null; + } + + const setup = await testRender(createElement(Harness), { width: 10, height: 2 }); + const settle = async () => { + for (let attempt = 0; attempt < 20 && latest.size === 0; attempt += 1) { + await act(async () => { + await Promise.resolve(); + await setup.renderOnce(); + }); + } + }; + + try { + await settle(); + const first = latest.get(file.id); + expect(first).toBeDefined(); + expect([matchesCalls, layoutCalls]).toEqual([1, 1]); + + await act(async () => { + refreshSelection(); + await setup.renderOnce(); + }); + await settle(); + expect([matchesCalls, layoutCalls]).toEqual([1, 1]); + expect(latest.get(file.id)).toBe(first); + + await act(async () => { + replaceRegistration(); + await setup.renderOnce(); + }); + await settle(); + expect([matchesCalls, layoutCalls]).toEqual([2, 2]); + expect(latest.get(file.id)?.registrationIdentity).not.toBe(first?.registrationIdentity); + expect(latest.get(file.id)?.layoutGeneration).not.toBe(first?.layoutGeneration); + } finally { + await act(async () => setup.renderer.destroy()); + } + }); +}); diff --git a/src/ui/fileViews/useFileViews.ts b/src/ui/fileViews/useFileViews.ts new file mode 100644 index 00000000..ac9b2293 --- /dev/null +++ b/src/ui/fileViews/useFileViews.ts @@ -0,0 +1,367 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import type { DiffFile } from "../../core/types"; +import type { RegisteredFileView } from "../../extensions/types"; +import { + fileViewHunkCount, + createFileViewInput, + createFileViewInputSnapshot, + type FileViewInputSnapshot, +} from "./host"; +import { + validateFileViewLayout, + validateFileViewSourceRanges, + type ValidatedFileViewLayout, +} from "./layout"; +import { registeredFileViewKey } from "./state"; + +/** Bound asynchronous third-party layout work so raw diff never waits indefinitely. */ +export const FILE_VIEW_LAYOUT_TIMEOUT_MS = 1_500; +/** Keep extension preparation parallel but bounded across a large changeset. */ +export const FILE_VIEW_LAYOUT_CONCURRENCY = 4; +/** Coalesce rapid width changes without ever painting geometry measured for a stale width. */ +export const FILE_VIEW_LAYOUT_RESIZE_DEBOUNCE_MS = 50; +/** Retain only a bounded set of prepared trees across file, view, and resize churn. */ +export const FILE_VIEW_LAYOUT_CACHE_MAX_ENTRIES = 64; +/** Bound warning dedupe metadata retained across input generations for this hook lifetime. */ +export const FILE_VIEW_LAYOUT_ISSUE_MAX_ENTRIES = 256; + +const EMPTY_RESOLVED_FILE_VIEW_LAYOUTS: ReadonlyMap = new Map(); + +export interface ResolvedFileViewLayout extends ValidatedFileViewLayout { + key: string; + extensionId: string; + viewId: string; + /** Stable identity for this concrete registration object. */ + registrationIdentity: number; + /** Changes whenever the host accepts a newly prepared layout. */ + layoutGeneration: number; +} + +interface CacheEntry { + file: DiffFile; + registered: RegisteredFileView; + resolved: ResolvedFileViewLayout | null; +} + +interface ResolvedEntry { + file: DiffFile; + key: string; + registered: RegisteredFileView; + width: number; + resolved: ResolvedFileViewLayout; +} + +/** Record a dedupe key while evicting the oldest retained key at the fixed limit. */ +function recordBoundedIssue(keys: Set, key: string) { + if (keys.has(key)) return false; + if (keys.size >= FILE_VIEW_LAYOUT_ISSUE_MAX_ENTRIES) { + const oldest = keys.values().next().value; + if (oldest !== undefined) keys.delete(oldest); + } + keys.add(key); + return true; +} + +/** Remove superseded widths for one registration before reading or preparing its current width. */ +function selectCacheWidthVariant( + entries: Map, + cacheKey: string, + file: DiffFile, + registered: RegisteredFileView, +) { + for (const [key, entry] of entries) { + if (key !== cacheKey && entry.file.id === file.id && entry.registered === registered) { + entries.delete(key); + } + } + const cached = entries.get(cacheKey); + if (cached) { + // Map insertion order doubles as a small LRU so hot entries survive changeset churn. + entries.delete(cacheKey); + entries.set(cacheKey, cached); + } + return cached; +} + +/** Insert one successful or declined result and evict the oldest retained tree when full. */ +function cacheLayoutResult(entries: Map, key: string, entry: CacheEntry) { + entries.delete(key); + entries.set(key, entry); + while (entries.size > FILE_VIEW_LAYOUT_CACHE_MAX_ENTRIES) { + const oldest = entries.keys().next().value; + if (oldest === undefined) return; + entries.delete(oldest); + } +} + +/** Create one layout-owned signal linked to the containing effect. */ +function createLayoutController(parentSignal: AbortSignal) { + const controller = new AbortController(); + const abort = () => controller.abort(parentSignal.reason); + if (parentSignal.aborted) { + abort(); + } else { + parentSignal.addEventListener("abort", abort, { once: true }); + } + return { + controller, + detach: () => parentSignal.removeEventListener("abort", abort), + }; +} + +/** + * Run one extension layout with a child cancellation lifetime. + * + * The child is aborted on timeout, parent supersession, and successful or failed completion. + * Every awaited phase races the same budget, so neither third-party layout code nor the host + * source reads its bindings require can hold a preparation slot past the deadline. + */ +export async function runFileViewLayoutRequest( + registered: RegisteredFileView, + file: DiffFile, + width: number, + parentSignal: AbortSignal, + timeoutMs = FILE_VIEW_LAYOUT_TIMEOUT_MS, + snapshot?: FileViewInputSnapshot, +): Promise { + const { controller, detach } = createLayoutController(parentSignal); + let timeout: ReturnType | undefined; + try { + const deadline = new Promise((_, reject) => { + timeout = setTimeout(() => { + controller.abort(new Error("layout timed out")); + reject(new Error("layout timed out")); + }, timeoutMs); + }); + const cancelled = new Promise((_, reject) => { + if (controller.signal.aborted) { + reject(new Error("layout aborted")); + return; + } + controller.signal.addEventListener("abort", () => reject(new Error("layout aborted")), { + once: true, + }); + }); + // Aborting the controller stops the host from waiting; an already-issued source read may still + // settle on its own, but it can no longer keep this preparation slot occupied. + const withinBudget = (work: Promise) => Promise.race([work, deadline, cancelled]); + const input = createFileViewInput(file, width, controller.signal, snapshot); + const candidate = await withinBudget( + Promise.resolve().then(() => registered.view.layout(input)), + ); + if (controller.signal.aborted || parentSignal.aborted) { + throw new Error("layout aborted"); + } + if (candidate === null) { + return null; + } + const checked = validateFileViewLayout(candidate, fileViewHunkCount(file), width); + if (!checked.valid) { + throw new Error(`invalid layout: ${checked.issue}`); + } + const requiredSides = new Set( + checked.value.layout.rows.flatMap((row) => + (row.sourceRanges ?? []).map((sourceRange) => sourceRange.side), + ), + ); + const documents = await withinBudget( + Promise.all( + [...requiredSides].map(async (side) => [side, await input.readDocument(side)] as const), + ).then((entries) => Object.fromEntries(entries)), + ); + if (controller.signal.aborted || parentSignal.aborted) { + throw new Error("layout aborted"); + } + const bindingIssue = validateFileViewSourceRanges(checked.value.layout, documents); + if (bindingIssue) { + // Unreadable source is an environment condition, so keep it distinguishable from a layout + // the extension actually got wrong. + throw new Error( + bindingIssue.kind === "unavailable-source" + ? `unavailable source: ${bindingIssue.detail}` + : `invalid layout: ${bindingIssue.detail}`, + ); + } + return checked.value; + } finally { + if (timeout) clearTimeout(timeout); + detach(); + controller.abort(); + } +} + +/** + * Run selected file-view layouts outside render and retain only validated results. + * + * Raw diff remains visible while preparation is pending or declines the file. A + * cancellation never reaches an extension as an error toast: resizes, reloads, + * and changing the selected view are normal control flow. + */ +export function useFileViewLayouts({ + files, + selections, + views, + width, + onIssue, +}: { + files: readonly DiffFile[]; + selections: Readonly>; + views: readonly RegisteredFileView[]; + width: number; + onIssue: (message: string) => void; +}) { + const cache = useRef(new Map()); + const registrationIdentities = useRef(new WeakMap()); + const nextRegistrationIdentity = useRef(1); + const nextLayoutGeneration = useRef(1); + const previousWidth = useRef(undefined); + const reportedIssues = useRef(new Set()); + const [resolved, setResolved] = useState>(new Map()); + + useEffect(() => { + const controller = new AbortController(); + const next = new Map(); + const widthChanged = previousWidth.current !== undefined && previousWidth.current !== width; + previousWidth.current = width; + let active = true; + let cursor = 0; + let startTimer: ReturnType | undefined; + const byKey = new Map(views.map((view) => [registeredFileViewKey(view), view])); + + const registrationIdentityFor = (registered: RegisteredFileView) => { + let identity = registrationIdentities.current.get(registered); + if (identity === undefined) { + identity = nextRegistrationIdentity.current++; + registrationIdentities.current.set(registered, identity); + } + return identity; + }; + + const reportOnce = (registered: RegisteredFileView, key: string, message: string) => { + const identity = registrationIdentityFor(registered); + if (recordBoundedIssue(reportedIssues.current, `${identity}:${key}`)) onIssue(message); + }; + + const prepareFile = async (file: DiffFile) => { + const key = selections[file.id]; + if (!key) return; + const registered = byKey.get(key); + if (!registered) return; + + const cacheKey = `${file.id}:${key}:${width}`; + const cached = selectCacheWidthVariant(cache.current, cacheKey, file, registered); + // A valid registration-aware cache hit bypasses even matches(), whose extension code may be + // expensive or stateful. A reload replaces the registration object and invalidates it. + if (cached?.file === file && cached.registered === registered) { + if (cached.resolved) { + next.set(file.id, { file, key, registered, width, resolved: cached.resolved }); + } + return; + } + + const snapshot = createFileViewInputSnapshot(file); + try { + if (!registered.view.matches(snapshot.file)) return; + } catch { + reportOnce( + registered, + `${file.id}:${key}:matches`, + `Extension ${registered.extensionId} file view "${registered.view.id}" failed matching ${file.path} • using raw diff`, + ); + return; + } + + try { + const validated = await runFileViewLayoutRequest( + registered, + file, + width, + controller.signal, + FILE_VIEW_LAYOUT_TIMEOUT_MS, + snapshot, + ); + if (controller.signal.aborted || !active) return; + if (validated === null) { + cacheLayoutResult(cache.current, cacheKey, { file, registered, resolved: null }); + return; + } + const registrationIdentity = registrationIdentityFor(registered); + const prepared: ResolvedFileViewLayout = { + ...validated, + key, + extensionId: registered.extensionId, + viewId: registered.view.id, + registrationIdentity, + layoutGeneration: nextLayoutGeneration.current++, + }; + cacheLayoutResult(cache.current, cacheKey, { file, registered, resolved: prepared }); + next.set(file.id, { file, key, registered, width, resolved: prepared }); + } catch (error) { + if (controller.signal.aborted || !active) return; + const detail = error instanceof Error ? error.message : String(error); + const view = `Extension ${registered.extensionId} file view "${registered.view.id}"`; + // Dedupe on the failure category, never on a thrown message an extension could vary. + const [category, attributed] = detail.startsWith("invalid layout: ") + ? [detail, `${view} returned an ${detail} • using raw diff`] + : detail.startsWith("unavailable source: ") + ? [ + "unavailable-source", + `${view} needs source for ${file.path} that Hunk could not read • using raw diff`, + ] + : ["layout", `${view} failed laying out ${file.path} • using raw diff`]; + reportOnce(registered, `${file.id}:${key}:${category}`, attributed); + cacheLayoutResult(cache.current, cacheKey, { file, registered, resolved: null }); + } + }; + + const worker = async () => { + while (active) { + const index = cursor++; + const file = files[index]; + if (!file) return; + await prepareFile(file); + } + }; + + const prepare = () => { + void Promise.all( + Array.from({ length: Math.min(FILE_VIEW_LAYOUT_CONCURRENCY, files.length) }, worker), + ).then(() => { + if (active) setResolved(next); + }); + }; + + if (widthChanged) { + startTimer = setTimeout(prepare, FILE_VIEW_LAYOUT_RESIZE_DEBOUNCE_MS); + } else { + prepare(); + } + + return () => { + active = false; + if (startTimer) clearTimeout(startTimer); + controller.abort(); + }; + }, [files, onIssue, selections, views, width]); + + return useMemo(() => { + const current = new Map(); + const byKey = new Map(views.map((view) => [registeredFileViewKey(view), view])); + for (const file of files) { + const key = selections[file.id]; + const entry = resolved.get(file.id); + if ( + key && + entry?.file === file && + entry.key === key && + entry.registered === byKey.get(key) && + entry.width === width + ) { + current.set(file.id, entry.resolved); + } + } + // Effects clean up after render. Exact per-file filtering synchronously declines stale geometry + // while preserving unaffected files across filtering and another file's selection change. + return current.size > 0 ? current : EMPTY_RESOLVED_FILE_VIEW_LAYOUTS; + }, [files, resolved, selections, views, width]); +} diff --git a/src/ui/lib/appCommands.test.ts b/src/ui/lib/appCommands.test.ts index 189ec181..59b08f6b 100644 --- a/src/ui/lib/appCommands.test.ts +++ b/src/ui/lib/appCommands.test.ts @@ -33,7 +33,9 @@ function createTestCommands(resolvedKeys?: ResolvedCommandKeys) { ran.push(args.length > 0 ? `${name}:${args.join(",")}` : name); }; const options: BuildAppCommandsOptions = { + canApplyFilePresentationToAllMatching: false, canRefreshCurrentInput: true, + applyFilePresentationToAllMatching: record("applyFilePresentationToAllMatching"), focusFilter: record("focusFilter"), moveToAnnotatedFile: record("moveToAnnotatedFile"), moveToAnnotatedHunk: record("moveToAnnotatedHunk"), @@ -203,6 +205,7 @@ describe("builtinCommandKeyDefaults", () => { "hunk.app.openAgentSkill", "hunk.review.nextAnnotatedFile", "hunk.review.previousAnnotatedFile", + "hunk.view.applyFilePresentationToAllMatching", "hunk.view.toggleCopyDecorations", ]); }); diff --git a/src/ui/lib/appCommands.ts b/src/ui/lib/appCommands.ts index b743cc21..1d56f9d8 100644 --- a/src/ui/lib/appCommands.ts +++ b/src/ui/lib/appCommands.ts @@ -81,7 +81,9 @@ interface BuiltinCommandSpec { /** The callbacks the built-in command set drives; App supplies its own handlers. */ export interface BuildAppCommandsOptions { + canApplyFilePresentationToAllMatching: boolean; canRefreshCurrentInput: boolean; + applyFilePresentationToAllMatching: () => void; focusFilter: () => void; moveToAnnotatedFile: (delta: number) => void; moveToAnnotatedHunk: (delta: number) => void; @@ -273,6 +275,15 @@ function builtinCommandSpecs(options: BuildAppCommandsOptions): BuiltinCommandSp run: () => options.selectLayoutMode("auto"), closesMenu: true, }, + { + id: "hunk.view.applyFilePresentationToAllMatching", + title: "Apply the current file presentation to all matching files", + scopes: REVIEW, + defaultKeys: [], + isEnabled: () => options.canApplyFilePresentationToAllMatching, + run: () => options.applyFilePresentationToAllMatching(), + closesMenu: true, + }, { id: "hunk.view.toggleSidebar", title: "Toggle sidebar", @@ -461,7 +472,9 @@ export function buildAppCommands(options: BuildAppCommandsOptions): AppCommand[] const NOOP_COMMAND_OPTIONS: BuildAppCommandsOptions = (() => { const noop = () => {}; return { + canApplyFilePresentationToAllMatching: false, canRefreshCurrentInput: true, + applyFilePresentationToAllMatching: noop, focusFilter: noop, moveToAnnotatedFile: noop, moveToAnnotatedHunk: noop, diff --git a/src/ui/lib/appMenus.test.ts b/src/ui/lib/appMenus.test.ts index ca8f2c2e..9fdf9421 100644 --- a/src/ui/lib/appMenus.test.ts +++ b/src/ui/lib/appMenus.test.ts @@ -36,7 +36,9 @@ function createTestCommands(overrides: Partial = {}) { }; const noop = () => {}; const commands = buildAppCommands({ + canApplyFilePresentationToAllMatching: false, canRefreshCurrentInput: true, + applyFilePresentationToAllMatching: record("applyFilePresentationToAllMatching"), focusFilter: noop, moveToAnnotatedFile: record("moveToAnnotatedFile"), moveToAnnotatedHunk: noop, @@ -178,6 +180,31 @@ describe("buildAppMenus", () => { ]); }); + test("dispatches the host-owned changeset-wide file-presentation action", () => { + const { commands, ran } = createTestCommands({ + canApplyFilePresentationToAllMatching: true, + }); + const menus = buildAppMenus({ + commands, + ...MENU_STATE, + fileViewEntries: [ + { + kind: "item", + label: "File presentation: Preview", + commandId: "hunk.view.filePresentation.preview", + action: () => {}, + }, + ], + fileViewApplyAllLabel: 'Apply "Preview" to all matching files', + }); + + const apply = entry(menus, "view", 'Apply "Preview" to all matching files'); + expect(apply.commandId).toBe("hunk.view.applyFilePresentationToAllMatching"); + expect(apply.hint).toBeUndefined(); + apply.action(); + expect(ran).toContain("applyFilePresentationToAllMatching"); + }); + test("Reload disappears when the current input cannot be reloaded", () => { const { commands } = createTestCommands({ canRefreshCurrentInput: false }); const menus = buildAppMenus({ commands, ...MENU_STATE }); diff --git a/src/ui/lib/appMenus.ts b/src/ui/lib/appMenus.ts index 6a3f7b50..8db42620 100644 --- a/src/ui/lib/appMenus.ts +++ b/src/ui/lib/appMenus.ts @@ -32,6 +32,10 @@ export interface BuildAppMenusOptions { commands: readonly AppCommand[]; /** The extension-contributed subset, in registration order, for the Extensions menu. */ extensionCommands?: readonly AppCommand[]; + /** Host-owned per-file presentation choices appended to View. */ + fileViewEntries?: readonly MenuEntry[]; + /** Live label for the stable host command that applies the selected presentation changeset-wide. */ + fileViewApplyAllLabel?: string; copyDecorations: boolean; layoutMode: LayoutMode; renderSidebar: boolean; @@ -118,6 +122,8 @@ function toExtensionMenuEntries( export function buildAppMenus({ commands, extensionCommands = [], + fileViewEntries = [], + fileViewApplyAllLabel, copyDecorations, layoutMode, renderSidebar, @@ -184,11 +190,27 @@ export function buildAppMenus({ help: [{ commandId: "hunk.app.toggleHelp", label: "Controls help", checked: showHelp }], }; + if (fileViewEntries.length > 0) { + specs.view.push(SEPARATOR); + } + const extensions = toExtensionMenuEntries(commands, extensionCommands); + const applyAllEntries = fileViewApplyAllLabel + ? toMenuEntries(commands, [ + { + commandId: "hunk.view.applyFilePresentationToAllMatching", + label: fileViewApplyAllLabel, + }, + ]) + : []; return { file: toMenuEntries(commands, specs.file), - view: toMenuEntries(commands, specs.view), + view: [ + ...toMenuEntries(commands, specs.view), + ...fileViewEntries, + ...(applyAllEntries.length > 0 ? [{ kind: "separator" as const }, ...applyAllEntries] : []), + ], navigate: toMenuEntries(commands, specs.navigate), agent: toMenuEntries(commands, specs.agent), // No extension commands means no menu at all, rather than an empty dropdown. diff --git a/src/ui/lib/extensionCommands.test.ts b/src/ui/lib/extensionCommands.test.ts index 146d84a1..692e3dcd 100644 --- a/src/ui/lib/extensionCommands.test.ts +++ b/src/ui/lib/extensionCommands.test.ts @@ -88,7 +88,7 @@ describe("buildExtensionAppCommands", () => { test("binds one command to every chord it declares", () => { const ran: string[] = []; const { commands, conflicts } = buildExtensionAppCommands({ - registered: [registeredCommand("meta", "toggle", ["y", "ctrl+g"])], + registered: [registeredCommand("meta", "toggle", ["y", "ctrl+o"])], builtins: builtinCommandMatchProbes(), runCommand: (registered) => ran.push(`${registered.extensionId}.${registered.command.id}`), }); @@ -96,9 +96,9 @@ describe("buildExtensionAppCommands", () => { expect(conflicts).toEqual([]); // One command, one dispatch entry, matching either chord. expect(commands).toHaveLength(1); - expect(commands[0]?.keyLabels).toEqual(["y", "Ctrl+G"]); + expect(commands[0]?.keyLabels).toEqual(["y", "Ctrl+O"]); expect(dispatchAppCommand(commands, "review", chordEvent("y"))?.id).toBe("meta.toggle"); - expect(dispatchAppCommand(commands, "review", chordEvent("ctrl+g"))?.id).toBe("meta.toggle"); + expect(dispatchAppCommand(commands, "review", chordEvent("ctrl+o"))?.id).toBe("meta.toggle"); expect(ran).toEqual(["meta.toggle", "meta.toggle"]); }); diff --git a/src/ui/lib/extensionPaintTheme.ts b/src/ui/lib/extensionPaintTheme.ts new file mode 100644 index 00000000..abe61630 --- /dev/null +++ b/src/ui/lib/extensionPaintTheme.ts @@ -0,0 +1,27 @@ +import type { ExtensionPaintTheme } from "../../extension-api/types"; +import type { AppTheme } from "../themes"; + +/** Project Hunk's active theme onto the one public paint-only extension palette. */ +export function toExtensionPaintTheme(theme: AppTheme): ExtensionPaintTheme { + return Object.freeze({ + appearance: theme.appearance, + background: theme.background, + panel: theme.panel, + panelAlt: theme.panelAlt, + border: theme.border, + accent: theme.accent, + accentMuted: theme.accentMuted, + text: theme.text, + muted: theme.muted, + selectedHunk: theme.selectedHunk, + badgeAdded: theme.badgeAdded, + badgeRemoved: theme.badgeRemoved, + badgeNeutral: theme.badgeNeutral, + fileNew: theme.fileNew, + fileDeleted: theme.fileDeleted, + fileRenamed: theme.fileRenamed, + fileModified: theme.fileModified, + fileUntracked: theme.fileUntracked, + noteBorder: theme.noteBorder, + }); +} diff --git a/test/pty/extensions-integration.test.ts b/test/pty/extensions-integration.test.ts index 2b723152..e4c9bb47 100644 --- a/test/pty/extensions-integration.test.ts +++ b/test/pty/extensions-integration.test.ts @@ -1,9 +1,13 @@ import { afterEach, describe, expect, setDefaultTimeout, test } from "bun:test"; import { existsSync, readFileSync } from "node:fs"; -import { join } from "node:path"; +import { join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; import { createPtyHarness } from "./harness"; const harness = createPtyHarness(); +const REVIEW_TRIAGE_EXTENSION = resolve( + fileURLToPath(new URL("../../examples/extensions/review-triage", import.meta.url)), +); /** Give PTY-backed startup, reloads, and redraws headroom on slower CI machines. */ setDefaultTimeout(30_000); @@ -348,6 +352,52 @@ describe("PTY extensions", () => { } }); + test("the real review-triage extension loads as a folder extension and exposes its menu commands", async () => { + const configHome = harness.createIsolatedConfigHome(); + const fixture = harness.createRepoExtensionFixture(TRANSFORM_EXTENSION_SOURCE); + const session = await harness.launchHunk({ + args: ["diff", "--mode", "stack", "--extension", REVIEW_TRIAGE_EXTENSION], + cwd: fixture.dir, + cols: 140, + rows: 24, + env: { XDG_CONFIG_HOME: configHome }, + }); + + try { + const before = await harness.waitForSnapshot( + session, + (text) => text.includes("alpha.ts") && text.includes("Extensions"), + 20_000, + ); + expect(before).not.toContain("Review triage (session only)"); + + // The command is a real Extensions-menu item, not a private menu hook. + // `Extensions` also appears in the temporary fixture path, so target its chrome position. + // Folder extension registration may finish after the first review frame, so retry the menu + // gesture until the command itself proves that the extension is ready. + let menu: string | null = null; + for (let attempt = 0; attempt < 5 && menu === null; attempt += 1) { + await session.clickAt(33, 0); + try { + menu = await harness.waitForSnapshot( + session, + (text) => text.includes("Toggle review triage"), + 3_000, + ); + } catch { + // A click may land before command registration or close an earlier empty menu; retry it. + } + } + expect(menu).not.toBeNull(); + expect(menu!).toMatch(/Toggle review triage\s+y/); + expect(menu).toMatch(/Mark selected hunk…\s+x/); + expect(menu).toContain("Set review focus…"); + expect(menu).toContain("Clear triage decisions"); + } finally { + session.close(); + } + }); + test("a startup handler's notify renders as a toast and clears itself", async () => { const configHome = harness.createIsolatedConfigHome(); const fixture = harness.createRepoExtensionFixture(NOTIFY_EXTENSION_SOURCE); diff --git a/test/pty/file-views-integration.test.ts b/test/pty/file-views-integration.test.ts new file mode 100644 index 00000000..7264dfed --- /dev/null +++ b/test/pty/file-views-integration.test.ts @@ -0,0 +1,343 @@ +import { afterEach, describe, expect, setDefaultTimeout, test } from "bun:test"; +import { cpSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createPtyHarness } from "./harness"; + +const harness = createPtyHarness(); +const RENDERED_MARKDOWN_EXTENSION = join( + import.meta.dir, + "../../examples/extensions/rendered-markdown", +); +const JSX_FILE_VIEW_EXTENSION = join(import.meta.dir, "../../examples/extensions/jsx-file-view"); +const JSX_FILE_VIEW_GALLERY = join( + import.meta.dir, + "../../examples/extensions/jsx-file-view-gallery", +); +const JSX_MIXED_REVIEW_LAUNCHER = join(JSX_FILE_VIEW_GALLERY, "mixed-review/run.ts"); +setDefaultTimeout(30_000); + +afterEach(() => { + harness.cleanup(); +}); + +/** Create a direct-file Markdown diff so exact old/new source remains host-readable. */ +function createMarkdownPairTest(noteRange: [number, number] = [3, 3]) { + const directory = mkdtempSync(join(tmpdir(), "hunk-file-view-")); + const before = join(directory, "before.md"); + const after = join(directory, "after.md"); + const agentContext = join(directory, "agent.json"); + writeFileSync(before, "# Heading\n\n- old item\n", "utf8"); + writeFileSync(after, "# Heading\n\n- new item\n", "utf8"); + writeFileSync( + agentContext, + JSON.stringify({ + version: 1, + files: [ + { + path: "after.md", + annotations: [{ newRange: noteRange, summary: "Review the new item." }], + }, + ], + }), + "utf8", + ); + return { after, agentContext, before, directory }; +} + +describe("PTY file views", () => { + test("does not load the Markdown example unless the user installs it", async () => { + const pair = createMarkdownPairTest(); + const session = await harness.launchHunk({ + args: ["diff", "--mode", "stack", pair.before, pair.after], + cwd: pair.directory, + cols: 140, + rows: 24, + }); + + try { + await session.waitForText(/before\.md/, { timeout: 20_000 }); + await session.click(/View/); + const menu = await session.waitForText(/File presentation: Raw diff/); + expect(menu).not.toContain("File presentation: Rendered Markdown"); + } finally { + session.close(); + rmSync(pair.directory, { recursive: true, force: true }); + } + }); + + test("loads the Markdown example and keeps hunk navigation live", async () => { + const pair = createMarkdownPairTest(); + const session = await harness.launchHunk({ + args: [ + "diff", + "--extension", + RENDERED_MARKDOWN_EXTENSION, + "--mode", + "stack", + pair.before, + pair.after, + ], + cwd: pair.directory, + cols: 140, + rows: 24, + }); + + try { + await session.waitForText(/before\.md/, { timeout: 20_000 }); + await session.click(/View/); + const menu = await session.waitForText(/File presentation: Rendered Markdown/, { + timeout: 20_000, + }); + expect(menu).toContain("File presentation: Raw diff"); + + await session.press("escape"); + await session.press("f8"); + await session.waitForText(/• new item/); + await session.click(/View/); + const toggled = await session.waitForText(/\[x\] File presentation: Rendered Markdown/, { + timeout: 20_000, + }); + expect(toggled).not.toContain("# Heading"); + + await session.press("escape"); + await session.press("]"); + await session.waitIdle(); + } finally { + session.close(); + rmSync(pair.directory, { recursive: true, force: true }); + } + }); + + for (const demo of [ + { + name: "change atlas", + before: join(JSX_FILE_VIEW_GALLERY, "fixtures/change-atlas/before.ts"), + after: join(JSX_FILE_VIEW_GALLERY, "fixtures/change-atlas/after.ts"), + view: /File presentation: JSX demo: Change atlas/, + first: /▶ CHANGE 01/, + second: /▶ CHANGE 02/, + raw: /const percent = Math\.min/, + }, + { + name: "CSS palette delta", + before: join(JSX_FILE_VIEW_GALLERY, "fixtures/css-palette/before.css"), + after: join(JSX_FILE_VIEW_GALLERY, "fixtures/css-palette/after.css"), + view: /File presentation: JSX demo: CSS palette delta/, + first: /▶ --accent/, + second: /▶ --card-highlight/, + raw: /--canvas: #090d18/, + }, + { + name: "dependency delta", + before: join(JSX_FILE_VIEW_GALLERY, "fixtures/package-dependencies/before/package.json"), + after: join(JSX_FILE_VIEW_GALLERY, "fixtures/package-dependencies/after/package.json"), + view: /File presentation: JSX demo: Dependency delta/, + first: /▶ Package metadata hunk 1/, + second: /▶\s+@opentui\/core/, + raw: /"@opentui\/core": "0\.4\.3"/, + }, + ]) { + test(`runs the checked-in JSX ${demo.name} against a real diff`, async () => { + const session = await harness.launchHunk({ + args: [ + "diff", + "--extension", + JSX_FILE_VIEW_GALLERY, + "--mode", + "stack", + demo.before, + demo.after, + ], + cwd: JSX_FILE_VIEW_GALLERY, + cols: 140, + rows: 24, + }); + + try { + await session.waitForText(/before\.|package\.json/, { timeout: 20_000 }); + await harness.ensureKeyboardIsLive(session); + await session.click(/View/); + await session.waitForText(demo.view, { timeout: 20_000 }); + await session.press("escape"); + await session.press("f8"); + await session.waitForText(demo.first, { timeout: 20_000 }); + await session.press("]"); + await session.waitForText(demo.second, { timeout: 20_000 }); + await session.click(/View/); + await session.waitForText(/File presentation: Raw diff/, { timeout: 20_000 }); + await session.click(/File presentation: Raw diff/); + await session.waitForText(demo.raw, { timeout: 20_000 }); + } finally { + session.close(); + } + }); + } + + test("retains three preview types between raw diffs in one scrollable review stream", async () => { + const session = await harness.launchShellCommand({ + command: `${JSON.stringify(process.execPath)} run ${JSON.stringify(JSX_MIXED_REVIEW_LAUNCHER)}`, + cols: 220, + rows: 24, + }); + + try { + await session.waitForText(/README\.md/, { timeout: 20_000 }); + await harness.ensureKeyboardIsLive(session); + + await session.click(/package\.json/, { first: true }); + await session.press("f8"); + await session.waitForText(/Package metadata hunk 1/, { timeout: 20_000 }); + + await session.click(/invoice\.ts/, { first: true }); + await session.press("f8"); + await session.waitForText(/CHANGE 01/, { timeout: 20_000 }); + + await session.click(/theme\.css/, { first: true }); + await session.press("f8"); + await session.waitForText(/--accent/, { timeout: 20_000 }); + + await session.click(/package\.json/, { first: true }); + await session.waitForText(/@opentui\/core/, { timeout: 20_000 }); + await session.click(/README\.md/, { first: true }); + await session.waitForText(/understanding release changes/, { timeout: 20_000 }); + let reachedRetainedPreview = false; + for (let step = 0; step < 10 && !reachedRetainedPreview; step += 1) { + await session.scrollDown(8); + try { + await session.waitForText(/Package metadata hunk 1/, { timeout: 750 }); + reachedRetainedPreview = true; + } catch { + // Continue through the intentionally tall raw README until the retained preview appears. + } + } + expect(reachedRetainedPreview).toBe(true); + + await session.press("q"); + await new Promise((resolve) => setTimeout(resolve, 200)); + } finally { + session.close(); + } + }); + + test("runs the real folder TSX view by key and menu across two hunks", async () => { + const pair = harness.createMultiHunkFilePair(); + // A fresh folder root avoids Bun reusing an extension module imported by another live test. + const extension = join(pair.dir, "jsx-runtime-proof"); + cpSync(JSX_FILE_VIEW_EXTENSION, extension, { recursive: true }); + const session = await harness.launchHunk({ + args: ["diff", "--extension", extension, "--mode", "stack", pair.before, pair.after], + cwd: pair.dir, + cols: 140, + rows: 24, + }); + + try { + await session.waitForText(/before\.ts/, { timeout: 20_000 }); + await harness.ensureKeyboardIsLive(session); + await session.press("f8"); + let custom = await session.waitForText(/▶ Hunk 1/, { timeout: 20_000 }); + expect(custom).toContain("Hunk 2"); + expect(custom).toContain("row 0 · click for detail"); + expect(custom).not.toContain("invalid span"); + + // This proves the example's current cooperative routing, not a host guarantee that custom + // rows will continue receiving pointer input through every future renderer integration. + await session.click(/▶ Hunk 1/); + custom = await session.waitForText(/lines 1–4 · @@ -1,4 \+1,4 @@/); + expect(custom).not.toContain("row 0 · click for detail"); + + await session.press("]"); + const secondHunk = await session.waitForText(/▶ Hunk 2/); + expect(secondHunk).not.toContain("▶ Hunk 1"); + + await session.press("f8"); + const raw = await session.waitForText(/line60 = 6000/); + expect(raw).not.toContain("Hunk 1"); + + await session.click(/Extensions/); + const menu = await session.waitForText(/Toggle JSX hunk cards \(POC\)/); + expect(menu).toMatch(/Toggle JSX hunk cards \(POC\)\s+F8/); + await session.click(/Toggle JSX hunk cards \(POC\)/); + const menuDispatched = await session.waitForText(/▶ Hunk 2/); + expect(menuDispatched).toContain("Hunk 1"); + } finally { + session.close(); + } + }); + + test("renders a host-owned inline note inside its bound Markdown presentation", async () => { + const pair = createMarkdownPairTest(); + const session = await harness.launchHunk({ + args: [ + "diff", + "--extension", + RENDERED_MARKDOWN_EXTENSION, + "--mode", + "stack", + "--agent-context", + pair.agentContext, + "--agent-notes", + pair.before, + pair.after, + ], + cwd: pair.directory, + cols: 140, + rows: 24, + }); + + try { + await session.waitForText(/before\.md/, { timeout: 20_000 }); + await harness.ensureKeyboardIsLive(session); + await session.press("f8"); + const preview = await session.waitForText(/• new item/); + expect(preview).toContain("Review the new item."); + expect(preview).not.toContain("old item"); + await session.click(/View/); + const menu = await session.waitForText(/\[x\] File presentation: Rendered Markdown/); + expect(menu).toContain("File presentation: Raw diff"); + } finally { + session.close(); + rmSync(pair.directory, { recursive: true, force: true }); + } + }); + + test("falls back all-or-raw for an unbound note and restores the stored view when hidden", async () => { + const pair = createMarkdownPairTest([99, 99]); + const session = await harness.launchHunk({ + args: [ + "diff", + "--extension", + RENDERED_MARKDOWN_EXTENSION, + "--mode", + "stack", + "--agent-context", + pair.agentContext, + "--agent-notes", + pair.before, + pair.after, + ], + cwd: pair.directory, + cols: 140, + rows: 24, + }); + + try { + await session.waitForText(/before\.md/, { timeout: 20_000 }); + await harness.ensureKeyboardIsLive(session); + await session.press("f8"); + const raw = await session.waitForText(/old item/); + expect(raw).not.toContain("• new item"); + await session.click(/View/); + await session.waitForText(/\[x\] File presentation: Rendered Markdown/); + await session.press("escape"); + + await session.press("a"); + const restored = await session.waitForText(/• new item/); + expect(restored).not.toContain("old item"); + } finally { + session.close(); + rmSync(pair.directory, { recursive: true, force: true }); + } + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 178489d5..d1052679 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -17,7 +17,8 @@ "@hunk/session-broker": ["packages/session-broker/src/index.ts"], "@hunk/session-broker-bun": ["packages/session-broker-bun/src/index.ts"], "@hunk/session-broker-core": ["packages/session-broker-core/src/index.ts"], - "@hunk/session-broker-node": ["packages/session-broker-node/src/index.ts"] + "@hunk/session-broker-node": ["packages/session-broker-node/src/index.ts"], + "hunkdiff/extension": ["src/extension-api/index.ts"] }, "strict": true, "skipLibCheck": true, @@ -34,6 +35,8 @@ "packages/**/*.ts", "test/**/*.ts", "test/**/*.tsx", - "benchmarks/**/*.ts" + "benchmarks/**/*.ts", + "examples/extensions/**/*.ts", + "examples/extensions/**/*.tsx" ] }