From b2789d12be1adc03b9435460c4f9202295b437f9 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 06:57:33 +0000 Subject: [PATCH 1/2] docs(examples): add inline-edit extension demonstrating the interactive surfaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A miniature line editor proving the three new capabilities compose: workspace reads seed the buffer, a file-view mode routes keystrokes, fileViews.refresh redraws each change, and a consented workspace write lands on disk and reloads the review. The command handler doubles as the mode's async runtime — the pattern the mode context's deliberate minimalism asks extension authors to use — and the PTY test drives the whole loop from typed keys to written file in a real terminal. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_015aJpBUupsP9L7Wtd7MEzmU --- .changeset/inline-edit-example.md | 2 + examples/README.md | 1 + examples/extensions/inline-edit/README.md | 91 +++ examples/extensions/inline-edit/index.ts | 668 +++++++++++++++++++ examples/extensions/inline-edit/package.json | 9 + scripts/inline-edit-extension.test.ts | 657 ++++++++++++++++++ test/pty/file-views-integration.test.ts | 72 +- 7 files changed, 1499 insertions(+), 1 deletion(-) create mode 100644 .changeset/inline-edit-example.md create mode 100644 examples/extensions/inline-edit/README.md create mode 100644 examples/extensions/inline-edit/index.ts create mode 100644 examples/extensions/inline-edit/package.json create mode 100644 scripts/inline-edit-extension.test.ts diff --git a/.changeset/inline-edit-example.md b/.changeset/inline-edit-example.md new file mode 100644 index 000000000..a845151cc --- /dev/null +++ b/.changeset/inline-edit-example.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/examples/README.md b/examples/README.md index 27f879adb..4c7a6e0d9 100644 --- a/examples/README.md +++ b/examples/README.md @@ -22,6 +22,7 @@ Each folder tells a small review story and includes the exact command to run fro - [`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/inline-edit/`](extensions/inline-edit/) edits the file under review in place, composing a file-view mode, layout refresh, and host-mediated workspace writes. - [`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. diff --git a/examples/extensions/inline-edit/README.md b/examples/extensions/inline-edit/README.md new file mode 100644 index 000000000..628b065a9 --- /dev/null +++ b/examples/extensions/inline-edit/README.md @@ -0,0 +1,91 @@ +# Inline edit extension + +A miniature line editor for the file under review. Press `Ctrl-E`, type into the diff, press `Ctrl-S`, and Hunk writes the file back to your working tree after asking you first. + +This example is **not bundled or loaded by Hunk**. Install it explicitly if you want it. + +It exists to demonstrate that Hunk's interactive extension surfaces compose, so it uses them all at once: + +| Capability | Where this extension uses it | +| --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `ctx.workspace` | `canWriteDocument` gates the affordance, `readDocument("new")` fills the buffer, `writeDocument` performs the consented write and reloads the review. | +| `mode` on a registered file view | `onKey` claims arrows, printable characters, Backspace, Enter, and `Ctrl-S`, and returns `"pass"` for everything else so the rest of Hunk keeps working while you edit. `Ctrl-S` is recognized with `matchesKey`, so the bare control byte terminals send for it matches too. | +| `fileViews.enterMode(viewId)` | One call makes the view the file's presentation _and_ gives its mode the keyboard, so `Ctrl-E` opens the editor in a single press. | +| `fileViews.refresh(viewId, { fileId })` | Every buffer or caret change re-derives the layout. A view's layout is a pure function of `(file, width)`, so this is the only way a stateful presentation redraws — and the buffer belongs to one file, so the refresh is scoped to it and no other file re-lays out. | + +## Try it from this checkout + +```bash +bun run src/main.tsx -- diff --extension ./examples/extensions/inline-edit +``` + +## Install it globally + +Copy the whole folder — it has no dependencies: + +```bash +mkdir -p ~/.config/hunk/extensions +cp -R examples/extensions/inline-edit ~/.config/hunk/extensions/ +``` + +Hunk discovers the folder automatically on later launches. Open **View** and choose **File presentation: Inline edit**, or press `Ctrl-E`. The command is named `inline-edit.edit` for `[keybindings]` customization. + +## Keys + +| Key | Does | +| ----------------------- | ----------------------------------------------------------------------------- | +| `Ctrl-E` | Starts editing the selected file, showing the view if it was not already. | +| `↑` `↓` `←` `→` | Move the caret. `←`/`→` wrap across line ends. | +| any printable character | Types at the caret — including characters bound to Hunk commands, like `z`. | +| `Backspace` | Deletes back one character, joining with the previous line at column 0. | +| `Enter` | Splits the line at the caret. | +| `Ctrl-S` | Asks Hunk to write the buffer. Hunk confirms, writes, and reloads the review. | +| `Esc` | Leaves the editor and **discards** everything typed since the last write. | +| everything else | Still Hunk's: `]` moves to the next hunk, `?` opens help, `q` quits. | + +The header row reads `EDITING — Esc exits · ctrl+s writes`, plus a `MODIFIED` marker whenever the buffer differs from the text on disk. + +## How the pieces fit + +`Ctrl-E` is one press. The command gates on `canWriteDocument`, reads the document, builds the buffer, and calls `fileViews.enterMode` — which makes the view the file's presentation and gives its mode the keyboard together, so the rows the editor acts on are on screen from the moment it holds the keys. When `enterMode` refuses (no `mode`, a file Hunk keeps on raw diff, a view that does not match), it warns by name and returns `false`, and the command drops the buffer it just built rather than leaving a session with no keyboard behind it. + +The editor slot is claimed synchronously, before that command's first `await`. Reading the document suspends the handler, so a guard that only checked "is a session live?" would let a second `Ctrl-E` through the window in between, and one of the two handlers would then be parked forever on a session nothing could end. The claim is released on every way out — an unwritable review, an unreadable document, a refused `enterMode` — and once a session is live it is the live session that answers the next press. + +`onKey` has to answer synchronously — its return value _is_ the routing decision — and the mode context carries only `file` and `fileViews`. So a keystroke can never write anything itself. What `Ctrl-S` does instead is post a request into the edit session, and the command handler that entered the mode is still awaiting that session: `ctx.workspace` is valid for the whole life of a command handler's promise, so the handler is the mode's async runtime. + +That loop ends the same way from every direction, because `onExit` runs on every exit path: + +- **Saved.** A successful `writeDocument` reloads the session, the reload exits the mode, `onExit` ends the loop. +- **Escaped.** Escape is host-owned, so it exits the mode without ever reaching `onKey`, and the loop ends the same way. +- **Cancelled write.** `{ ok: false, reason: "cancelled" }` is the user answering, not a failure. The editor keeps running. +- **Failed write.** The `detail` sentence is shown as a warning and the editor keeps running, so nothing typed is lost to a full disk. + +## Limitations + +This is a demonstration, not an editor: + +- **No wrapping.** Lines are truncated at the pane width with `…`. A file-view layout must be deterministic for `(file, width)`, and wrapping would be a second layout problem on top of the one this example is about. +- **Whole-line truncation.** The caret can move past the visible edge; the row does not scroll horizontally to follow it. +- **One buffer, no undo.** Escape discards everything since the last write, with no confirmation beyond the notice it leaves behind. +- **Writes are working-tree only**, which is a property of `ctx.workspace`: `hunk show`, `hunk patch`, a staged diff, and a file-pair diff have no working-tree document to replace, so the command refuses instead of opening an editor that could not save. +- **Agent notes describe the document as it was loaded.** They stay on the lines they were written about, but nothing re-reads the changeset while you type. + +## Source bindings and provenance + +Rows carry `sourceRanges` so Hunk can place its own inline notes inside this presentation, and a row may only bind a line it honestly still _is_. Row position cannot answer that once you split or join a line, so the edit session keeps **provenance**: for each buffer line, the document line it came from, or nothing at all. + +| Edit | Provenance | +| ------------------ | -------------------------------------------------------------------- | +| typing in a line | kept — an edited line is still the line it came from | +| `Enter` (split) | the first line keeps it; the new tail gets none | +| `Backspace` (join) | the merged line keeps the first line's; the second line's is dropped | + +So a line you inserted binds nothing, the lines below a split keep the numbers they had, and a join is the exact inverse of the split it undoes. The hunk extents in `hunkRows` are derived from the same provenance, so the hunk highlight follows the rows still holding a hunk's lines instead of drifting down by however many lines you added above them. A hunk whose lines were all joined away collapses onto the first row. + +One thing this shows that is easy to get wrong: Hunk accepts a binding only on a row exactly one `hunkRows` extent owns, and rejects the whole layout otherwise — so a last pass drops the bindings no extent owns, and context lines outside every hunk are presented without one. The [rendered Markdown example](../rendered-markdown/) ends its layout with the same pass, for the same reason. + +## Where this is documented + +- [`docs/extensions.md` → Interactive file views](../../../docs/extensions.md#interactive-file-views) +- [`docs/extensions.md` → Reading and writing a reviewed file](../../../docs/extensions.md#reading-and-writing-a-reviewed-file) +- [`docs/extensions.md` → `hunk.registerFileView(view)`](../../../docs/extensions.md#hunkregisterfileviewview-experimental) diff --git a/examples/extensions/inline-edit/index.ts b/examples/extensions/inline-edit/index.ts new file mode 100644 index 000000000..a6f56f354 --- /dev/null +++ b/examples/extensions/inline-edit/index.ts @@ -0,0 +1,668 @@ +/** + * Inline edit — a miniature line editor for the file under review. + * + * This is the demonstration three extension-API capabilities were built for, + * and the proof that they compose: + * + * - `ctx.workspace` fills the buffer with `readDocument`, gates the affordance + * with `canWriteDocument`, and performs the consented `writeDocument`. + * - a file view `mode` routes real keystrokes into the view: it claims arrows, + * printable characters, Backspace, Enter, and Ctrl-S, and declines the rest + * so `]`, `?`, and `q` keep working while the editor is running. One + * `enterMode` selects the view *and* takes the keyboard, so one Ctrl-E opens + * the editor rather than two. + * - `fileViews.refresh(VIEW_ID, { fileId })` re-derives the layout after every + * buffer or caret change, which is the only way a stateful view redraws at + * all — scoped to the edited file, because the buffer is that file's state + * and every other file presenting this view is still showing its document. + * + * Keys are matched with `matchesKey` from the public API rather than by reading + * modifier flags, so Ctrl-S is recognized in both forms terminals send it: + * `ctrl: true, name: "s"`, and the bare C0 control byte `0x13`, which + * carries no `ctrl` flag at all. + * + * The load-bearing shape is the command handler. `onKey` must answer + * synchronously, and the mode context deliberately carries only `file` and + * `fileViews` — so a keystroke can only *request* a save. `ctx.workspace` is + * valid for the whole life of a command handler's promise, so the handler that + * entered the mode parks on a session lifecycle loop and is the thing that + * actually writes. Every exit path — the reload after a save, Escape, a + * host auto-exit — runs `onExit`, which ends that loop, so the handler always + * settles and no promise is left dangling. + * + * That last guarantee is why the editor slot is claimed *synchronously*, before + * the handler's first `await`: two fast Ctrl-E presses would otherwise both + * pass a guard that only reads the live session, and the loser's loop would + * park on a mailbox nothing is left holding a reference to. + */ + +import { matchesKey } from "hunkdiff/extension"; +import type { + ExtensionCommandContext, + ExtensionDiffFile, + ExtensionFactory, + ExtensionFileChangeRange, + ExtensionFileViewRow, + ExtensionFileViewSpan, + ExtensionKeyEvent, +} from "hunkdiff/extension"; + +/** The registered view id. This folder's extension id is `inline-edit` too. */ +const VIEW_ID = "inline-edit"; +const HEADER_LABEL = "EDITING — Esc exits · ctrl+s writes"; +const MODIFIED_MARKER = " MODIFIED"; +/** Marks the cursor line in the gutter, where an editor would show a caret. */ +const CURSOR_MARK = "▎"; + +/** What the mode can ask its command handler for, between keystrokes. */ +type SessionRequest = "save" | "end"; + +/** + * One file's live edit buffer, plus the mailbox the mode talks to the command + * handler through. + * + * The mailbox is the whole trick: `requestSave` and `end` are synchronous, so + * `onKey` and `onExit` can call them and answer immediately, while `next()` is + * what the async handler awaits. + */ +interface EditSession { + readonly fileId: string; + readonly path: string; + /** The line terminator the document was read with, preserved on write. */ + readonly newline: string; + /** Whether the document ended with a terminator, also preserved on write. */ + readonly endsWithNewline: boolean; + /** Mutated in place — never reassigned, since `text()` closes over it. */ + readonly lines: string[]; + /** + * The **provenance policy**: for each buffer line, the one-based line of the + * document it was loaded from that this line still *is*, or `null` for a line + * the document never had. + * + * Row positions stop describing the document the moment a line is split or + * joined, so this array — not the row's index — is what a source binding may + * claim. It is kept parallel to `lines` by exactly three rules: + * + * - typing in a line keeps its provenance; an edited line still corresponds + * to the line it came from; + * - a split keeps the first line's provenance and gives the tail `null`, + * because the tail is a line the document does not have; + * - a join keeps the first line's provenance and drops the second's, which + * makes a join the exact inverse of the split it undoes. + */ + readonly provenance: (number | null)[]; + /** The text on disk as far as this session knows, so `MODIFIED` is a fact. */ + savedText: string; + cursorLine: number; + cursorColumn: number; + /** Ask the command handler to write the buffer. Answers immediately. */ + requestSave(): void; + /** Tell the handler the session is over. Every exit path calls this. */ + end(): void; + /** Await the next request. Resolves `"end"` forever once the session ended. */ + next(): Promise; + /** The buffer as one document, with the read's line endings restored. */ + text(): string; +} + +/** Clamp a value into an inclusive range. */ +function clamp(value: number, low: number, high: number) { + return Math.min(Math.max(value, low), high); +} + +/** Cut text to a column budget, marking the cut so truncation is never silent. */ +function truncate(value: string, width: number) { + if (value.length <= width) { + return value; + } + return width <= 1 ? value.slice(0, Math.max(0, width)) : `${value.slice(0, width - 1)}…`; +} + +/** Split a document into editable lines, dropping the terminator's empty tail. */ +function splitDocumentLines(text: string) { + // CRLF, LF, and bare CR all count: a CR-only document is still a document + // with lines, and treating it as one line would edit the wrong structure. + const lines = text.split(/\r\n|\r|\n/); + if (lines.length > 1 && lines.at(-1) === "") { + lines.pop(); + } + return lines.length > 0 ? lines : [""]; +} + +/** The document's own line terminator, preserved verbatim on write. */ +function detectNewline(text: string) { + if (text.includes("\r\n")) { + return "\r\n"; + } + return text.includes("\r") ? "\r" : "\n"; +} + +/** Collect the new-side line numbers this changeset added, for read-only tone. */ +function addedLineNumbers(changes: readonly ExtensionFileChangeRange[]) { + const added = new Set(); + for (const change of changes) { + if (change.kind !== "added") { + continue; + } + for (let line = change.range[0]; line <= change.range[1]; line += 1) { + added.add(line); + } + } + return added; +} + +/** + * The character a key would type, or `null` for anything that is not text. + * + * A mode receives every key the app's modal surfaces did not claim, so deciding + * what counts as typing is the extension's job: one character, no modifier that + * turns it into a chord, and nothing in the C0/DEL control range (which is how + * Enter, Tab, Backspace, and Ctrl-S arrive). + */ +function printableCharacter(key: ExtensionKeyEvent) { + if (key.ctrl === true || key.meta === true || key.option === true) { + return null; + } + const sequence = key.sequence ?? ""; + if (sequence.length !== 1) { + return null; + } + const code = sequence.codePointAt(0) ?? 0; + return code >= 0x20 && code !== 0x7f ? sequence : null; +} + +/** Build one file's edit session from the exact document text behind it. */ +function createEditSession(file: ExtensionDiffFile, text: string, cursorLine: number): EditSession { + const newline = detectNewline(text); + const endsWithNewline = /\r\n$|\r$|\n$/.test(text); + const lines = splitDocumentLines(text); + + let pending: ((request: SessionRequest) => void) | null = null; + let queued: SessionRequest | null = null; + let ended = false; + + /** Hand one request to the waiting handler, or hold it until one waits. */ + const post = (request: SessionRequest) => { + if (ended) { + return; + } + if (request === "end") { + ended = true; + } + if (pending) { + const resolve = pending; + pending = null; + resolve(request); + return; + } + // `"end"` supersedes a held save: once the session is over, the write that + // save asked for no longer belongs to anything. + queued = request === "end" ? "end" : (queued ?? "save"); + }; + + const session: EditSession = { + fileId: file.id, + path: file.path, + newline, + endsWithNewline, + lines, + // Loading the document is the one moment buffer and document agree, so + // every line starts out being exactly the line it was read from. + provenance: lines.map((_, index) => index + 1), + // Normalized rather than the raw read, so a document with mixed terminators + // is not reported as modified before anyone has typed into it. + savedText: lines.join(newline) + (endsWithNewline ? newline : ""), + cursorLine: clamp(cursorLine, 0, lines.length - 1), + cursorColumn: 0, + requestSave: () => post("save"), + end: () => post("end"), + next() { + if (queued !== null) { + const request = queued; + queued = null; + return Promise.resolve(request); + } + // An ended session answers instantly, so a handler can never park on a + // promise nothing is left to resolve. + if (ended) { + return Promise.resolve("end"); + } + return new Promise((resolve) => { + pending = resolve; + }); + }, + text: () => lines.join(newline) + (endsWithNewline ? newline : ""), + }; + return session; +} + +/** Keep the caret inside the buffer after any edit or movement. */ +function clampCursor(session: EditSession) { + session.cursorLine = clamp(session.cursorLine, 0, session.lines.length - 1); + const line = session.lines[session.cursorLine] ?? ""; + session.cursorColumn = clamp(session.cursorColumn, 0, line.length); +} + +/** + * Insert typed text at the caret. + * + * Provenance is untouched: an edited line is still the document line it came + * from, so a note bound to it stays where the user is typing. + */ +function insertText(session: EditSession, text: string) { + const line = session.lines[session.cursorLine] ?? ""; + session.lines[session.cursorLine] = + line.slice(0, session.cursorColumn) + text + line.slice(session.cursorColumn); + session.cursorColumn += text.length; + clampCursor(session); +} + +/** Delete backwards, joining with the previous line at column 0. */ +function deleteBackwards(session: EditSession) { + const line = session.lines[session.cursorLine] ?? ""; + if (session.cursorColumn > 0) { + session.lines[session.cursorLine] = + line.slice(0, session.cursorColumn - 1) + line.slice(session.cursorColumn); + session.cursorColumn -= 1; + clampCursor(session); + return; + } + if (session.cursorLine === 0) { + return; + } + const previous = session.lines[session.cursorLine - 1] ?? ""; + session.lines.splice(session.cursorLine - 1, 2, previous + line); + // Provenance policy: the merged line keeps the first line's provenance and + // the second line's is dropped, so the lines below keep the numbers they had. + session.provenance.splice(session.cursorLine - 1, 2, session.provenance[session.cursorLine - 1]!); + session.cursorLine -= 1; + session.cursorColumn = previous.length; + clampCursor(session); +} + +/** Split the current line at the caret. */ +function splitLine(session: EditSession) { + const line = session.lines[session.cursorLine] ?? ""; + session.lines.splice( + session.cursorLine, + 1, + line.slice(0, session.cursorColumn), + line.slice(session.cursorColumn), + ); + // Provenance policy: the head is still the line it was, and the tail is a + // line the document has never had — so it gets no provenance rather than + // inheriting the number of whatever line used to sit below it. + session.provenance.splice(session.cursorLine, 1, session.provenance[session.cursorLine]!, null); + session.cursorLine += 1; + session.cursorColumn = 0; + clampCursor(session); +} + +/** Move the caret one line or one column, wrapping columns across line ends. */ +function moveCursor(session: EditSession, key: ExtensionKeyEvent) { + if (key.name === "up" || key.name === "down") { + session.cursorLine += key.name === "down" ? 1 : -1; + clampCursor(session); + return; + } + if (key.name === "left") { + if (session.cursorColumn === 0 && session.cursorLine > 0) { + session.cursorLine -= 1; + session.cursorColumn = (session.lines[session.cursorLine] ?? "").length; + } else { + session.cursorColumn -= 1; + } + clampCursor(session); + return; + } + const line = session.lines[session.cursorLine] ?? ""; + if (session.cursorColumn >= line.length && session.cursorLine < session.lines.length - 1) { + session.cursorLine += 1; + session.cursorColumn = 0; + } else { + session.cursorColumn += 1; + } + clampCursor(session); +} + +/** Render the editing header, which is also where `MODIFIED` is reported. */ +function headerRow(session: EditSession, width: number): ExtensionFileViewRow { + const marker = session.text() === session.savedText ? "" : MODIFIED_MARKER; + // The marker outranks the key legend when columns are tight: unsaved work is + // the more important half of this row. + const label = truncate(HEADER_LABEL, Math.max(0, width - marker.length)); + const spans: ExtensionFileViewSpan[] = []; + if (label.length > 0) { + spans.push({ text: label, tone: "accent", attributes: ["bold"] }); + } + if (marker.length > 0) { + spans.push({ text: truncate(marker, width), tone: "added", attributes: ["bold"] }); + } + // Every row has to occupy a terminal cell, however narrow the pane got. + return { id: "editing", spans: spans.length > 0 ? spans : [{ text: " " }] }; +} + +/** + * A whole-file inline editor for the reviewed document. + * + * Every piece of mutable state lives in this closure, keyed to the file it + * belongs to, so nothing leaks between files or between Hunk sessions. + */ +const inlineEditExtension: ExtensionFactory = (hunk) => { + // At most one session exists at a time — the host allows one active mode + // app-wide — but it is tagged with its file so a layout for any *other* file + // presenting this view still renders read-only. + let editSession: EditSession | null = null; + const sessionFor = (fileId: string) => (editSession?.fileId === fileId ? editSession : null); + + /** + * Whether a command handler is between claiming the editor and having a live + * session. `editSession` alone cannot answer that: it is assigned after the + * `readDocument` await, and a guard that only reads it lets a second press + * through the window in between. + */ + let opening = false; + + /** + * Claim the one editor slot, then build the session `file` is edited through + * — or `null` when the review, the document, or the host refuses. + * + * The claim is taken synchronously, before the first await, and released on + * every way out of this function, so exactly one command handler can ever own + * the mailbox a session's keystrokes are posted to. + */ + const beginEditSession = async (ctx: ExtensionCommandContext, file: ExtensionDiffFile) => { + opening = true; + try { + // The affordance gate. Reads work in every review kind, but writes are + // working-tree only, and an editor that cannot save is a lie. + if (!ctx.workspace.canWriteDocument(file.id)) { + ctx.notify( + `${file.path} cannot be written from this review — inline edit needs a working-tree diff`, + "warning", + ); + return null; + } + + const document = await ctx.workspace.readDocument(file.id, "new"); + if (document === null) { + ctx.notify(`No readable document for ${file.path}`, "warning"); + return null; + } + + // Start on the hunk the user was looking at, so editing opens where the + // review already pointed. + const hunks = file.hunks ?? []; + const selectedHunk = hunks[ctx.selection.hunkIndex ?? 0] ?? hunks[0]; + const session = createEditSession(file, document, (selectedHunk?.newRange?.[0] ?? 1) - 1); + + editSession = session; + // One step: if the file was on raw diff or another view, entering selects + // this one too, so a single Ctrl-E goes straight into the editor. + if (!ctx.fileViews.enterMode(VIEW_ID)) { + // `enterMode` already warned naming the refusal; drop the buffer that + // now has no keyboard behind it. + editSession = null; + return null; + } + return session; + } finally { + // Held across the opening window only. A live session is guarded by + // `editSession` from here on, and a refused one left it null. + opening = false; + } + }; + + hunk.registerFileView({ + id: VIEW_ID, + title: "Inline edit", + matches(file) { + // Any file Hunk read as text. A deleted file matches too and is declined + // by `layout` below, where the missing new side actually shows up. + return file.isBinary !== true && file.isTooLarge !== true; + }, + async layout(input) { + const session = sessionFor(input.file.id); + // While a session is live the buffer *is* the document: re-reading would + // throw away everything typed since the last write. + const document = session ? null : await input.readDocument("new"); + if (!session && document === null) { + return null; + } + if (input.signal.aborted) { + return null; + } + + const lines = session ? session.lines : splitDocumentLines(document ?? ""); + const numberWidth = String(Math.max(lines.length, 1)).length; + // The caret cell is reserved in both states, so entering the mode changes + // what the rows say without reflowing where the text starts. + const gutterWidth = numberWidth + 2; + const textWidth = Math.max(1, input.width - gutterWidth); + // Added-line tone belongs to the read-only presentation only: once the + // buffer is edited, the changeset's line numbers no longer describe it. + const added = session ? new Set() : addedLineNumbers(input.changes); + const headerOffset = session ? 1 : 0; + // The single source of truth for everything this layout claims about the + // document: which document line each buffer line still is. Before a + // session exists the buffer *is* the document, so the mapping is the + // identity; while one runs it is the session's provenance, which survives + // splits and joins that row positions do not. + const provenance: (number | null)[] = session + ? session.provenance + : lines.map((_, index) => index + 1); + // The new-side line spans this file's changeset declares, which both the + // hunk extents and the bindings that must sit inside them come from. + const hunkRanges: [number, number][] = (input.file.hunks ?? []).map( + (entry) => entry.newRange ?? [1, 1], + ); + + const rows: ExtensionFileViewRow[] = []; + if (session) { + rows.push(headerRow(session, input.width)); + } + + lines.forEach((line, index) => { + const lineNumber = index + 1; + const onCursor = session !== null && index === session.cursorLine; + const visible = truncate(line, textWidth); + const spans: ExtensionFileViewSpan[] = [ + { + text: `${onCursor ? CURSOR_MARK : " "}${String(lineNumber).padStart(numberWidth)} `, + tone: onCursor ? "accent" : "muted", + }, + ]; + + if (session && onCursor) { + // Three spans put the caret on a column without needing an inverse + // attribute the row contract does not have. + const caret = clamp(session.cursorColumn, 0, visible.length); + if (caret > 0) { + spans.push({ text: visible.slice(0, caret) }); + } + spans.push({ + text: visible.slice(caret, caret + 1) || " ", + tone: "accent", + attributes: ["underline", "bold"], + }); + if (caret + 1 < visible.length) { + spans.push({ text: visible.slice(caret + 1) }); + } + } else if (added.has(lineNumber)) { + spans.push({ text: visible, tone: "added" }); + } else { + spans.push({ text: visible }); + } + + const source = provenance[index] ?? null; + rows.push({ + id: `line:${lineNumber}`, + spans, + // One row per source line is what lets Hunk place inline agent notes + // inside this presentation — so a row may only bind the line it still + // *is*, never the line its position would suggest. A line the split + // key invented binds nothing at all rather than shifting every note + // below it onto the wrong code. + ...(source === null + ? {} + : { sourceRanges: [{ side: "new" as const, range: [source, source] as const }] }), + }); + }); + + // Hunk extents come from the same provenance, for the same reason: the + // hunk highlight has to follow the rows still holding the hunk's lines, + // wherever splitting and joining above them moved those rows to. + const hunkRows = hunkRanges.map((range) => { + let startRow = -1; + let endRow = -1; + provenance.forEach((line, index) => { + if (line === null || line < range[0] || line > range[1]) { + return; + } + const row = headerOffset + index; + startRow = startRow === -1 ? row : startRow; + endRow = row; + }); + // A hunk whose every line was joined away has no row left to point at. + // Collapse it onto the first row: in bounds, which is all the host asks + // of an extent, and the pass below keeps it from claiming a binding. + return startRow === -1 ? { startRow: 0, endRow: 0 } : { startRow, endRow }; + }); + + // Hunk places an inline note through a bound row, and only accepts one + // that exactly one hunk extent owns. Context lines outside every hunk — + // and the whole file when a review has no hunks at all — are presented + // without a binding rather than making the layout unusable. + const boundRows = rows.map((row, rowIndex) => { + const owners = hunkRows.filter( + (extent) => rowIndex >= extent.startRow && rowIndex <= extent.endRow, + ).length; + if (owners === 1 || row.sourceRanges === undefined) { + return row; + } + const { sourceRanges: _unowned, ...unbound } = row; + return unbound; + }); + + return { rows: boundRows, hunkRows }; + }, + mode: { + onEnter(ctx) { + // The session was created before `enterMode`, so this is the redraw + // that swaps the read-only rows for the editable buffer. Scoped to the + // edited file: every other file presenting this view still shows the + // document it read, and has no reason to lay out again. + ctx.fileViews.refresh(VIEW_ID, { fileId: ctx.file.id }); + }, + onKey(key, ctx) { + const session = sessionFor(ctx.file.id); + if (!session) { + // No buffer means nothing here is an editor; decline rather than + // swallow keys the review still has uses for. + return "pass"; + } + + // Requested, never performed: `onKey` answers synchronously, and the + // write lives on the command handler's promise with `ctx.workspace`. + if (matchesKey("ctrl+s", key)) { + session.requestSave(); + return "handled"; + } + + if ( + key.name === "up" || + key.name === "down" || + key.name === "left" || + key.name === "right" + ) { + moveCursor(session, key); + } else if (key.name === "backspace") { + deleteBackwards(session); + } else if (matchesKey("enter", key)) { + splitLine(session); + } else { + const character = printableCharacter(key); + if (character === null) { + // Everything the editor has no use for stays Hunk's: `]` still + // moves to the next hunk and `?` still opens the help overlay. + return "pass"; + } + insertText(session, character); + } + + // A stateful view has no `(file, width)` change to announce, so this is + // how every keystroke reaches the screen — for this file's buffer only. + ctx.fileViews.refresh(VIEW_ID, { fileId: session.fileId }); + return "handled"; + }, + onExit(ctx) { + const ending = editSession; + editSession = null; + // The only thing that ends the command handler's loop. Escape, a + // reload after a write, and a host auto-exit all arrive here. + ending?.end(); + // Back to the read-only presentation, again for the edited file alone. + ctx.fileViews.refresh(VIEW_ID, { fileId: ending?.fileId ?? ctx.file.id }); + }, + }, + }); + + hunk.registerCommand( + { id: "edit", title: "Edit the selected file inline", key: "ctrl+e" }, + async (ctx) => { + const file = ctx.selection.file; + if (!file) { + ctx.notify("Select a file to edit", "warning"); + return; + } + + // `onKey` passes on Ctrl-E, so this command is reachable from inside its + // own mode, and from a second press while the first one is still opening. + // Answer both instead of starting a second session on one file. + if (opening) { + ctx.notify("Already opening the editor"); + return; + } + if (editSession !== null) { + ctx.notify(`Already editing ${editSession.path} — Esc exits, ctrl+s writes`); + return; + } + + const session = await beginEditSession(ctx, file); + if (!session) { + return; + } + + // From here this handler *is* the mode's runtime. It stays parked on the + // session's mailbox until something ends the session, which is why the + // write can be async while `onKey` stays synchronous. + for (let request = await session.next(); request !== "end"; request = await session.next()) { + const text = session.text(); + if (text === session.savedText) { + ctx.notify("No unsaved edits"); + continue; + } + + const result = await ctx.workspace.writeDocument({ fileId: session.fileId, text }); + if (result.ok) { + session.savedText = text; + ctx.notify(`Wrote ${session.path}`); + // Hunk reloads the review after a successful write, and a reload + // exits the mode — `onExit` posts the `"end"` this loop stops on. + continue; + } + if (result.reason === "cancelled") { + // The user answered the question. Keep editing. + continue; + } + ctx.notify(result.detail, "warning"); + } + + if (session.text() !== session.savedText) { + ctx.notify(`Discarded unsaved edits to ${session.path}`); + } + }, + ); +}; + +export default inlineEditExtension; diff --git a/examples/extensions/inline-edit/package.json b/examples/extensions/inline-edit/package.json new file mode 100644 index 000000000..560eaf882 --- /dev/null +++ b/examples/extensions/inline-edit/package.json @@ -0,0 +1,9 @@ +{ + "name": "hunk-inline-edit-extension", + "private": true, + "hunk": { + "extensions": [ + "./index.ts" + ] + } +} diff --git a/scripts/inline-edit-extension.test.ts b/scripts/inline-edit-extension.test.ts new file mode 100644 index 000000000..fcbf5c8d6 --- /dev/null +++ b/scripts/inline-edit-extension.test.ts @@ -0,0 +1,657 @@ +import { describe, expect, test } from "bun:test"; +import type { + ExtensionCommand, + ExtensionCommandHandler, + ExtensionFileChangeRange, + ExtensionDiffFile, + ExtensionFileView, + ExtensionFileViewLayout, + ExtensionKeyEvent, + ExtensionWorkspaceWriteResult, + HunkExtensionAPI, +} from "../src/extension-api/types"; +import { validateFileViewLayout } from "../src/ui/fileViews/layout"; +import inlineEditExtension from "../examples/extensions/inline-edit"; + +const TEST_FILE = { + id: "alpha", + path: "alpha.ts", + patch: "", + stats: { additions: 1, deletions: 0 }, + metadata: {}, + agent: null, + hunks: [{ index: 0, header: "@@ -1 +1,2 @@", newRange: [2, 2] }], +} as const; + +/** + * A three-line file whose one hunk covers the whole document. + * + * Editing in the middle of a buffer is what moves rows away from the lines they + * were loaded from, and a hunk over every line is what lets the host's own + * validator check the result: it requires each bound row to sit inside exactly + * one hunk extent. + */ +const WHOLE_FILE_HUNK_FILE = { + ...TEST_FILE, + hunks: [{ index: 0, header: "@@ -1,3 +1,3 @@", newRange: [1, 3] }], +} as const; + +const THREE_LINE_DOCUMENT = "alpha\nbeta\ngamma\n"; + +const ADDED_SECOND_LINE: ExtensionFileChangeRange[] = [ + { hunkIndex: 0, kind: "added", range: [2, 2] }, +]; + +/** + * Ctrl-S as a terminal that decodes nothing sends it: a bare C0 byte, with no + * `ctrl` flag and no `name`. Spelled by code point so it stays visible here. + */ +const CTRL_S_CONTROL_BYTE = String.fromCharCode(0x13); + +/** Register the example against a fake API and keep what it contributed. */ +function registerInlineEditTestExtension() { + let view: ExtensionFileView | undefined; + let command: ExtensionCommand | undefined; + let commandHandler: ExtensionCommandHandler | undefined; + inlineEditExtension({ + registerCommand(candidate: ExtensionCommand, handler: ExtensionCommandHandler) { + command = candidate; + commandHandler = handler; + }, + registerFileView(candidate: ExtensionFileView) { + view = candidate; + }, + } as HunkExtensionAPI); + return { view: view!, command: command!, commandHandler: commandHandler! }; +} + +/** Let every already-resolved promise in the handler's loop run to its next await. */ +function flush() { + return new Promise((resolve) => setTimeout(resolve, 0)); +} + +/** + * Report whether a command handler's promise settles, on a deadline. + * + * A handler that parks on a mailbox nothing can end never rejects — it simply + * never resolves — so awaiting it directly would hang the run instead of + * failing it. + */ +function settlement(handler: void | Promise) { + return Promise.race([ + Promise.resolve(handler).then(() => "settled" as const), + new Promise<"parked">((resolve) => setTimeout(() => resolve("parked"), 50)), + ]); +} + +/** Flatten one layout row to the text a terminal would show. */ +function rowText(layout: ExtensionFileViewLayout | null, index: number) { + return (layout?.rows[index]?.spans ?? []).map((span) => span.text).join(""); +} + +/** + * Drive the example the way the host does. + * + * The fake owns the thing the real host owns and the extension only observes: + * the mode lifecycle callbacks that fire around `enterMode`/`exitMode`. There + * is no separate "is the view showing" state to set up, because `enterMode` + * selects the view for the file itself. + */ +function createInlineEditTestHost({ + canWrite = true, + document = "alpha\nbeta\n", + enters = true, + file = TEST_FILE as unknown as ExtensionDiffFile, + holdDocumentReads = false, + write = async (): Promise => ({ ok: true }), +}: { + canWrite?: boolean; + document?: string | null; + /** Whether the fake host lets `enterMode` start the mode, as the real one may refuse. */ + enters?: boolean; + /** The reviewed file the command acts on, when the default hunk shape is not the point. */ + file?: ExtensionDiffFile; + /** Suspend `readDocument` until the test releases it, to open the command's await window. */ + holdDocumentReads?: boolean; + write?: (text: string) => Promise; +} = {}) { + const { view, command, commandHandler } = registerInlineEditTestExtension(); + const notices: { message: string; type?: string }[] = []; + const refreshed: { viewId: string; fileId?: string }[] = []; + const selected: (string | null)[] = []; + const writes: string[] = []; + const documentReads: string[] = []; + let modeActive = false; + let releaseDocumentReads = () => {}; + const readGate = holdDocumentReads + ? new Promise((resolve) => { + releaseDocumentReads = resolve; + }) + : null; + + const fileViews = { + select: (viewId: string | null) => selected.push(viewId), + refresh: (viewId: string, options?: { fileId?: string }) => + refreshed.push({ + viewId, + ...(options?.fileId === undefined ? {} : { fileId: options.fileId }), + }), + isModeActive: () => modeActive, + // One step in the real host too: entering selects the view for the file + // when it is not already showing it, so there is nothing to activate first. + enterMode: () => { + if (!enters) { + return false; + } + modeActive = true; + view.mode?.onEnter?.({ ...modeContext(), file } as never); + return true; + }, + exitMode: () => { + if (!modeActive) { + return; + } + modeActive = false; + view.mode?.onExit?.(modeContext() as never); + }, + }; + const notify = (message: string, type?: string) => notices.push({ message, type }); + const modeContext = () => ({ cwd: "/repo", file, fileViews, notify }); + + const ctx = { + cwd: "/repo", + notify, + fileViews, + selection: { file, hunkIndex: 0 }, + workspace: { + canWriteDocument: () => canWrite, + readDocument: async (fileId: string) => { + // Suspending here is what a slow working-tree read does to the command: + // it opens a window in which a second Ctrl-E can arrive. + if (readGate) { + await readGate; + } + documentReads.push(fileId); + return document; + }, + writeDocument: async ({ text }: { fileId: string; text: string }) => { + writes.push(text); + return write(text); + }, + }, + }; + + return { + command, + documentReads, + notices, + refreshed, + selected, + view, + writes, + isModeActive: () => modeActive, + /** Let a held `readDocument` resolve, closing the window it opened. */ + releaseDocumentReads: () => releaseDocumentReads(), + /** Run the command exactly as a keypress would, without awaiting its loop. */ + run: () => commandHandler(ctx as never), + /** Deliver one key to the mode, answering `"pass"` when no mode is running. */ + press: (key: ExtensionKeyEvent) => + modeActive ? view.mode?.onKey(key, modeContext() as never) : "pass", + /** Escape is host-owned: it exits without ever reaching `onKey`. */ + escape: () => fileViews.exitMode(), + /** Lay the current file out at a fixed width, as the review stream does. */ + layout: (changes: ExtensionFileChangeRange[] = []) => + view.layout({ + file, + width: 40, + signal: new AbortController().signal, + changes, + readDocument: async () => { + documentReads.push("view"); + return document; + }, + } as never) as Promise, + }; +} + +describe("inline edit example extension", () => { + test("registers one interactive file view and one command on a free chord", () => { + const host = createInlineEditTestHost(); + expect(host.command).toMatchObject({ id: "edit", key: "ctrl+e" }); + expect(host.view.id).toBe("inline-edit"); + expect(typeof host.view.mode?.onKey).toBe("function"); + expect(host.view.matches({ path: "alpha.ts" } as never)).toBe(true); + expect(host.view.matches({ path: "logo.png", isBinary: true } as never)).toBe(false); + }); + + test("renders the read document with line numbers, added tone, and source bindings", async () => { + const host = createInlineEditTestHost(); + const layout = await host.layout(ADDED_SECOND_LINE); + + expect(layout?.rows).toEqual([ + { + id: "line:1", + // Line 1 is context this file's one hunk does not cover, so it is shown + // and not bound: Hunk requires every bound row to sit inside exactly one + // hunk extent, and a note could never be placed here anyway. + spans: [{ text: " 1 ", tone: "muted" }, { text: "alpha" }], + }, + { + id: "line:2", + spans: [ + { text: " 2 ", tone: "muted" }, + { text: "beta", tone: "added" }, + ], + sourceRanges: [{ side: "new", range: [2, 2] }], + }, + ]); + expect(layout?.hunkRows).toEqual([{ startRow: 1, endRow: 1 }]); + expect(validateFileViewLayout(layout, TEST_FILE.hunks.length, 40)).toMatchObject({ + valid: true, + }); + }); + + test("declines the file when the new-side document is unreadable", async () => { + const host = createInlineEditTestHost({ document: null }); + await expect(host.layout()).resolves.toBeNull(); + }); + + test("enters the mode on one press, without a select of its own", async () => { + const host = createInlineEditTestHost(); + + void host.run(); + await flush(); + expect(host.isModeActive()).toBe(true); + expect(host.documentReads).toEqual(["alpha"]); + // `enterMode` selects the view as part of entering, so the command never + // calls `select` and never asks for a second press. + expect(host.selected).toEqual([]); + expect(host.notices).toEqual([]); + // `onEnter` asks for the redraw that swaps the read-only rows for the + // buffer, scoped to the file whose buffer it is. + expect(host.refreshed).toEqual([{ viewId: "inline-edit", fileId: "alpha" }]); + }); + + test("drops the buffer when the host refuses to enter the mode", async () => { + const host = createInlineEditTestHost({ enters: false }); + + await host.run(); + expect(host.isModeActive()).toBe(false); + expect(host.documentReads).toEqual(["alpha"]); + // `enterMode` warned by name already, so the command adds nothing — but the + // buffer it built has to go, or the view would keep rendering an editor + // with no keyboard behind it and a second press would refuse as busy. + expect(host.notices).toEqual([]); + expect(rowText(await host.layout(), 0)).toBe(" 1 alpha"); + }); + + test("refuses to edit a review it could not write back to", async () => { + const host = createInlineEditTestHost({ canWrite: false }); + + await host.run(); + expect(host.isModeActive()).toBe(false); + expect(host.documentReads).toEqual([]); + expect(host.notices.at(-1)).toMatchObject({ type: "warning" }); + expect(host.notices.at(-1)?.message).toContain("working-tree diff"); + }); + + test("renders the buffer, the caret, and the editing header while a session runs", async () => { + const host = createInlineEditTestHost(); + void host.run(); + await flush(); + + const layout = await host.layout(ADDED_SECOND_LINE); + // The buffer is the document now, so nothing is re-read behind the editor. + expect(host.documentReads).toEqual(["alpha"]); + expect(rowText(layout, 0)).toBe("EDITING — Esc exits · ctrl+s writes"); + expect(rowText(layout, 1)).toBe(" 1 alpha"); + // The caret starts on the selected hunk's first new-side line. + expect(layout?.rows[2]?.spans).toEqual([ + { text: "▎2 ", tone: "accent" }, + { text: "b", tone: "accent", attributes: ["underline", "bold"] }, + { text: "eta" }, + ]); + // Added tone belongs to the read-only presentation; an edited buffer's line + // numbers no longer describe the changeset. + expect(layout?.rows.flatMap((row) => row.spans).some((span) => span.tone === "added")).toBe( + false, + ); + expect(layout?.hunkRows).toEqual([{ startRow: 2, endRow: 2 }]); + }); + + test("types, splits, joins, and moves the caret, refreshing on every change", async () => { + const host = createInlineEditTestHost(); + void host.run(); + await flush(); + const refreshesBefore = host.refreshed.length; + + expect(host.press({ name: "z", sequence: "z" })).toBe("handled"); + expect(host.press({ name: "space", sequence: " " })).toBe("handled"); + expect(rowText(await host.layout(), 2)).toBe("▎2 z beta"); + + expect(host.press({ name: "backspace", sequence: "" })).toBe("handled"); + expect(rowText(await host.layout(), 2)).toBe("▎2 zbeta"); + + expect(host.press({ name: "return", sequence: "\r" })).toBe("handled"); + let layout = await host.layout(); + expect(rowText(layout, 2)).toBe(" 2 z"); + expect(rowText(layout, 3)).toBe("▎3 beta"); + + // Backspace at column 0 joins the split back together. + expect(host.press({ name: "backspace", sequence: "" })).toBe("handled"); + expect(rowText(await host.layout(), 2)).toBe("▎2 zbeta"); + + expect(host.press({ name: "up" })).toBe("handled"); + expect(host.press({ name: "right" })).toBe("handled"); + layout = await host.layout(); + expect(rowText(layout, 1)).toBe("▎1 alpha"); + expect(layout?.rows[1]?.spans[2]).toEqual({ + text: "p", + tone: "accent", + attributes: ["underline", "bold"], + }); + + // Every buffer or caret change asked for its own redraw, and every one of + // them named the edited file: the buffer is that file's state, so no other + // file presenting this view has to lay out again. + expect(host.refreshed.length - refreshesBefore).toBe(7); + expect(host.refreshed).toEqual( + host.refreshed.map(() => ({ viewId: "inline-edit", fileId: "alpha" })), + ); + expect(rowText(await host.layout(), 0)).toContain("MODIFIED"); + }); + + test("claims printable keys bound to commands and passes on everything else", async () => { + const host = createInlineEditTestHost(); + void host.run(); + await flush(); + const refreshesBefore = host.refreshed.length; + + // `]` is Hunk's next-hunk key; while the editor runs it is text. + expect(host.press({ name: "]", sequence: "]" })).toBe("handled"); + expect(host.press({ name: "tab", sequence: "\t" })).toBe("pass"); + expect(host.press({ name: "f8" })).toBe("pass"); + expect(host.press({ name: "g", sequence: "g", ctrl: true })).toBe("pass"); + expect(host.refreshed.length - refreshesBefore).toBe(1); + }); + + test("writes the buffer on ctrl+s, preserving the document's trailing newline", async () => { + const host = createInlineEditTestHost(); + const running = host.run(); + await flush(); + + // Nothing to write yet: the buffer still matches what was loaded. + expect(host.press({ name: "s", ctrl: true })).toBe("handled"); + await flush(); + expect(host.writes).toEqual([]); + expect(host.notices.at(-1)?.message).toBe("No unsaved edits"); + + host.press({ name: "z", sequence: "z" }); + expect(host.press({ name: "s", ctrl: true })).toBe("handled"); + await flush(); + expect(host.writes).toEqual(["alpha\nzbeta\n"]); + expect(host.notices.at(-1)?.message).toBe("Wrote alpha.ts"); + expect(rowText(await host.layout(), 0)).not.toContain("MODIFIED"); + + // In the real host the write's reload exits the mode; here the exit stands + // in for it, and either way `onExit` is what ends the handler's loop. + host.escape(); + await running; + expect(host.isModeActive()).toBe(false); + }); + + test("edits a CR-only document line by line and writes its own terminator back", async () => { + // Bare-CR documents still exist in the wild; splitting only on \r?\n would + // present this whole file as one line and rewrite it with LF on save. + const host = createInlineEditTestHost({ document: "alpha\rbeta\r" }); + const running = host.run(); + await flush(); + + const layout = await host.layout(); + expect(rowText(layout, 1)).toContain("alpha"); + expect(rowText(layout, 2)).toContain("beta"); + + host.press({ name: "z", sequence: "z" }); + expect(host.press({ name: "s", ctrl: true })).toBe("handled"); + await flush(); + expect(host.writes).toEqual(["alpha\rzbeta\r"]); + + host.escape(); + await running; + }); + + test("recognizes ctrl+s as both a flagged chord and a bare control byte", async () => { + const host = createInlineEditTestHost(); + const running = host.run(); + await flush(); + + // The decoded form: the terminal reported the modifier and the letter. + host.press({ name: "z", sequence: "z" }); + expect(host.press({ name: "s", ctrl: true })).toBe("handled"); + await flush(); + expect(host.writes).toEqual(["alpha\nzbeta\n"]); + + // The undecoded form, with no `ctrl` flag and no `name` to match on. + // `matchesKey("ctrl+s", key)` is what makes it the same chord — an + // extension reading `key.ctrl` itself would silently never save here. + host.press({ name: "y", sequence: "y" }); + expect(host.press({ sequence: CTRL_S_CONTROL_BYTE })).toBe("handled"); + await flush(); + expect(host.writes).toEqual(["alpha\nzbeta\n", "alpha\nzybeta\n"]); + // The byte is a chord, never text: nothing of it landed in the buffer. + expect(rowText(await host.layout(), 2)).toBe("▎2 zybeta"); + + host.escape(); + await running; + }); + + test("reports a failed write and keeps editing, and a cancelled one silently", async () => { + const results: ExtensionWorkspaceWriteResult[] = [ + { ok: false, reason: "cancelled", detail: "The write to alpha.ts was declined." }, + { ok: false, reason: "failed", detail: "Failed to write alpha.ts • EACCES" }, + ]; + const host = createInlineEditTestHost({ + write: async () => results.shift() ?? { ok: true }, + }); + const running = host.run(); + await flush(); + host.press({ name: "z", sequence: "z" }); + + host.press({ name: "s", ctrl: true }); + await flush(); + // A declined write is the user answering, not something to report. + expect(host.notices).toEqual([]); + expect(host.isModeActive()).toBe(true); + + host.press({ name: "s", ctrl: true }); + await flush(); + expect(host.notices.at(-1)).toMatchObject({ + message: "Failed to write alpha.ts • EACCES", + type: "warning", + }); + expect(host.isModeActive()).toBe(true); + + host.escape(); + await running; + }); + + test("claims the editor before its first await, so a second press strands nothing", async () => { + const host = createInlineEditTestHost({ holdDocumentReads: true }); + + // Both presses land while the first handler is suspended reading the + // document — the window a guard that only reads the live session misses. + const first = host.run(); + const second = host.run(); + + // The loser answers immediately instead of building a second buffer and + // parking forever on a mailbox nothing is left holding. + expect(await settlement(second)).toBe("settled"); + expect(host.notices.at(-1)?.message).toBe("Already opening the editor"); + + host.releaseDocumentReads(); + await flush(); + + // Exactly one session became live: one read, one entry, one buffer. + expect(host.documentReads).toEqual(["alpha"]); + expect(host.isModeActive()).toBe(true); + expect(host.refreshed).toEqual([{ viewId: "inline-edit", fileId: "alpha" }]); + expect(host.press({ name: "z", sequence: "z" })).toBe("handled"); + expect(rowText(await host.layout(), 2)).toBe("▎2 zbeta"); + + host.escape(); + expect(await settlement(first)).toBe("settled"); + expect(host.isModeActive()).toBe(false); + }); + + test("answers a second press while a session is live, leaving the buffer alone", async () => { + const host = createInlineEditTestHost(); + const running = host.run(); + await flush(); + host.press({ name: "z", sequence: "z" }); + + // Ctrl-E is one of the keys `onKey` passes on, so the command is reachable + // from inside its own mode. The claim taken while opening must not have + // replaced this answer. + expect(await settlement(host.run())).toBe("settled"); + expect(host.notices.at(-1)?.message).toBe( + "Already editing alpha.ts — Esc exits, ctrl+s writes", + ); + expect(host.documentReads).toEqual(["alpha"]); + expect(rowText(await host.layout(), 2)).toBe("▎2 zbeta"); + + host.escape(); + expect(await settlement(running)).toBe("settled"); + }); + + test("releases the editor claim on every failed entry", async () => { + const unwritable = createInlineEditTestHost({ canWrite: false }); + await unwritable.run(); + await unwritable.run(); + expect(unwritable.notices).toHaveLength(2); + + const unreadable = createInlineEditTestHost({ document: null }); + await unreadable.run(); + await unreadable.run(); + expect(unreadable.documentReads).toEqual(["alpha", "alpha"]); + + // A refused `enterMode` drops the buffer, so the next press may try again. + const refused = createInlineEditTestHost({ enters: false }); + await refused.run(); + await refused.run(); + expect(refused.documentReads).toEqual(["alpha", "alpha"]); + expect(refused.notices).toEqual([]); + }); + + test("keeps rows bound to the lines they came from across a mid-buffer split", async () => { + const host = createInlineEditTestHost({ + file: WHOLE_FILE_HUNK_FILE as unknown as ExtensionDiffFile, + document: THREE_LINE_DOCUMENT, + }); + const running = host.run(); + await flush(); + + // Down then Left puts the caret at the end of line 1, so Enter inserts a + // line in the middle of the document. + expect(host.press({ name: "down" })).toBe("handled"); + expect(host.press({ name: "left" })).toBe("handled"); + expect(host.press({ name: "return", sequence: "\r" })).toBe("handled"); + + const layout = await host.layout(); + expect(layout?.rows.map((_, index) => rowText(layout, index)).slice(1)).toEqual([ + " 1 alpha", + "▎2 ", + " 3 beta", + " 4 gamma", + ]); + // Provenance, not row position: the inserted row is a line the document + // never had, and every line below it still binds the line it came from. + expect(layout?.rows.map((row) => row.sourceRanges ?? null)).toEqual([ + null, + [{ side: "new", range: [1, 1] }], + null, + [{ side: "new", range: [2, 2] }], + [{ side: "new", range: [3, 3] }], + ]); + // The hunk highlight follows the same provenance instead of drifting down + // with the row positions the split moved. + expect(layout?.hunkRows).toEqual([{ startRow: 1, endRow: 4 }]); + expect(validateFileViewLayout(layout, WHOLE_FILE_HUNK_FILE.hunks.length, 40)).toMatchObject({ + valid: true, + }); + + host.escape(); + expect(await settlement(running)).toBe("settled"); + }); + + test("keeps the first line's binding through a join, and typing keeps its own", async () => { + const host = createInlineEditTestHost({ + file: WHOLE_FILE_HUNK_FILE as unknown as ExtensionDiffFile, + document: THREE_LINE_DOCUMENT, + }); + const running = host.run(); + await flush(); + + // The caret opens on line 1 here, so Down then Backspace at column 0 joins + // lines 1 and 2 into one. + expect(host.press({ name: "down" })).toBe("handled"); + expect(host.press({ name: "backspace", sequence: "" })).toBe("handled"); + // Typing moves nothing: an edited line is still the line it came from. + expect(host.press({ name: "z", sequence: "z" })).toBe("handled"); + + const layout = await host.layout(); + expect(rowText(layout, 1)).toBe("▎1 alphazbeta"); + expect(layout?.rows.map((row) => row.sourceRanges ?? null)).toEqual([ + null, + // The merged line keeps the first line's provenance; the second line's is + // dropped, so `gamma` still binds 3 rather than sliding onto 2. + [{ side: "new", range: [1, 1] }], + [{ side: "new", range: [3, 3] }], + ]); + expect(layout?.hunkRows).toEqual([{ startRow: 1, endRow: 2 }]); + expect(validateFileViewLayout(layout, WHOLE_FILE_HUNK_FILE.hunks.length, 40)).toMatchObject({ + valid: true, + }); + + host.escape(); + expect(await settlement(running)).toBe("settled"); + }); + + test("gives a hunk whose lines were joined away an in-bounds extent", async () => { + const host = createInlineEditTestHost({ document: THREE_LINE_DOCUMENT }); + const running = host.run(); + await flush(); + + // This file's one hunk covers new line 2 alone, and the caret opens there. + expect(host.press({ name: "backspace", sequence: "" })).toBe("handled"); + + const layout = await host.layout(); + expect(rowText(layout, 1)).toBe("▎1 alphabeta"); + // Nothing left in the buffer is line 2, so nothing claims to be, and the + // hunk collapses onto the header row: in bounds, and never a row a binding + // would then belong to. + expect(layout?.rows.every((row) => row.sourceRanges === undefined)).toBe(true); + expect(layout?.hunkRows).toEqual([{ startRow: 0, endRow: 0 }]); + expect(validateFileViewLayout(layout, TEST_FILE.hunks.length, 40)).toMatchObject({ + valid: true, + }); + + host.escape(); + expect(await settlement(running)).toBe("settled"); + }); + + test("escape discards the buffer, clears the session, and settles the handler", async () => { + const host = createInlineEditTestHost(); + const running = host.run(); + await flush(); + host.press({ name: "z", sequence: "z" }); + + host.escape(); + await running; + + expect(host.writes).toEqual([]); + expect(host.notices.at(-1)?.message).toBe("Discarded unsaved edits to alpha.ts"); + // The session is gone: the view is back to rendering the document it reads. + expect(rowText(await host.layout(), 0)).toBe(" 1 alpha"); + expect(host.press({ name: "z", sequence: "z" })).toBe("pass"); + }); +}); diff --git a/test/pty/file-views-integration.test.ts b/test/pty/file-views-integration.test.ts index 357150e93..a8e7c838f 100644 --- a/test/pty/file-views-integration.test.ts +++ b/test/pty/file-views-integration.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, setDefaultTimeout, test } from "bun:test"; -import { cpSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { createPtyHarness } from "./harness"; @@ -9,6 +9,7 @@ const RENDERED_MARKDOWN_EXTENSION = join( import.meta.dir, "../../examples/extensions/rendered-markdown", ); +const INLINE_EDIT_EXTENSION = join(import.meta.dir, "../../examples/extensions/inline-edit"); const JSX_FILE_VIEW_EXTENSION = join(import.meta.dir, "../../examples/extensions/jsx-file-view"); const JSX_FILE_VIEW_GALLERY = join( import.meta.dir, @@ -98,6 +99,17 @@ function createInteractiveViewExtension(directory: string) { return extension; } +/** Poll one file until the host's write lands, so the assertion is not a race. */ +async function waitForWrittenFile(path: string, expected: string, timeout = 15_000) { + const deadline = Date.now() + timeout; + let text = readFileSync(path, "utf8"); + while (text !== expected && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 100)); + text = readFileSync(path, "utf8"); + } + return text; +} + describe("PTY file views", () => { test("does not load the Markdown example unless the user installs it", async () => { const pair = createMarkdownPairTest(); @@ -369,6 +381,64 @@ describe("PTY file views", () => { } }); + test("runs the inline edit example from typed keys to a written working-tree file", async () => { + const repo = harness.createTwoFileRepoFixture(); + const edited = join(repo.dir, "alpha.ts"); + const session = await harness.launchHunk({ + args: ["diff", "--extension", INLINE_EDIT_EXTENSION, "--mode", "stack"], + cwd: repo.dir, + cols: 140, + rows: 24, + }); + + try { + await session.waitForText(/alpha\.ts/, { timeout: 20_000 }); + await harness.ensureKeyboardIsLive(session); + + // One press: `enterMode` selects the view for the file and takes the + // keyboard together, so the editor opens without a second Ctrl-E. + await session.press(["ctrl", "e"]); + // The view shows the new document alone, so the removed old-side line is + // how the terminal reports that the presentation actually switched. + await harness.waitForSnapshot(session, (text) => !text.includes("alpha = 1"), 20_000); + await session.waitForText(/EDITING — Esc exits · ctrl\+s writes/, { timeout: 20_000 }); + await session.waitForText(/inline-edit:inline-edit mode — Esc exits/, { timeout: 20_000 }); + + // `z` is Hunk's expand-context key; while the mode runs it is text, and + // each keystroke reaches the screen only through `fileViews.refresh`. + await session.press("z"); + await session.press("z"); + await session.press("z"); + const typed = await session.waitForText(/zzzexport const alpha = 2;/, { timeout: 20_000 }); + expect(typed).toContain("MODIFIED"); + + // The mode can only request the write; the command handler awaiting the + // session performs it, and the host asks the user first. + await session.press(["ctrl", "s"]); + await session.waitForText(/Write alpha\.ts\?/, { timeout: 20_000 }); + const prompt = await session.waitForText(/ext inline-edit/, { timeout: 20_000 }); + expect(prompt).toContain("replace this file's contents on disk"); + await session.press("enter"); + + expect( + await waitForWrittenFile(edited, "zzzexport const alpha = 2;\nexport const add = true;\n"), + ).toBe("zzzexport const alpha = 2;\nexport const add = true;\n"); + + // A successful write reloads the review, and the reload exits the mode. + await session.waitForText(/zzzexport const alpha = 2;/, { timeout: 20_000 }); + await harness.waitForSnapshot(session, (text) => !text.includes("Esc exits"), 20_000); + + // The command table owns the keyboard again: `z` no longer types. + await session.press("z"); + await session.waitIdle(); + const afterExit = await session.text(); + expect(afterExit).toContain("zzzexport const alpha = 2;"); + expect(afterExit).not.toContain("zzzz"); + } finally { + session.close(); + } + }); + test("renders a host-owned inline note inside its bound Markdown presentation", async () => { const pair = createMarkdownPairTest(); const session = await harness.launchHunk({ From ab97f526726c44dcfd89112c539b220f9527bdd1 Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Sat, 8 Aug 2026 16:14:29 -0400 Subject: [PATCH 2/2] fix(examples): make inline editing reliable --- examples/extensions/inline-edit/README.md | 46 ++-- examples/extensions/inline-edit/index.ts | 254 +++++++++++++++++----- scripts/inline-edit-extension.test.ts | 143 ++++++++++-- test/pty/file-views-integration.test.ts | 91 ++++++++ 4 files changed, 439 insertions(+), 95 deletions(-) diff --git a/examples/extensions/inline-edit/README.md b/examples/extensions/inline-edit/README.md index 628b065a9..83bf95cdb 100644 --- a/examples/extensions/inline-edit/README.md +++ b/examples/extensions/inline-edit/README.md @@ -6,12 +6,12 @@ This example is **not bundled or loaded by Hunk**. Install it explicitly if you It exists to demonstrate that Hunk's interactive extension surfaces compose, so it uses them all at once: -| Capability | Where this extension uses it | -| --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ctx.workspace` | `canWriteDocument` gates the affordance, `readDocument("new")` fills the buffer, `writeDocument` performs the consented write and reloads the review. | -| `mode` on a registered file view | `onKey` claims arrows, printable characters, Backspace, Enter, and `Ctrl-S`, and returns `"pass"` for everything else so the rest of Hunk keeps working while you edit. `Ctrl-S` is recognized with `matchesKey`, so the bare control byte terminals send for it matches too. | -| `fileViews.enterMode(viewId)` | One call makes the view the file's presentation _and_ gives its mode the keyboard, so `Ctrl-E` opens the editor in a single press. | -| `fileViews.refresh(viewId, { fileId })` | Every buffer or caret change re-derives the layout. A view's layout is a pure function of `(file, width)`, so this is the only way a stateful presentation redraws — and the buffer belongs to one file, so the refresh is scoped to it and no other file re-lays out. | +| Capability | Where this extension uses it | +| --------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `ctx.workspace` | `canWriteDocument` gates the affordance, `readDocument("new")` fills the buffer, `writeDocument` performs the consented write and reloads the review. | +| `mode` on a registered file view | `onKey` claims arrows, editable characters, Backspace, Enter, and `Ctrl-S`, while `]`, `?`, and `q` pass through so navigation, help, and quit keep working. `Ctrl-S` is recognized with `matchesKey`, so the bare control byte terminals send for it matches too. | +| `fileViews.enterMode(viewId)` | One call makes the view the file's presentation _and_ gives its mode the keyboard, so `Ctrl-E` opens the editor in a single press. | +| `fileViews.refresh(viewId, { fileId })` | Every buffer or caret change re-derives the layout. A view's layout is a pure function of `(file, width)`, so this is the only way a stateful presentation redraws — and the buffer belongs to one file, so the refresh is scoped to it and no other file re-lays out. | ## Try it from this checkout @@ -32,22 +32,22 @@ Hunk discovers the folder automatically on later launches. Open **View** and cho ## Keys -| Key | Does | -| ----------------------- | ----------------------------------------------------------------------------- | -| `Ctrl-E` | Starts editing the selected file, showing the view if it was not already. | -| `↑` `↓` `←` `→` | Move the caret. `←`/`→` wrap across line ends. | -| any printable character | Types at the caret — including characters bound to Hunk commands, like `z`. | -| `Backspace` | Deletes back one character, joining with the previous line at column 0. | -| `Enter` | Splits the line at the caret. | -| `Ctrl-S` | Asks Hunk to write the buffer. Hunk confirms, writes, and reloads the review. | -| `Esc` | Leaves the editor and **discards** everything typed since the last write. | -| everything else | Still Hunk's: `]` moves to the next hunk, `?` opens help, `q` quits. | +| Key | Does | +| ----------------------------- | ----------------------------------------------------------------------------- | +| `Ctrl-E` | Starts editing the selected file, showing the view if it was not already. | +| `↑` `↓` `←` `→` | Move the caret. `←`/`→` wrap across line ends. | +| any other printable character | Types at the caret — including characters bound to Hunk commands, like `z`. | +| `Backspace` | Deletes back one character, joining with the previous line at column 0. | +| `Enter` | Splits the line at the caret. | +| `Ctrl-S` | Asks Hunk to write the buffer. Hunk confirms, writes, and reloads the review. | +| `Esc` | Leaves the editor and **discards** everything typed since the last write. | +| everything else | Still Hunk's: `]` moves to the next hunk, `?` opens help, `q` quits. | The header row reads `EDITING — Esc exits · ctrl+s writes`, plus a `MODIFIED` marker whenever the buffer differs from the text on disk. ## How the pieces fit -`Ctrl-E` is one press. The command gates on `canWriteDocument`, reads the document, builds the buffer, and calls `fileViews.enterMode` — which makes the view the file's presentation and gives its mode the keyboard together, so the rows the editor acts on are on screen from the moment it holds the keys. When `enterMode` refuses (no `mode`, a file Hunk keeps on raw diff, a view that does not match), it warns by name and returns `false`, and the command drops the buffer it just built rather than leaving a session with no keyboard behind it. +`Ctrl-E` is one press. The command gates on `canWriteDocument`, starts reading the document, and calls `fileViews.enterMode` before awaiting that read. Entry therefore uses the same selected file the command captured; a selection change cannot attach a late buffer to another file. `enterMode` makes the view that file's presentation and gives its mode the keyboard together, then the completed read builds the buffer and refreshes the view. When entry refuses (no `mode`, a file Hunk keeps on raw diff, a view that does not match), it warns by name and returns `false` without installing a buffer. The editor slot is claimed synchronously, before that command's first `await`. Reading the document suspends the handler, so a guard that only checked "is a session live?" would let a second `Ctrl-E` through the window in between, and one of the two handlers would then be parked forever on a session nothing could end. The claim is released on every way out — an unwritable review, an unreadable document, a refused `enterMode` — and once a session is live it is the live session that answers the next press. @@ -74,13 +74,13 @@ This is a demonstration, not an editor: Rows carry `sourceRanges` so Hunk can place its own inline notes inside this presentation, and a row may only bind a line it honestly still _is_. Row position cannot answer that once you split or join a line, so the edit session keeps **provenance**: for each buffer line, the document line it came from, or nothing at all. -| Edit | Provenance | -| ------------------ | -------------------------------------------------------------------- | -| typing in a line | kept — an edited line is still the line it came from | -| `Enter` (split) | the first line keeps it; the new tail gets none | -| `Backspace` (join) | the merged line keeps the first line's; the second line's is dropped | +| Edit | Provenance | +| ------------------ | ------------------------------------------------------------------------ | +| typing in a line | kept — an edited line is still the line it came from | +| `Enter` (split) | the first line keeps it; the new tail gets none | +| `Backspace` (join) | the merged line presents both source lines so either note stays attached | -So a line you inserted binds nothing, the lines below a split keep the numbers they had, and a join is the exact inverse of the split it undoes. The hunk extents in `hunkRows` are derived from the same provenance, so the hunk highlight follows the rows still holding a hunk's lines instead of drifting down by however many lines you added above them. A hunk whose lines were all joined away collapses onto the first row. +So a line you inserted binds nothing, the lines below a split keep the numbers they had, and a joined row keeps every source line it now presents. The hunk extents in `hunkRows` are derived from the same provenance, so the hunk highlight follows the rows still holding a hunk's lines instead of drifting down by however many lines you added above them. A join across two distinct hunks is refused because one bound row cannot belong to two hunk extents. One thing this shows that is easy to get wrong: Hunk accepts a binding only on a row exactly one `hunkRows` extent owns, and rejects the whole layout otherwise — so a last pass drops the bindings no extent owns, and context lines outside every hunk are presented without one. The [rendered Markdown example](../rendered-markdown/) ends its layout with the same pass, for the same reason. diff --git a/examples/extensions/inline-edit/index.ts b/examples/extensions/inline-edit/index.ts index a6f56f354..cc9015e68 100644 --- a/examples/extensions/inline-edit/index.ts +++ b/examples/extensions/inline-edit/index.ts @@ -7,8 +7,8 @@ * - `ctx.workspace` fills the buffer with `readDocument`, gates the affordance * with `canWriteDocument`, and performs the consented `writeDocument`. * - a file view `mode` routes real keystrokes into the view: it claims arrows, - * printable characters, Backspace, Enter, and Ctrl-S, and declines the rest - * so `]`, `?`, and `q` keep working while the editor is running. One + * most printable characters, Backspace, Enter, and Ctrl-S, and declines + * `]`, `?`, and `q` so navigation, help, and quit keep working. One * `enterMode` selects the view *and* takes the keyboard, so one Ctrl-E opens * the editor rather than two. * - `fileViews.refresh(VIEW_ID, { fileId })` re-derives the layout after every @@ -74,10 +74,12 @@ interface EditSession { readonly endsWithNewline: boolean; /** Mutated in place — never reassigned, since `text()` closes over it. */ readonly lines: string[]; + /** New-side ranges used to keep one merged row owned by at most one hunk. */ + readonly hunkRanges: readonly (readonly [number, number])[]; /** * The **provenance policy**: for each buffer line, the one-based line of the - * document it was loaded from that this line still *is*, or `null` for a line - * the document never had. + * document lines it was loaded from that this line still presents, or an + * empty array for a line the document never had. * * Row positions stop describing the document the moment a line is split or * joined, so this array — not the row's index — is what a source binding may @@ -85,12 +87,12 @@ interface EditSession { * * - typing in a line keeps its provenance; an edited line still corresponds * to the line it came from; - * - a split keeps the first line's provenance and gives the tail `null`, + * - a split keeps the first line's provenance and gives the tail none, * because the tail is a line the document does not have; - * - a join keeps the first line's provenance and drops the second's, which - * makes a join the exact inverse of the split it undoes. + * - a join combines both lines' provenance, so notes bound to either source + * line remain attached to the merged row. */ - readonly provenance: (number | null)[]; + readonly provenance: number[][]; /** The text on disk as far as this session knows, so `MODIFIED` is a fact. */ savedText: string; cursorLine: number; @@ -110,12 +112,95 @@ function clamp(value: number, low: number, high: number) { return Math.min(Math.max(value, low), high); } -/** Cut text to a column budget, marking the cut so truncation is never silent. */ +/** Segment text by user-perceived characters so editing never splits UTF-16 pairs. */ +const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" }); + +/** Return every grapheme in a string without splitting emoji or combining sequences. */ +function graphemes(value: string) { + return Array.from(graphemeSegmenter.segment(value), ({ segment }) => segment); +} + +/** Return the previous valid caret boundary in one line. */ +function previousGraphemeBoundary(value: string, column: number) { + let previous = 0; + for (const { index } of graphemeSegmenter.segment(value)) { + if (index >= column) { + break; + } + previous = index; + } + return previous; +} + +/** Snap an arbitrary column backward onto a valid caret boundary. */ +function snapGraphemeBoundary(value: string, column: number) { + if (column >= value.length) { + return value.length; + } + let boundary = 0; + for (const { index } of graphemeSegmenter.segment(value)) { + if (index > column) { + break; + } + boundary = index; + } + return boundary; +} + +/** Return the next valid caret boundary in one line. */ +function nextGraphemeBoundary(value: string, column: number) { + for (const { index, segment } of graphemeSegmenter.segment(value)) { + if (index >= column) { + return index + segment.length; + } + } + return value.length; +} + +/** Return the zero-based grapheme column at one valid string boundary. */ +function graphemeColumn(value: string, boundary: number) { + let column = 0; + for (const { index } of graphemeSegmenter.segment(value)) { + if (index >= boundary) { + break; + } + column += 1; + } + return column; +} + +/** Return the string boundary at one zero-based grapheme column. */ +function boundaryAtGraphemeColumn(value: string, column: number) { + let current = 0; + for (const { index } of graphemeSegmenter.segment(value)) { + if (current === column) { + return index; + } + current += 1; + } + return value.length; +} + +/** Cut text to a terminal-cell budget without splitting a grapheme. */ function truncate(value: string, width: number) { - if (value.length <= width) { + if (Bun.stringWidth(value) <= width) { return value; } - return width <= 1 ? value.slice(0, Math.max(0, width)) : `${value.slice(0, width - 1)}…`; + if (width <= 0) { + return ""; + } + const budget = width - Bun.stringWidth("…"); + let used = 0; + let visible = ""; + for (const segment of graphemes(value)) { + const segmentWidth = Bun.stringWidth(segment); + if (used + segmentWidth > budget) { + break; + } + visible += segment; + used += segmentWidth; + } + return `${visible}…`; } /** Split a document into editable lines, dropping the terminator's empty tail. */ @@ -164,7 +249,7 @@ function printableCharacter(key: ExtensionKeyEvent) { return null; } const sequence = key.sequence ?? ""; - if (sequence.length !== 1) { + if (graphemes(sequence).length !== 1) { return null; } const code = sequence.codePointAt(0) ?? 0; @@ -206,9 +291,10 @@ function createEditSession(file: ExtensionDiffFile, text: string, cursorLine: nu newline, endsWithNewline, lines, + hunkRanges: (file.hunks ?? []).map((hunk) => hunk.newRange ?? [1, 1]), // Loading the document is the one moment buffer and document agree, so // every line starts out being exactly the line it was read from. - provenance: lines.map((_, index) => index + 1), + provenance: lines.map((_, index) => [index + 1]), // Normalized rather than the raw read, so a document with mixed terminators // is not reported as modified before anyone has typed into it. savedText: lines.join(newline) + (endsWithNewline ? newline : ""), @@ -240,7 +326,7 @@ function createEditSession(file: ExtensionDiffFile, text: string, cursorLine: nu function clampCursor(session: EditSession) { session.cursorLine = clamp(session.cursorLine, 0, session.lines.length - 1); const line = session.lines[session.cursorLine] ?? ""; - session.cursorColumn = clamp(session.cursorColumn, 0, line.length); + session.cursorColumn = snapGraphemeBoundary(line, clamp(session.cursorColumn, 0, line.length)); } /** @@ -261,20 +347,37 @@ function insertText(session: EditSession, text: string) { function deleteBackwards(session: EditSession) { const line = session.lines[session.cursorLine] ?? ""; if (session.cursorColumn > 0) { + const previousColumn = previousGraphemeBoundary(line, session.cursorColumn); session.lines[session.cursorLine] = - line.slice(0, session.cursorColumn - 1) + line.slice(session.cursorColumn); - session.cursorColumn -= 1; + line.slice(0, previousColumn) + line.slice(session.cursorColumn); + session.cursorColumn = previousColumn; clampCursor(session); return; } if (session.cursorLine === 0) { return; } + const mergedSources = [ + ...session.provenance[session.cursorLine - 1]!, + ...session.provenance[session.cursorLine]!, + ]; + const sourceHunkOwners = new Set( + mergedSources.flatMap((source) => + session.hunkRanges.flatMap((range, hunkIndex) => + source >= range[0] && source <= range[1] ? [hunkIndex] : [], + ), + ), + ); + // One row cannot belong to two hunk extents under the host layout contract. + // Refuse only that boundary join instead of dropping bindings and hiding the editor. + if (sourceHunkOwners.size > 1) { + return; + } const previous = session.lines[session.cursorLine - 1] ?? ""; session.lines.splice(session.cursorLine - 1, 2, previous + line); - // Provenance policy: the merged line keeps the first line's provenance and - // the second line's is dropped, so the lines below keep the numbers they had. - session.provenance.splice(session.cursorLine - 1, 2, session.provenance[session.cursorLine - 1]!); + // A merged row presents both source lines, so notes bound to either line stay + // visible instead of forcing the host back to raw diff around a hidden mode. + session.provenance.splice(session.cursorLine - 1, 2, mergedSources); session.cursorLine -= 1; session.cursorColumn = previous.length; clampCursor(session); @@ -292,7 +395,7 @@ function splitLine(session: EditSession) { // Provenance policy: the head is still the line it was, and the tail is a // line the document has never had — so it gets no provenance rather than // inheriting the number of whatever line used to sit below it. - session.provenance.splice(session.cursorLine, 1, session.provenance[session.cursorLine]!, null); + session.provenance.splice(session.cursorLine, 1, session.provenance[session.cursorLine]!, []); session.cursorLine += 1; session.cursorColumn = 0; clampCursor(session); @@ -301,8 +404,17 @@ function splitLine(session: EditSession) { /** Move the caret one line or one column, wrapping columns across line ends. */ function moveCursor(session: EditSession, key: ExtensionKeyEvent) { if (key.name === "up" || key.name === "down") { - session.cursorLine += key.name === "down" ? 1 : -1; - clampCursor(session); + const line = session.lines[session.cursorLine] ?? ""; + const column = graphemeColumn(line, session.cursorColumn); + session.cursorLine = clamp( + session.cursorLine + (key.name === "down" ? 1 : -1), + 0, + session.lines.length - 1, + ); + session.cursorColumn = boundaryAtGraphemeColumn( + session.lines[session.cursorLine] ?? "", + column, + ); return; } if (key.name === "left") { @@ -310,7 +422,8 @@ function moveCursor(session: EditSession, key: ExtensionKeyEvent) { session.cursorLine -= 1; session.cursorColumn = (session.lines[session.cursorLine] ?? "").length; } else { - session.cursorColumn -= 1; + const line = session.lines[session.cursorLine] ?? ""; + session.cursorColumn = previousGraphemeBoundary(line, session.cursorColumn); } clampCursor(session); return; @@ -320,7 +433,7 @@ function moveCursor(session: EditSession, key: ExtensionKeyEvent) { session.cursorLine += 1; session.cursorColumn = 0; } else { - session.cursorColumn += 1; + session.cursorColumn = nextGraphemeBoundary(line, session.cursorColumn); } clampCursor(session); } @@ -362,6 +475,7 @@ const inlineEditExtension: ExtensionFactory = (hunk) => { * through the window in between. */ let opening = false; + let openingFileId: string | null = null; /** * Claim the one editor slot, then build the session `file` is edited through @@ -373,6 +487,7 @@ const inlineEditExtension: ExtensionFactory = (hunk) => { */ const beginEditSession = async (ctx: ExtensionCommandContext, file: ExtensionDiffFile) => { opening = true; + openingFileId = file.id; try { // The affordance gate. Reads work in every review kind, but writes are // working-tree only, and an editor that cannot save is a lie. @@ -384,9 +499,30 @@ const inlineEditExtension: ExtensionFactory = (hunk) => { return null; } - const document = await ctx.workspace.readDocument(file.id, "new"); + // Start the read, but enter before awaiting it. Nothing can change the + // live selection between these synchronous operations, so the mode and + // the command's file are guaranteed to agree. + const documentPromise = ctx.workspace.readDocument(file.id, "new"); + const viewWasActive = ctx.fileViews.isActive(VIEW_ID); + if (!ctx.fileViews.enterMode(VIEW_ID)) { + return null; + } + + const document = await documentPromise; + // Selection changes and Escape auto-exit the mode while the read is in + // flight. Never install the late buffer after its keyboard disappeared. + if (!ctx.fileViews.isModeActive(VIEW_ID)) { + ctx.notify(`Editor closed while ${file.path} was loading`, "warning"); + return null; + } if (document === null) { ctx.notify(`No readable document for ${file.path}`, "warning"); + ctx.fileViews.exitMode(); + // Entry selected this view before the read failed. Restore raw unless + // this presentation was already selected when the command began. + if (!viewWasActive) { + ctx.fileViews.select(null); + } return null; } @@ -395,21 +531,14 @@ const inlineEditExtension: ExtensionFactory = (hunk) => { const hunks = file.hunks ?? []; const selectedHunk = hunks[ctx.selection.hunkIndex ?? 0] ?? hunks[0]; const session = createEditSession(file, document, (selectedHunk?.newRange?.[0] ?? 1) - 1); - editSession = session; - // One step: if the file was on raw diff or another view, entering selects - // this one too, so a single Ctrl-E goes straight into the editor. - if (!ctx.fileViews.enterMode(VIEW_ID)) { - // `enterMode` already warned naming the refusal; drop the buffer that - // now has no keyboard behind it. - editSession = null; - return null; - } + ctx.fileViews.refresh(VIEW_ID, { fileId: file.id }); return session; } finally { // Held across the opening window only. A live session is guarded by // `editSession` from here on, and a refused one left it null. opening = false; + openingFileId = null; } }; @@ -448,9 +577,9 @@ const inlineEditExtension: ExtensionFactory = (hunk) => { // session exists the buffer *is* the document, so the mapping is the // identity; while one runs it is the session's provenance, which survives // splits and joins that row positions do not. - const provenance: (number | null)[] = session + const provenance: number[][] = session ? session.provenance - : lines.map((_, index) => index + 1); + : lines.map((_, index) => [index + 1]); // The new-side line spans this file's changeset declares, which both the // hunk extents and the bindings that must sit inside them come from. const hunkRanges: [number, number][] = (input.file.hunks ?? []).map( @@ -476,17 +605,21 @@ const inlineEditExtension: ExtensionFactory = (hunk) => { if (session && onCursor) { // Three spans put the caret on a column without needing an inverse // attribute the row contract does not have. - const caret = clamp(session.cursorColumn, 0, visible.length); + const caret = snapGraphemeBoundary( + visible, + clamp(session.cursorColumn, 0, visible.length), + ); + const caretEnd = nextGraphemeBoundary(visible, caret); if (caret > 0) { spans.push({ text: visible.slice(0, caret) }); } spans.push({ - text: visible.slice(caret, caret + 1) || " ", + text: visible.slice(caret, caretEnd) || " ", tone: "accent", attributes: ["underline", "bold"], }); - if (caret + 1 < visible.length) { - spans.push({ text: visible.slice(caret + 1) }); + if (caretEnd < visible.length) { + spans.push({ text: visible.slice(caretEnd) }); } } else if (added.has(lineNumber)) { spans.push({ text: visible, tone: "added" }); @@ -494,18 +627,20 @@ const inlineEditExtension: ExtensionFactory = (hunk) => { spans.push({ text: visible }); } - const source = provenance[index] ?? null; + const sources = provenance[index] ?? []; rows.push({ id: `line:${lineNumber}`, spans, - // One row per source line is what lets Hunk place inline agent notes - // inside this presentation — so a row may only bind the line it still - // *is*, never the line its position would suggest. A line the split - // key invented binds nothing at all rather than shifting every note - // below it onto the wrong code. - ...(source === null + // One row may present several source lines after a join. Preserve all + // of them so notes never disappear behind a still-active edit mode. + ...(sources.length === 0 ? {} - : { sourceRanges: [{ side: "new" as const, range: [source, source] as const }] }), + : { + sourceRanges: sources.map((source) => ({ + side: "new" as const, + range: [source, source] as const, + })), + }), }); }); @@ -515,15 +650,15 @@ const inlineEditExtension: ExtensionFactory = (hunk) => { const hunkRows = hunkRanges.map((range) => { let startRow = -1; let endRow = -1; - provenance.forEach((line, index) => { - if (line === null || line < range[0] || line > range[1]) { + provenance.forEach((sources, index) => { + if (!sources.some((line) => line >= range[0] && line <= range[1])) { return; } const row = headerOffset + index; startRow = startRow === -1 ? row : startRow; endRow = row; }); - // A hunk whose every line was joined away has no row left to point at. + // A malformed or source-less hunk may have no represented row left. // Collapse it onto the first row: in bounds, which is all the host asks // of an extent, and the pass below keeps it from claiming a binding. return startRow === -1 ? { startRow: 0, endRow: 0 } : { startRow, endRow }; @@ -548,11 +683,12 @@ const inlineEditExtension: ExtensionFactory = (hunk) => { }, mode: { onEnter(ctx) { - // The session was created before `enterMode`, so this is the redraw - // that swaps the read-only rows for the editable buffer. Scoped to the - // edited file: every other file presenting this view still shows the - // document it read, and has no reason to lay out again. - ctx.fileViews.refresh(VIEW_ID, { fileId: ctx.file.id }); + // The command enters synchronously before awaiting its document read, + // so the live mode file must be the file that claimed the opening slot. + if (openingFileId !== ctx.file.id) { + ctx.notify("Inline edit mode must be opened with Ctrl-E", "warning"); + ctx.fileViews.exitMode(); + } }, onKey(key, ctx) { const session = sessionFor(ctx.file.id); @@ -569,6 +705,12 @@ const inlineEditExtension: ExtensionFactory = (hunk) => { return "handled"; } + // These printable keys remain host-owned by the example's explicit + // policy: hunk navigation, help, and quit must stay reachable. + if (key.sequence === "]" || key.sequence === "?" || key.sequence === "q") { + return "pass"; + } + if ( key.name === "up" || key.name === "down" || diff --git a/scripts/inline-edit-extension.test.ts b/scripts/inline-edit-extension.test.ts index fcbf5c8d6..563237ea8 100644 --- a/scripts/inline-edit-extension.test.ts +++ b/scripts/inline-edit-extension.test.ts @@ -11,6 +11,7 @@ import type { HunkExtensionAPI, } from "../src/extension-api/types"; import { validateFileViewLayout } from "../src/ui/fileViews/layout"; +import { buildFileViewRenderPlan } from "../src/ui/fileViews/renderPlan"; import inlineEditExtension from "../examples/extensions/inline-edit"; const TEST_FILE = { @@ -122,6 +123,7 @@ function createInlineEditTestHost({ const writes: string[] = []; const documentReads: string[] = []; let modeActive = false; + let viewActive = false; let releaseDocumentReads = () => {}; const readGate = holdDocumentReads ? new Promise((resolve) => { @@ -130,12 +132,16 @@ function createInlineEditTestHost({ : null; const fileViews = { - select: (viewId: string | null) => selected.push(viewId), + select: (viewId: string | null) => { + selected.push(viewId); + viewActive = viewId === "inline-edit"; + }, refresh: (viewId: string, options?: { fileId?: string }) => refreshed.push({ viewId, ...(options?.fileId === undefined ? {} : { fileId: options.fileId }), }), + isActive: () => viewActive, isModeActive: () => modeActive, // One step in the real host too: entering selects the view for the file // when it is not already showing it, so there is nothing to activate first. @@ -144,6 +150,7 @@ function createInlineEditTestHost({ return false; } modeActive = true; + viewActive = true; view.mode?.onEnter?.({ ...modeContext(), file } as never); return true; }, @@ -200,10 +207,10 @@ function createInlineEditTestHost({ /** Escape is host-owned: it exits without ever reaching `onKey`. */ escape: () => fileViews.exitMode(), /** Lay the current file out at a fixed width, as the review stream does. */ - layout: (changes: ExtensionFileChangeRange[] = []) => + layout: (changes: ExtensionFileChangeRange[] = [], width = 40) => view.layout({ file, - width: 40, + width, signal: new AbortController().signal, changes, readDocument: async () => { @@ -285,6 +292,18 @@ describe("inline edit example extension", () => { expect(rowText(await host.layout(), 0)).toBe(" 1 alpha"); }); + test("restores raw presentation when the opening document is unreadable", async () => { + const host = createInlineEditTestHost({ document: null }); + + await host.run(); + expect(host.isModeActive()).toBe(false); + expect(host.selected).toEqual([null]); + expect(host.notices.at(-1)).toMatchObject({ + message: "No readable document for alpha.ts", + type: "warning", + }); + }); + test("refuses to edit a review it could not write back to", async () => { const host = createInlineEditTestHost({ canWrite: false }); @@ -361,14 +380,77 @@ describe("inline edit example extension", () => { expect(rowText(await host.layout(), 0)).toContain("MODIFIED"); }); - test("claims printable keys bound to commands and passes on everything else", async () => { + test("edits whole Unicode graphemes without corrupting surrogate pairs", async () => { + const host = createInlineEditTestHost({ document: "😀\n" }); + const running = host.run(); + await flush(); + + const initialLayout = await host.layout(); + expect(initialLayout?.rows[1]?.spans[1]).toMatchObject({ text: "😀", tone: "accent" }); + + expect(host.press({ name: "right" })).toBe("handled"); + expect(host.press({ name: "backspace", sequence: "\u007f" })).toBe("handled"); + expect(host.press({ name: "s", ctrl: true })).toBe("handled"); + await flush(); + expect(host.writes).toEqual(["\n"]); + + expect(host.press({ name: "😀", sequence: "😀" })).toBe("handled"); + expect(host.press({ name: "s", ctrl: true })).toBe("handled"); + await flush(); + expect(host.writes).toEqual(["\n", "😀\n"]); + + host.escape(); + await running; + }); + + test("preserves the grapheme column during vertical Unicode movement", async () => { + const twoLineFile = { + ...TEST_FILE, + hunks: [{ index: 0, header: "@@ -1,2 +1,2 @@", newRange: [1, 2] }], + } as unknown as ExtensionDiffFile; + const host = createInlineEditTestHost({ file: twoLineFile, document: "éx\nab\n" }); + const running = host.run(); + await flush(); + + // One Right crosses the combining grapheme's two UTF-16 units. Down keeps + // grapheme column 1, rather than mistaking that offset for column 2. + expect(host.press({ name: "right" })).toBe("handled"); + expect(host.press({ name: "down" })).toBe("handled"); + const layout = await host.layout(); + expect(layout?.rows[2]?.spans.slice(1)).toEqual([ + { text: "a" }, + { text: "b", tone: "accent", attributes: ["underline", "bold"] }, + ]); + + host.escape(); + await running; + }); + + test("truncates wide graphemes by terminal cells without wrapping", async () => { + const host = createInlineEditTestHost({ document: "界界\n" }); + const running = host.run(); + await flush(); + + const layout = await host.layout([], 4); + expect(rowText(layout, 1)).toBe("▎1 …"); + expect(validateFileViewLayout(layout, TEST_FILE.hunks.length, 4)).toMatchObject({ + valid: true, + }); + + host.escape(); + await running; + }); + + test("claims editable printable keys and preserves documented host shortcuts", async () => { const host = createInlineEditTestHost(); void host.run(); await flush(); const refreshesBefore = host.refreshed.length; - // `]` is Hunk's next-hunk key; while the editor runs it is text. - expect(host.press({ name: "]", sequence: "]" })).toBe("handled"); + expect(host.press({ name: "z", sequence: "z" })).toBe("handled"); + expect(host.press({ name: "]", sequence: "]" })).toBe("pass"); + expect(host.press({ name: "?", sequence: "?", shift: true })).toBe("pass"); + expect(host.press({ name: "q", sequence: "q" })).toBe("pass"); expect(host.press({ name: "tab", sequence: "\t" })).toBe("pass"); expect(host.press({ name: "f8" })).toBe("pass"); expect(host.press({ name: "g", sequence: "g", ctrl: true })).toBe("pass"); @@ -503,6 +585,25 @@ describe("inline edit example extension", () => { expect(host.isModeActive()).toBe(false); }); + test("enters the selected file's mode before awaiting its document read", async () => { + const host = createInlineEditTestHost({ holdDocumentReads: true }); + + const running = host.run(); + // Entry is synchronous with the command's selection snapshot, so a later + // selection change cannot attach this buffer to another file. + expect(host.isModeActive()).toBe(true); + expect(host.documentReads).toEqual([]); + expect(host.refreshed).toEqual([]); + + host.releaseDocumentReads(); + await flush(); + expect(host.documentReads).toEqual(["alpha"]); + expect(host.refreshed).toEqual([{ viewId: "inline-edit", fileId: "alpha" }]); + + host.escape(); + await running; + }); + test("answers a second press while a session is live, leaving the buffer alone", async () => { const host = createInlineEditTestHost(); const running = host.run(); @@ -583,7 +684,7 @@ describe("inline edit example extension", () => { expect(await settlement(running)).toBe("settled"); }); - test("keeps the first line's binding through a join, and typing keeps its own", async () => { + test("keeps both source bindings through a join, and typing keeps them", async () => { const host = createInlineEditTestHost({ file: WHOLE_FILE_HUNK_FILE as unknown as ExtensionDiffFile, document: THREE_LINE_DOCUMENT, @@ -602,21 +703,29 @@ describe("inline edit example extension", () => { expect(rowText(layout, 1)).toBe("▎1 alphazbeta"); expect(layout?.rows.map((row) => row.sourceRanges ?? null)).toEqual([ null, - // The merged line keeps the first line's provenance; the second line's is - // dropped, so `gamma` still binds 3 rather than sliding onto 2. - [{ side: "new", range: [1, 1] }], + // The merged row presents both original lines, while `gamma` still binds + // line 3 rather than sliding onto the removed row position. + [ + { side: "new", range: [1, 1] }, + { side: "new", range: [2, 2] }, + ], [{ side: "new", range: [3, 3] }], ]); expect(layout?.hunkRows).toEqual([{ startRow: 1, endRow: 2 }]); expect(validateFileViewLayout(layout, WHOLE_FILE_HUNK_FILE.hunks.length, 40)).toMatchObject({ valid: true, }); + expect( + buildFileViewRenderPlan(layout!, [ + { id: "line-two", annotation: { id: "line-two", summary: "Line two", newRange: [2, 2] } }, + ]).unresolvedNoteIds, + ).toEqual([]); host.escape(); expect(await settlement(running)).toBe("settled"); }); - test("gives a hunk whose lines were joined away an in-bounds extent", async () => { + test("keeps a single-line hunk bound when its line joins the row above", async () => { const host = createInlineEditTestHost({ document: THREE_LINE_DOCUMENT }); const running = host.run(); await flush(); @@ -626,11 +735,13 @@ describe("inline edit example extension", () => { const layout = await host.layout(); expect(rowText(layout, 1)).toBe("▎1 alphabeta"); - // Nothing left in the buffer is line 2, so nothing claims to be, and the - // hunk collapses onto the header row: in bounds, and never a row a binding - // would then belong to. - expect(layout?.rows.every((row) => row.sourceRanges === undefined)).toBe(true); - expect(layout?.hunkRows).toEqual([{ startRow: 0, endRow: 0 }]); + // The merged row still presents line 2, so notes on that hunk keep an exact + // anchor and the editor never falls back to a hidden raw-diff state. + expect(layout?.rows[1]?.sourceRanges).toEqual([ + { side: "new", range: [1, 1] }, + { side: "new", range: [2, 2] }, + ]); + expect(layout?.hunkRows).toEqual([{ startRow: 1, endRow: 1 }]); expect(validateFileViewLayout(layout, TEST_FILE.hunks.length, 40)).toMatchObject({ valid: true, }); diff --git a/test/pty/file-views-integration.test.ts b/test/pty/file-views-integration.test.ts index a8e7c838f..42ad14001 100644 --- a/test/pty/file-views-integration.test.ts +++ b/test/pty/file-views-integration.test.ts @@ -412,6 +412,13 @@ describe("PTY file views", () => { const typed = await session.waitForText(/zzzexport const alpha = 2;/, { timeout: 20_000 }); expect(typed).toContain("MODIFIED"); + // `?` is an explicitly host-owned printable key, so help remains + // reachable and one Escape closes only the overlay, not the editor. + await session.press("?"); + await session.waitForText(/Controls help/, { timeout: 20_000 }); + await session.press("escape"); + await session.waitForText(/inline-edit:inline-edit mode — Esc exits/, { timeout: 20_000 }); + // The mode can only request the write; the command handler awaiting the // session performs it, and the host asks the user first. await session.press(["ctrl", "s"]); @@ -439,6 +446,90 @@ describe("PTY file views", () => { } }); + test("keeps an annotated joined line visible in the active editor", async () => { + const repo = harness.createTwoFileRepoFixture(); + const agentContext = join(repo.dir, "agent.json"); + writeFileSync( + agentContext, + JSON.stringify({ + version: 1, + files: [ + { + path: "alpha.ts", + annotations: [{ newRange: [2, 2], summary: "Keep this note visible." }], + }, + ], + }), + "utf8", + ); + const session = await harness.launchHunk({ + args: [ + "diff", + "--extension", + INLINE_EDIT_EXTENSION, + "--mode", + "stack", + "--agent-context", + agentContext, + "--agent-notes", + ], + cwd: repo.dir, + cols: 140, + rows: 24, + }); + + try { + await session.waitForText(/Keep this note visible\./, { timeout: 20_000 }); + await harness.ensureKeyboardIsLive(session); + await session.press(["ctrl", "e"]); + await session.waitForText(/EDITING — Esc exits/, { timeout: 20_000 }); + + await session.press("down"); + await session.press("backspace"); + const joined = await session.waitForText(/export const alpha = 2;export const add = true;/, { + timeout: 20_000, + }); + expect(joined).toContain("EDITING — Esc exits"); + expect(joined).toContain("Keep this note visible."); + + await session.press("z"); + await session.waitForText(/export const alpha = 2;zexport const add = true;/, { + timeout: 20_000, + }); + await session.press("escape"); + } finally { + session.close(); + } + }); + + test("deletes and writes a whole emoji without surrogate corruption", async () => { + const repo = harness.createTwoFileRepoFixture(); + const edited = join(repo.dir, "alpha.ts"); + writeFileSync(edited, "😀\n", "utf8"); + const session = await harness.launchHunk({ + args: ["diff", "--extension", INLINE_EDIT_EXTENSION, "--mode", "stack"], + cwd: repo.dir, + cols: 140, + rows: 24, + }); + + try { + await session.waitForText(/😀/, { timeout: 20_000 }); + await harness.ensureKeyboardIsLive(session); + await session.press(["ctrl", "e"]); + await session.waitForText(/EDITING — Esc exits/, { timeout: 20_000 }); + await session.press("right"); + await session.press("backspace"); + await session.press(["ctrl", "s"]); + await session.waitForText(/Write alpha\.ts\?/, { timeout: 20_000 }); + await session.press("enter"); + + expect(await waitForWrittenFile(edited, "\n")).toBe("\n"); + } finally { + session.close(); + } + }); + test("renders a host-owned inline note inside its bound Markdown presentation", async () => { const pair = createMarkdownPairTest(); const session = await harness.launchHunk({