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..83bf95cdb --- /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, 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 + +```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 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`, 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. + +`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 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 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. + +## 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..cc9015e68 --- /dev/null +++ b/examples/extensions/inline-edit/index.ts @@ -0,0 +1,810 @@ +/** + * 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, + * 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 + * 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[]; + /** 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 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 + * 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 none, + * because the tail is a line the document does not have; + * - a join combines both lines' provenance, so notes bound to either source + * line remain attached to the merged row. + */ + readonly provenance: number[][]; + /** 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); +} + +/** 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 (Bun.stringWidth(value) <= width) { + return value; + } + 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. */ +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 (graphemes(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, + 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]), + // 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 = snapGraphemeBoundary(line, 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) { + const previousColumn = previousGraphemeBoundary(line, session.cursorColumn); + session.lines[session.cursorLine] = + 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); + // 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); +} + +/** 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]!, []); + 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") { + 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") { + if (session.cursorColumn === 0 && session.cursorLine > 0) { + session.cursorLine -= 1; + session.cursorColumn = (session.lines[session.cursorLine] ?? "").length; + } else { + const line = session.lines[session.cursorLine] ?? ""; + session.cursorColumn = previousGraphemeBoundary(line, session.cursorColumn); + } + 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 = nextGraphemeBoundary(line, session.cursorColumn); + } + 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; + let openingFileId: string | null = null; + + /** + * 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; + 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. + 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; + } + + // 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; + } + + // 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; + 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; + } + }; + + 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[][] = 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 = 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, caretEnd) || " ", + tone: "accent", + attributes: ["underline", "bold"], + }); + if (caretEnd < visible.length) { + spans.push({ text: visible.slice(caretEnd) }); + } + } else if (added.has(lineNumber)) { + spans.push({ text: visible, tone: "added" }); + } else { + spans.push({ text: visible }); + } + + const sources = provenance[index] ?? []; + rows.push({ + id: `line:${lineNumber}`, + spans, + // 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: sources.map((source) => ({ + 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((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 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 }; + }); + + // 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 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); + 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"; + } + + // 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" || + 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..563237ea8 --- /dev/null +++ b/scripts/inline-edit-extension.test.ts @@ -0,0 +1,768 @@ +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 { buildFileViewRenderPlan } from "../src/ui/fileViews/renderPlan"; +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 viewActive = false; + let releaseDocumentReads = () => {}; + const readGate = holdDocumentReads + ? new Promise((resolve) => { + releaseDocumentReads = resolve; + }) + : null; + + const fileViews = { + 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. + enterMode: () => { + if (!enters) { + return false; + } + modeActive = true; + viewActive = 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[] = [], width = 40) => + view.layout({ + file, + width, + 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("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 }); + + 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("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; + + 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"); + 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("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(); + 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 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, + }); + 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 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("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(); + + // 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"); + // 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, + }); + + 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..42ad14001 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,155 @@ 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"); + + // `?` 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"]); + 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("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({