diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index d6ba937..9508024 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -74,6 +74,7 @@ export type { ContextNamespace, LanguagesNamespace, ThemesNamespace, + ClipboardNamespace, Tecode, } from "./namespaces"; diff --git a/packages/api/src/namespaces.ts b/packages/api/src/namespaces.ts index 9667805..14fa895 100644 --- a/packages/api/src/namespaces.ts +++ b/packages/api/src/namespaces.ts @@ -1,5 +1,5 @@ /** - * The nine `tecode.*` namespaces (Req 10.1, design.md §12) and the + * The ten `tecode.*` namespaces (Req 10.1, design.md §12) and the * aggregate {@link Tecode} interface that bundles them into the single * frozen object handed to every extension. */ @@ -451,6 +451,61 @@ export interface ThemesNamespace { onDidChange: Event; } +/* ------------------------------------------------------------------ */ +/* tecode.clipboard */ +/* ------------------------------------------------------------------ */ + +/** + * The clipboard (Issue #91): an internal buffer holding the last text + * copied or cut, write-through synced to the terminal's OWN system + * clipboard via OSC 52 (`@opentui/core`'s `CliRenderer. + * copyToClipboardOSC52`, `packages/cli/src/renderShell.tsx`'s + * `onClipboardWriterReady`) when the host terminal supports it and + * `clipboard.useSystemClipboard` (`editor-core`'s own configuration + * contribution) is enabled. Backs `editor-core`'s + * `editor.action.clipboardCopy`/`clipboardCut`/`clipboardPaste` commands + * (Issue #91), and is available to any other extension that wants + * programmatic access to the same buffer. + * + * **Never throws — matches `FileSystem`'s never-crash discipline, NOT its + * reject-on-failure one**: unlike {@link FileSystem}'s `read`/`write` + * (which reject the returned promise on a real I/O failure — a caller is + * expected to handle that), a clipboard write's OSC 52 half is a + * best-effort terminal escape sequence with no reliable failure signal at + * all — a terminal that ignores it produces neither an error nor any + * other observable difference from success. {@link write} therefore always + * resolves once the INTERNAL buffer is updated (the part every terminal + * supports unconditionally); an OSC 52 write that the host reports failing + * is logged (`HostLog`, design.md §14) and otherwise swallowed, never + * surfaced as a rejection. + * + * **OSC 52 is write-only here, deliberately**: reading a terminal's system + * clipboard back via OSC 52 is not portable across terminals (many either + * don't implement the query form at all or gate it behind a user prompt), + * so {@link read} only ever reports this namespace's OWN internal buffer — + * never attempts a live OSC 52 query. This means `read()` sees exactly + * what THIS process (or another `tecode.clipboard.write` caller) most + * recently wrote, not necessarily whatever the OS clipboard currently + * holds if something else changed it in between. + */ +export interface ClipboardNamespace { + /** + * The clipboard's current internal buffer contents (this namespace's + * TSDoc's "OSC 52 is write-only" note) — `""` when nothing has been + * copied/cut yet this session. Always resolves; never rejects. + */ + read(): Promise; + /** + * Store `text` as the clipboard's new internal buffer contents, and + * (when system-clipboard sync is enabled and the host terminal + * supports it) write it through to the terminal's OWN clipboard via OSC + * 52. Always resolves once the internal buffer is updated — an OSC 52 + * write failure is logged and swallowed, never surfaced as a rejection + * (this namespace's TSDoc). + */ + write(text: string): Promise; +} + /* ------------------------------------------------------------------ */ /* Tecode — the aggregate namespace object */ /* ------------------------------------------------------------------ */ @@ -472,4 +527,5 @@ export interface Tecode { context: ContextNamespace; languages: LanguagesNamespace; themes: ThemesNamespace; + clipboard: ClipboardNamespace; } diff --git a/packages/builtin/command-palette/index.test.ts b/packages/builtin/command-palette/index.test.ts index 71be135..fc8ca6c 100644 --- a/packages/builtin/command-palette/index.test.ts +++ b/packages/builtin/command-palette/index.test.ts @@ -136,6 +136,7 @@ function createFakeApi(tree: FakeTree, rootUri: Uri | undefined = "file:///works }, languages: undefined as never, themes: undefined as never, + clipboard: undefined as never, }; return { diff --git a/packages/builtin/editor-core/clipboard.test.ts b/packages/builtin/editor-core/clipboard.test.ts new file mode 100644 index 0000000..0fc1a29 --- /dev/null +++ b/packages/builtin/editor-core/clipboard.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, test } from "bun:test"; +import type { Position, Selection } from "@tecode/api"; +import type { LineReader } from "./movement"; +import { buildClipboardText, buildCutResult, buildPasteResult } from "./clipboard"; +import { collapsedSelection } from "./selectionMerge"; + +function pos(line: number, character: number): Position { + return { line, character }; +} + +function cursorAt(line: number, character: number): Selection { + return collapsedSelection(pos(line, character)); +} + +function selectionOf(startLine: number, startChar: number, endLine: number, endChar: number): Selection { + const start = pos(startLine, startChar); + const end = pos(endLine, endChar); + return { start, end, anchor: start, active: end }; +} + +function reversedSelectionOf(startLine: number, startChar: number, endLine: number, endChar: number): Selection { + const start = pos(startLine, startChar); + const end = pos(endLine, endChar); + return { start, end, anchor: end, active: start }; +} + +function reader(lines: string[]): LineReader { + return { getLine: (n) => lines[n] ?? "", lineCount: lines.length }; +} + +describe("buildClipboardText (Issue #91)", () => { + test("empty selections array: \"\"", () => { + expect(buildClipboardText(reader(["abc"]), [])).toBe(""); + }); + + test("a single collapsed cursor: \"\" (nothing selected)", () => { + expect(buildClipboardText(reader(["abc"]), [cursorAt(0, 1)])).toBe(""); + }); + + test("a single non-collapsed selection: its own text", () => { + const text = buildClipboardText(reader(["hello world"]), [selectionOf(0, 6, 0, 11)]); + expect(text).toBe("world"); + }); + + test("a selection spanning multiple lines includes the real newline(s) in between", () => { + const text = buildClipboardText(reader(["abc", "def", "ghi"]), [selectionOf(0, 1, 2, 2)]); + expect(text).toBe("bc\ndef\ngh"); + }); + + test("multiple cursors: each selection's own text, joined by \\n, in selection order", () => { + const text = buildClipboardText(reader(["foo bar", "baz qux"]), [ + selectionOf(0, 0, 0, 3), + selectionOf(1, 4, 1, 7), + ]); + expect(text).toBe("foo\nqux"); + }); + + test("direction (forward vs backward selection) does not change the copied text", () => { + const forward = buildClipboardText(reader(["abcdef"]), [selectionOf(0, 1, 0, 4)]); + const backward = buildClipboardText(reader(["abcdef"]), [reversedSelectionOf(0, 1, 0, 4)]); + expect(forward).toBe("bcd"); + expect(backward).toBe("bcd"); + }); +}); + +describe("buildCutResult (Issue #91)", () => { + test("a collapsed cursor: no edit, unchanged position, empty clipboard text", () => { + const result = buildCutResult(reader(["abc"]), [cursorAt(0, 1)]); + expect(result.text).toBe(""); + expect(result.edits).toHaveLength(0); + expect(result.selections).toEqual([cursorAt(0, 1)]); + }); + + test("a single non-collapsed selection: deletes the range, collapses the cursor to the start, copies the cut text", () => { + const result = buildCutResult(reader(["hello world"]), [selectionOf(0, 6, 0, 11)]); + expect(result.text).toBe("world"); + expect(result.edits).toEqual([{ range: { start: pos(0, 6), end: pos(0, 11) }, newText: "" }]); + expect(result.selections).toEqual([cursorAt(0, 6)]); + }); + + test("multiple cursors: only non-collapsed selections produce edits; the batch is ONE edits array, both cursors' text is still copied", () => { + const result = buildCutResult(reader(["aaa bbb ccc"]), [ + selectionOf(0, 0, 0, 3), // "aaa" + cursorAt(0, 5), // collapsed — nothing to delete + selectionOf(0, 8, 0, 11), // "ccc" + ]); + expect(result.text).toBe("aaa\n\nccc"); + expect(result.edits).toHaveLength(2); // only the two non-collapsed selections + }); + + test("a backward (reversed anchor/active) selection still cuts correctly and collapses to its start", () => { + const result = buildCutResult(reader(["abcdef"]), [reversedSelectionOf(0, 1, 0, 4)]); + expect(result.text).toBe("bcd"); + expect(result.selections).toEqual([cursorAt(0, 1)]); + }); +}); + +describe("buildPasteResult (Issue #91)", () => { + test("a single collapsed cursor: inserts the pasted text, caret lands after it", () => { + const result = buildPasteResult([cursorAt(0, 1)], "XY"); + expect(result.edits).toEqual([{ range: { start: pos(0, 1), end: pos(0, 1) }, newText: "XY" }]); + expect(result.selections).toEqual([cursorAt(0, 3)]); + }); + + test("a non-collapsed selection is replaced wholesale, caret lands at the end of the pasted text", () => { + const result = buildPasteResult([selectionOf(0, 1, 0, 4)], "Z"); + expect(result.edits).toEqual([{ range: { start: pos(0, 1), end: pos(0, 4) }, newText: "Z" }]); + expect(result.selections).toEqual([cursorAt(0, 2)]); + }); + + test("a backward selection is still replaced correctly, not from the wrong end", () => { + const result = buildPasteResult([reversedSelectionOf(0, 1, 0, 4)], "Z"); + expect(result.selections).toEqual([cursorAt(0, 2)]); + }); + + test("multi-line pasted text at a single cursor lands the caret on the pasted text's own last line", () => { + // Regression coverage for the exact class of bug `positionTransform.ts` + // (both `@tecode/core`'s and this package's own copy) had to get right + // for a multi-line replacement whose edit does not start at column 0. + const result = buildPasteResult([cursorAt(0, 1)], "line1\nline2\nline3"); + expect(result.selections).toEqual([cursorAt(2, 5)]); // "line3".length + }); + + test("two cursors on the same line: the second cursor's position accounts for the first's own paste", () => { + const result = buildPasteResult([cursorAt(0, 2), cursorAt(0, 5)], "ab"); + expect(result.edits).toHaveLength(2); + expect(result.selections).toEqual([cursorAt(0, 4), cursorAt(0, 9)]); + }); +}); diff --git a/packages/builtin/editor-core/clipboard.ts b/packages/builtin/editor-core/clipboard.ts new file mode 100644 index 0000000..4ed4635 --- /dev/null +++ b/packages/builtin/editor-core/clipboard.ts @@ -0,0 +1,114 @@ +/** + * Pure per-selection logic for `editor-core`'s clipboard commands (Issue + * #91): `editor.action.clipboardCopy`/`clipboardCut`/`clipboardPaste`. + * Copy/cut read the text under every selection (joined by `"\n"` across + * multiple cursors — a multi-cursor copy/cut's clipboard text has one line + * per cursor, in selection order); cut also builds the delete-the-selection + * edit batch, reusing `editing.ts`'s `buildEditBatch` exactly like + * `deleteLeft`/`deleteRight` do. Paste reuses `editing.ts`'s own + * `buildInsertEdit` — a paste and a Tab/Enter keystroke both "insert this + * text, replacing any active selection" (that function's own TSDoc). + * + * No `@tecode/core` import (the ESLint layering rule); reads through a + * {@link LineReader}, matching `movement.ts`/`multiCursor.ts`. + */ + +import type { Position, Selection, TextEdit } from "@tecode/api"; +import { buildEditBatch, buildInsertEdit, type EditBatch } from "./editing"; +import type { LineReader } from "./movement"; +import { comparePositions } from "./positionTransform"; + +/** Whether `selection` is a plain collapsed cursor (no selected range) — + * duplicated locally (`editing.ts`'s own private `isCollapsed`, `movement. + * ts`'s `multiCursor.ts`'s own copies) rather than exported/shared, matching + * this package's existing convention for this one-line check. */ +function isCollapsed(selection: Selection): boolean { + return comparePositions(selection.start, selection.end) === 0; +} + +/** Join every line of the document into one string with the offset each + * line starts at, and the inverse offset→position lookup — duplicated from + * `multiCursor.ts`'s own private `readBuffer`/`toOffset` (not exported + * there) rather than imported, for the same "no cross-module coupling + * beyond what's actually shared" reasoning `multiCursor.ts`'s own TSDoc + * gives for not depending on `movement.ts`. */ +function readBuffer(reader: LineReader): { text: string; lineStarts: number[] } { + const lines: string[] = []; + const lineStarts: number[] = []; + let offset = 0; + for (let i = 0; i < reader.lineCount; i++) { + const line = reader.getLine(i); + lineStarts.push(offset); + lines.push(line); + offset += line.length + 1; + } + return { text: lines.join("\n"), lineStarts }; +} + +/** Convert `position` to an offset into {@link readBuffer}'s `text`. */ +function toOffset(position: Position, lineStarts: readonly number[]): number { + return lineStarts[position.line]! + position.character; +} + +/** + * The text `editor.action.clipboardCopy`/`clipboardCut` write to the + * clipboard (Issue #91): each selection's own text (`""` for a collapsed + * cursor with nothing selected), joined with `"\n"` across multiple + * cursors, in `selections`' own order. `""` for an empty `selections` + * array (the no-active-editor/no-op case — `index.ts`'s command handlers + * already guard on this before ever calling here, matching every other + * command in this package's convention). + */ +export function buildClipboardText(reader: LineReader, selections: readonly Selection[]): string { + if (selections.length === 0) return ""; + const { text, lineStarts } = readBuffer(reader); + return selections + .map((selection) => text.slice(toOffset(selection.start, lineStarts), toOffset(selection.end, lineStarts))) + .join("\n"); +} + +/** Build the edit that deletes `selection`'s own range for + * `editor.action.clipboardCut` — `undefined` for a collapsed selection + * (nothing to delete for that cursor, matching `buildBackspaceEdit`/ + * `buildDeleteEdit`'s own "boundary no-op" convention in `editing.ts`, + * rather than deleting a whole line the way some editors do with nothing + * selected — Issue #91 does not ask for that). */ +function buildCutRangeEdit(selection: Selection): TextEdit | undefined { + if (isCollapsed(selection)) return undefined; + return { range: { start: selection.start, end: selection.end }, newText: "" }; +} + +/** What {@link buildCutResult} produces: the clipboard text (this module's + * {@link buildClipboardText}) plus the delete edit batch (`editing.ts`'s + * `EditBatch` shape — `edits` to apply, `selections` the resulting + * collapsed cursors). */ +export interface CutResult extends EditBatch { + text: string; +} + +/** + * Build `editor.action.clipboardCut`'s full result (Issue #91): the text to + * write to the clipboard (every selection's own text, even a collapsed + * one's `""`, so a cut always copies exactly what a copy of the same + * selections would have), and the edit batch that deletes each + * NON-collapsed selection's range — reusing `editing.ts`'s `buildEditBatch` + * exactly like `deleteLeft`/`deleteRight` do (this module's TSDoc). + */ +export function buildCutResult(reader: LineReader, selections: readonly Selection[]): CutResult { + const text = buildClipboardText(reader, selections); + const { edits, selections: newSelections } = buildEditBatch(selections, buildCutRangeEdit); + return { text, edits, selections: newSelections }; +} + +/** + * Build `editor.action.clipboardPaste`'s edit batch (Issue #91): insert + * `text` at every selection, replacing its range if it has one — reusing + * `editing.ts`'s own `buildInsertEdit(selection, text)` (the exact + * "insert, replacing any active selection" shape Tab/Enter already use) + * through `buildEditBatch`, so a multi-line/multi-cursor paste is one + * batch, one `applyEdits` call, one undo step, exactly like every other + * `editor-core` editing command. + */ +export function buildPasteResult(selections: readonly Selection[], text: string): EditBatch { + return buildEditBatch(selections, (selection) => buildInsertEdit(selection, text)); +} diff --git a/packages/builtin/editor-core/index.test.ts b/packages/builtin/editor-core/index.test.ts index f775a4a..b854913 100644 --- a/packages/builtin/editor-core/index.test.ts +++ b/packages/builtin/editor-core/index.test.ts @@ -48,6 +48,7 @@ function createFakeApi(initialLines: string[]) { const configListeners = new Set<(e: ConfigChangeEvent) => void>(); const savedUris: string[] = []; const languageContributions = new Map(); + let clipboardBuffer = ""; function applyEditsToLines(edits: TextEdit[]): void { // Apply in reverse document order so earlier edits' ranges stay valid @@ -206,6 +207,12 @@ function createFakeApi(initialLines: string[]) { getLanguage: (id: string) => languageContributions.get(id), }, themes: undefined as never, + clipboard: { + read: async () => clipboardBuffer, + write: async (text: string) => { + clipboardBuffer = text; + }, + }, } as unknown as Tecode; function setConfig(key: string, value: unknown): void { @@ -217,6 +224,10 @@ function createFakeApi(initialLines: string[]) { return { api, lines, + getClipboardBuffer: () => clipboardBuffer, + setClipboardBuffer: (text: string) => { + clipboardBuffer = text; + }, appliedEdits, savedUris, setConfig, @@ -520,3 +531,167 @@ describe("editor-core activate() — bracket auto-close (Req 11.1, Task 2.4)", ( expect(lines[0]).toBe("("); }); }); + +describe("editor-core activate() — clipboard copy/cut/paste (Issue #91)", () => { + test("copy writes the selected text to the clipboard and does not touch the buffer", async () => { + const { api, lines, appliedEdits, getClipboardBuffer } = activateFixture(["hello world"]); + api.editor.setSelections([ + { start: pos(0, 6), end: pos(0, 11), anchor: pos(0, 6), active: pos(0, 11) }, + ]); + + await api.commands.execute("editor.action.clipboardCopy"); + + expect(getClipboardBuffer()).toBe("world"); + expect(lines[0]).toBe("hello world"); + expect(appliedEdits).toHaveLength(0); + }); + + test("multi-cursor copy joins each selection's text with \\n, in selection order", async () => { + const { api, getClipboardBuffer } = activateFixture(["foo bar", "baz qux"]); + api.editor.setSelections([ + { start: pos(0, 0), end: pos(0, 3), anchor: pos(0, 0), active: pos(0, 3) }, + { start: pos(1, 4), end: pos(1, 7), anchor: pos(1, 4), active: pos(1, 7) }, + ]); + + await api.commands.execute("editor.action.clipboardCopy"); + + expect(getClipboardBuffer()).toBe("foo\nqux"); + }); + + test("cut writes the selected text to the clipboard AND deletes it, as one undo step", async () => { + const { api, lines, appliedEdits, getSelections, getClipboardBuffer } = activateFixture(["hello world"]); + api.editor.setSelections([ + { start: pos(0, 5), end: pos(0, 11), anchor: pos(0, 5), active: pos(0, 11) }, + ]); + + await api.commands.execute("editor.action.clipboardCut"); + + expect(getClipboardBuffer()).toBe(" world"); + expect(lines[0]).toBe("hello"); + expect(getSelections()).toEqual([cursorAt(0, 5)]); + expect(appliedEdits).toHaveLength(1); // ONE applyEdits call — one undo step + }); + + test("a multi-cursor cut is a SINGLE undo entry, not one per cursor", async () => { + const { api, lines } = activateFixture(["aaa bbb ccc"]); + api.editor.setSelections([ + { start: pos(0, 0), end: pos(0, 3), anchor: pos(0, 0), active: pos(0, 3) }, + { start: pos(0, 8), end: pos(0, 11), anchor: pos(0, 8), active: pos(0, 11) }, + ]); + + await api.commands.execute("editor.action.clipboardCut"); + expect(lines[0]).toBe(" bbb "); + + await api.commands.execute("editor.action.undo"); + expect(lines[0]).toBe("aaa bbb ccc"); + }); + + test("cut with a collapsed (empty) selection copies \"\" and does not call applyEdits", async () => { + const { api, appliedEdits, getClipboardBuffer, setClipboardBuffer } = activateFixture(["abc"]); + setClipboardBuffer("previous"); + api.editor.setSelections([cursorAt(0, 1)]); + + await api.commands.execute("editor.action.clipboardCut"); + + expect(getClipboardBuffer()).toBe(""); + expect(appliedEdits).toHaveLength(0); + }); + + test("paste inserts the clipboard's current text at the cursor, replacing any selection", async () => { + const { api, lines, getSelections, setClipboardBuffer } = activateFixture(["hello world"]); + setClipboardBuffer("there"); + api.editor.setSelections([ + { start: pos(0, 6), end: pos(0, 11), anchor: pos(0, 6), active: pos(0, 11) }, + ]); + + await api.commands.execute("editor.action.clipboardPaste"); + + expect(lines[0]).toBe("hello there"); + expect(getSelections()).toEqual([cursorAt(0, 11)]); + }); + + test("a multi-line paste across multiple cursors is a SINGLE applyEdits call (one undo step), not one per line", async () => { + const { api, lines, appliedEdits, setClipboardBuffer } = activateFixture(["a", "c"]); + setClipboardBuffer("X\nY"); + api.editor.setSelections([cursorAt(0, 1), cursorAt(1, 1)]); + + await api.commands.execute("editor.action.clipboardPaste"); + + expect(appliedEdits).toHaveLength(1); // the mutation this test is built to catch: a loop over lines + expect(appliedEdits[0]).toHaveLength(2); // one TextEdit per cursor, batched together + expect(lines).toEqual(["aX", "Y", "cX", "Y"]); + }); + + test("paste with an empty clipboard buffer (nothing copied/cut yet) leaves the buffer unchanged", async () => { + const { api, lines } = activateFixture(["abc"]); + api.editor.setSelections([cursorAt(0, 1)]); + // Default clipboard buffer is "" — nothing has been copied/cut yet. + // `buildInsertEdit` still produces a real (zero-width, empty-text) + // `TextEdit` for this case — `editor.action.clipboardPaste` applies it + // like any other edit rather than special-casing an empty clipboard — + // so the observable outcome is simply "the buffer is unchanged", not + // "applyEdits was never called". + await api.commands.execute("editor.action.clipboardPaste"); + expect(lines[0]).toBe("abc"); + }); + + test("empty selections array (no active editor): copy/cut/paste never call applyEdits or touch the clipboard", async () => { + const commandHandlers = new Map Promise | void>(); + let applyEditsCalls = 0; + let clipboardReadCalls = 0; + let clipboardWriteCalls = 0; + const fakeApi = { + commands: { + register: (id: string, handler: () => Promise | void) => { + commandHandlers.set(id, handler); + return { dispose() {} }; + }, + execute: async (id: string) => commandHandlers.get(id)?.(), + list: () => [], + }, + workspace: { save: async () => {} }, + window: { + get activeEditor() { + return { document: { applyEdits: () => applyEditsCalls++, transaction: (fn: () => void) => fn() } }; + }, + }, + editor: { + get selections() { + return []; + }, + getLine: () => "", + get lineCount() { + return 0; + }, + setSelections: () => {}, + }, + config: { get: () => undefined, onDidChange: () => ({ dispose() {} }) }, + languages: { register: () => ({ dispose() {} }), getLanguageId: () => "plaintext", getLanguage: () => undefined }, + clipboard: { + read: async () => { + clipboardReadCalls++; + return "should never be read"; + }, + write: async () => { + clipboardWriteCalls++; + }, + }, + } as unknown as Tecode; + + const ctx: ExtensionContext = { + api: fakeApi, + extensionUri: "file:///fake-ext", + subscriptions: [], + storagePath: "/tmp/fake-ext-storage", + }; + activate(ctx); + + await fakeApi.commands.execute("editor.action.clipboardPaste"); + await fakeApi.commands.execute("editor.action.clipboardCut"); + await fakeApi.commands.execute("editor.action.clipboardCopy"); + + expect(applyEditsCalls).toBe(0); + expect(clipboardReadCalls).toBe(0); + expect(clipboardWriteCalls).toBe(0); + }); +}); diff --git a/packages/builtin/editor-core/index.ts b/packages/builtin/editor-core/index.ts index f6c550f..6f72852 100644 --- a/packages/builtin/editor-core/index.ts +++ b/packages/builtin/editor-core/index.ts @@ -98,6 +98,7 @@ import type { TextEdit, } from "@tecode/api"; import { buildBracketEditBatch } from "./brackets"; +import { buildClipboardText, buildCutResult, buildPasteResult } from "./clipboard"; import { buildToggleLineCommentResult } from "./comments"; import { buildBackspaceEdit, @@ -425,6 +426,63 @@ export function activate(ctx: ExtensionContext): void { ), ); ctx.subscriptions.push(api.commands.register("editor.action.closeFind", () => api.editor.find.close())); + + // Issue #91: clipboard copy/cut/paste — pure builders in `clipboard.ts`, + // wired to `api.clipboard` for the actual buffer/OSC-52 read-write. No + // `clipboard.useSystemClipboard` reading happens here: that setting's + // schema is declared by THIS manifest (`manifest.ts`'s `contributes. + // configuration`), but the flag it controls lives on the host-only + // `Clipboard` service `@tecode/api`'s `ClipboardNamespace` deliberately + // does not expose (only `read`/`write` are extension-visible) — wiring + // config to that flag is `packages/cli/src/main.ts`'s job + // (`AssemblyRoot.applyClipboardSystemSetting`'s TSDoc explains why). + // + // Empty `selections` (`[]`, no active editor) makes each handler a + // documented no-op — no `applyEdits` call, no `api.clipboard.write`/ + // `read` either — matching every other command's own guard in this file. + ctx.subscriptions.push( + api.commands.register("editor.action.clipboardCopy", async () => { + const selections = api.editor.selections; + if (selections.length === 0) return; + await api.clipboard.write(buildClipboardText(reader(), selections)); + }), + ); + + ctx.subscriptions.push( + api.commands.register("editor.action.clipboardCut", async () => { + const editor = api.window.activeEditor; + if (!editor) return; + const selections = api.editor.selections; + if (selections.length === 0) return; + const { text, edits, selections: newSelections } = buildCutResult(reader(), selections); + await api.clipboard.write(text); + if (edits.length > 0) { + const document: Document = editor.document; + // Same "one transaction = one undo step" shape as every other + // editing command in this file (`registerEditing`'s TSDoc). + document.transaction(() => document.applyEdits(edits)); + } + api.editor.setSelections(newSelections); + }), + ); + + ctx.subscriptions.push( + api.commands.register("editor.action.clipboardPaste", async () => { + const editor = api.window.activeEditor; + if (!editor) return; + const selections = api.editor.selections; + if (selections.length === 0) return; + const text = await api.clipboard.read(); + const { edits, selections: newSelections } = buildPasteResult(selections, text); + if (edits.length === 0) return; + const document: Document = editor.document; + // ONE transaction, ONE `applyEdits` call for the WHOLE batch (Req + // 6.6) — a multi-cursor/multi-line paste is a single undo step, + // exactly like `buildPasteResult`'s own TSDoc documents. + document.transaction(() => document.applyEdits(edits)); + api.editor.setSelections(newSelections); + }), + ); } export function deactivate(): void { diff --git a/packages/builtin/editor-core/manifest.ts b/packages/builtin/editor-core/manifest.ts index aebae86..b7227ca 100644 --- a/packages/builtin/editor-core/manifest.ts +++ b/packages/builtin/editor-core/manifest.ts @@ -160,6 +160,30 @@ * Plain `ctrl+letter` combos (`ctrl+s`, `ctrl+z`, `ctrl+y`, `ctrl+d`) are * unaffected by any of this — a single modifier with no shift ambiguity * decodes identically and unambiguously in every mode. + * + * **Clipboard commands (Issue #91)**: `editor.action.clipboardCopy`/ + * `clipboardCut`/`clipboardPaste` are declared like every other command + * above — reachable from the command palette and `tecode.commands.execute` + * — but `clipboardCopy` is bound to NO default keybinding, deliberately, + * even though `ctrl+c` is not claimed by anything else in this manifest + * (`ctrl+x`/`ctrl+v` ARE bound below, for cut/paste). `ctrl+c` is NOT + * usable as a keybinding at all today: `packages/cli/src/renderShell.tsx`'s + * `renderShellToTerminal` calls `@opentui/core`'s `createCliRenderer()` + * with no `exitOnCtrlC` override, which defaults to `true` — OpenTUI + * itself intercepts the raw `\x03` byte and calls `CliRenderer.destroy()` + * directly, BEFORE it ever reaches this manifest's keymap layer (the same + * "raw mode disables signal generation" mechanism `renderShell.tsx`'s + * `ShellRenderDeps.onDestroy` TSDoc documents for why Ctrl+C never becomes + * a real `SIGINT` either). Worse, Ctrl+C is currently the ONLY way to quit + * tecode at all (Issue #84, Req 12.3) — no `workbench.action.quit` (or + * equivalent) command exists anywhere in any manifest yet, core or + * built-in. Adding a `ctrl+c` binding here would therefore be silently + * unreachable in practice (OpenTUI's own handling wins the race every + * time) while ALSO reading as if a real alternative to Ctrl+C-to-quit + * existed, which it does not. Whether/how to free up Ctrl+C for copy (via + * `exitOnCtrlC: false` plus a real quit command) is a product decision for + * the app owner, out of scope here — do not "fix" this by adding a + * `ctrl+c` binding without that decision being made first. */ import type { Manifest } from "@tecode/api"; @@ -170,6 +194,13 @@ const WHEN_EDITOR_TEXT_FOCUS = "editorTextFocus"; * the exact same mechanism `WHEN_EDITOR_TEXT_FOCUS` uses for the buffer. */ const WHEN_FIND_WIDGET_FOCUS = "findWidgetFocus"; +/** Issue #91's `clipboard.useSystemClipboard` setting's key — named, + * exported constant, matching `explorer/manifest.ts`'s + * `EXPLORER_SHOW_HIDDEN_CONFIG_KEY` precedent (`index.ts` and this + * manifest's own `contributes.configuration` block below both reference + * this same string). */ +export const CLIPBOARD_USE_SYSTEM_CONFIG_KEY = "clipboard.useSystemClipboard"; + export default { id: "tecode.editor-core", version: "0.1.0", @@ -237,6 +268,13 @@ export default { category: "Editor", }, { id: "editor.action.closeFind", title: "Close Find", category: "Editor" }, + // Issue #91: clipboard copy/cut/paste. See this file's TSDoc's + // "Clipboard commands (Issue #91)" section, just below the + // keybindings table, for why `clipboardCopy` alone has no default + // keybinding. + { id: "editor.action.clipboardCopy", title: "Copy", category: "Editor" }, + { id: "editor.action.clipboardCut", title: "Cut", category: "Editor" }, + { id: "editor.action.clipboardPaste", title: "Paste", category: "Editor" }, ], keybindings: [ { key: "left", command: "editor.action.cursorLeft", when: WHEN_EDITOR_TEXT_FOCUS }, @@ -307,6 +345,22 @@ export default { { key: "return", command: "editor.action.findNext", when: WHEN_FIND_WIDGET_FOCUS }, { key: "shift+return", command: "editor.action.findPrevious", when: WHEN_FIND_WIDGET_FOCUS }, { key: "escape", command: "editor.action.closeFind", when: WHEN_FIND_WIDGET_FOCUS }, + // Issue #91: clipboard cut/paste. See this file's TSDoc's "Clipboard + // commands (Issue #91)" section for why `clipboardCopy` has no + // keybinding entry here at all. + { key: "ctrl+x", command: "editor.action.clipboardCut", when: WHEN_EDITOR_TEXT_FOCUS }, + { key: "ctrl+v", command: "editor.action.clipboardPaste", when: WHEN_EDITOR_TEXT_FOCUS }, ], + configuration: { + title: "Clipboard", + properties: { + [CLIPBOARD_USE_SYSTEM_CONFIG_KEY]: { + type: "boolean", + default: true, + description: + "Sync copy/cut to the terminal's system clipboard via OSC 52, when the terminal supports it.", + }, + }, + }, }, } satisfies Manifest; diff --git a/packages/builtin/explorer/index.test.tsx b/packages/builtin/explorer/index.test.tsx index bf5fa5d..89da4ef 100644 --- a/packages/builtin/explorer/index.test.tsx +++ b/packages/builtin/explorer/index.test.tsx @@ -252,6 +252,7 @@ function createFakeApi(rootUri: Uri | undefined) { }, languages: undefined as never, themes: undefined as never, + clipboard: undefined as never, }; return { diff --git a/packages/builtin/keybindings-editor/index.test.ts b/packages/builtin/keybindings-editor/index.test.ts index e29d72f..e6966bd 100644 --- a/packages/builtin/keybindings-editor/index.test.ts +++ b/packages/builtin/keybindings-editor/index.test.ts @@ -64,6 +64,7 @@ function createFakeApi() { config: undefined as never, languages: undefined as never, themes: undefined as never, + clipboard: undefined as never, context: undefined as never, window: { showMessage(message: string, kind?: MessageKind) { diff --git a/packages/builtin/statusbar/index.test.ts b/packages/builtin/statusbar/index.test.ts index 3a278dc..50cfabb 100644 --- a/packages/builtin/statusbar/index.test.ts +++ b/packages/builtin/statusbar/index.test.ts @@ -153,6 +153,7 @@ function createFakeApi() { }, onDidChange: themesChange.on, }, + clipboard: undefined as never, }; return { diff --git a/packages/cli/src/keyRouting.test.ts b/packages/cli/src/keyRouting.test.ts index 193f845..dfdeec5 100644 --- a/packages/cli/src/keyRouting.test.ts +++ b/packages/cli/src/keyRouting.test.ts @@ -11,7 +11,13 @@ import { type KeymapLayers, } from "@tecode/core"; import editorCoreManifest from "@tecode/builtin/editor-core/manifest"; -import { handleKeyEvent, type KeyRoutingDeps, type RoutableKeyEvent } from "./keyRouting"; +import { + handleKeyEvent, + handlePasteEvent, + type KeyRoutingDeps, + type PasteRoutingDeps, + type RoutableKeyEvent, +} from "./keyRouting"; function keyOf(partial: Partial & { name: string }): RoutableKeyEvent { return { @@ -409,6 +415,30 @@ describe("editor-core's Task 2.5 find/replace keybindings (Req 11.1, manifest.ts }); }); +describe("handlePasteEvent (Issue #91's paste path)", () => { + test("delegates the decoded text straight to editorInputRouter.insertText", () => { + let received: string | undefined; + const deps: PasteRoutingDeps = { + editorInputRouter: { insertText: (text) => (received = text) }, + }; + + handlePasteEvent(deps, "pasted\ntext"); + + expect(received).toBe("pasted\ntext"); + }); + + test("goes through no chord machine at all — an empty paste is still forwarded", () => { + let calls = 0; + const deps: PasteRoutingDeps = { + editorInputRouter: { insertText: () => calls++ }, + }; + + handlePasteEvent(deps, ""); + + expect(calls).toBe(1); + }); +}); + /** * End-to-end version of the same pipeline, wired against the REAL * `ChordStateMachine`/`BindingTable`/`createEditorInputRouter`/`CoreDocument` diff --git a/packages/cli/src/keyRouting.ts b/packages/cli/src/keyRouting.ts index 9605cd5..c9a1822 100644 --- a/packages/cli/src/keyRouting.ts +++ b/packages/cli/src/keyRouting.ts @@ -53,3 +53,27 @@ export function handleKeyEvent(deps: KeyRoutingDeps, event: RoutableKeyEvent): v } deps.editorInputRouter.routeKeyEvent(event); } + +/** Dependencies for {@link handlePasteEvent} — narrowed to the one method + * it needs (matches {@link KeyRoutingDeps}'s own `Pick<...>` convention). */ +export interface PasteRoutingDeps { + editorInputRouter: Pick; +} + +/** + * Route one decoded bracketed-paste string (Issue #91, design.md §6.1's + * pipeline extended to terminal paste input): straight to {@link + * EditorInputRouter.insertText}, with no chord-machine step at all — + * unlike {@link handleKeyEvent}'s ordinary keystrokes, a paste never has a + * keybinding to match against; it always means "insert this text" at + * whatever the current selections are. `renderShell.tsx`'s + * `renderShellToTerminal` calls this from its `renderer.keyInput.on( + * "paste", ...)` listener, already having decoded `PasteEvent.bytes` (a + * `Uint8Array`) to a UTF-8 string (`ShellRenderDeps.onPaste`'s TSDoc) — + * this function itself never touches raw bytes, matching {@link + * handleKeyEvent}'s own "pulled out for direct, `@opentui/core`-free + * testability" shape (this module's TSDoc). + */ +export function handlePasteEvent(deps: PasteRoutingDeps, text: string): void { + deps.editorInputRouter.insertText(text); +} diff --git a/packages/cli/src/main.test.ts b/packages/cli/src/main.test.ts index efe3a1a..5717bd0 100644 --- a/packages/cli/src/main.test.ts +++ b/packages/cli/src/main.test.ts @@ -106,6 +106,7 @@ test("buildAssemblyRoot wires every core service and registers the 'tecode' modu "context", "languages", "themes", + "clipboard", ]); expect(root.api.workspace.rootUri).toBe(pathToUri(dir)); diff --git a/packages/cli/src/main.ts b/packages/cli/src/main.ts index 941d7f8..084cfbe 100644 --- a/packages/cli/src/main.ts +++ b/packages/cli/src/main.ts @@ -17,6 +17,7 @@ import { createConfigService, createContextService, createDocumentManager, + createClipboard, createEditorInputRouter, createEditorSessionService, createExtensionHost, @@ -58,6 +59,7 @@ import { type ContextService, type DiscoveryFs, type DocumentManager, + type Clipboard, type EditorInputRouter, type EditorSessionService, type ExtensionHost, @@ -89,6 +91,7 @@ import { applyConfiguredKeybindingPreset as applyConfiguredKeybindingPresetImpl, wireKeybindingPresetConfigSync, } from "./keybindingPresetConfigSync"; +import { handlePasteEvent } from "./keyRouting"; import { createKeymapState, type KeymapState } from "./keymapState"; import { createBuiltinLanguageAssetsFs } from "./languageAssetsFs"; import { renderShellHeadless, renderShellToTerminal, type RenderShell } from "./renderShell"; @@ -173,6 +176,13 @@ export interface AssemblyRoot { commands: CommandRegistry; documents: DocumentManager; fs: FileSystem; + /** The clipboard service (Issue #91, `@tecode/core`'s `clipboard/ + * clipboard.ts`) — backs `tecode.clipboard` (via `api` below), and + * receives the terminal's OSC 52 write function from `renderShell.tsx`'s + * `onClipboardWriterReady` once the render seam resolves one + * (`runTecode`, this module's TSDoc). Built once here, exactly like + * `fs` above. */ + clipboard: Clipboard; config: ConfigService; context: ContextService; api: Tecode; @@ -319,6 +329,37 @@ export interface AssemblyRoot { * the active theme. Disposed alongside every other startup-owned * subscription in {@link wireProcessExit}. */ keybindingPresetConfigSync: Disposable; + /** + * Apply the CURRENT `clipboard.useSystemClipboard` setting (Issue #91) + * to {@link clipboard}'s system-clipboard-sync flag. Lives here, in the + * composition root, rather than in `editor-core` (the extension that + * DECLARES this setting's schema) because `@tecode/api`'s + * `ClipboardNamespace` — all an extension can ever see — exposes only + * `read`/`write`, never the host-only `setSystemClipboardEnabled` this + * needs (`create.ts`'s "narrowing, not re-implementing" boundary + * extended to this one setting): only `main.ts` holds the real + * `Clipboard` instance. Mirrors `applyConfiguredKeybindingPreset`'s own + * shape (a core-owned setting wired to a core-owned service) even though + * THIS setting's schema is extension-owned, not `config/coreDefaults. + * ts`'s. `runTecode` calls this once after `config.ready` settles; + * {@link clipboardConfigSync} (below) calls it again on every live + * change to that one key. Falls back to `true` — matching both this + * setting's own schema default and {@link Clipboard}'s own internal + * default — when `config.get(...)` reports nothing yet (e.g. before + * `editor-core`'s manifest has been discovered/registered; an explicit + * `false` in the user's `settings.json` still applies immediately + * regardless of registration, since JSONC settings are read independent + * of schema registration). Synchronous and never throws: + * `Clipboard.setSystemClipboardEnabled` itself never throws + * (`clipboard/clipboard.ts`'s TSDoc). + */ + applyClipboardSystemSetting(): void; + /** Live `clipboard.useSystemClipboard` config-change subscription (Issue + * #91) — mirrors {@link keybindingPresetConfigSync}'s own shape, just for + * {@link applyClipboardSystemSetting} instead of the keybinding preset + * layer. Disposed alongside every other startup-owned subscription in + * {@link wireProcessExit}. */ + clipboardConfigSync: Disposable; /** The live two-stroke chord state machine (Req 4.4, design.md §6.1, * §6.3), built once here against a small forwarding view over `keymap` * (see this function's TSDoc's "Live keymap table view") so it always @@ -617,6 +658,11 @@ export function buildAssemblyRoot( sink, }); const fs = createFileSystem({ log }); + // Issue #91's clipboard service — built once, exactly like `fs` above. + // Its OSC 52 system writer arrives later (`runTecode`'s `renderShell(...)` + // call, via `deps.onClipboardWriterReady`) since it needs a real + // `CliRenderer` that does not exist yet at this point in the sync phase. + const clipboard = createClipboard({ log }); // `MODAL_DEFAULT_KEYBINDINGS` (Task 3.1, `ui/modalCommands.ts`) is this // codebase's first real occupant of the `defaults` layer @@ -723,6 +769,28 @@ export function buildAssemblyRoot( applyConfiguredKeybindingPresetImpl({ config, keymap, log }); const keybindingPresetConfigSync = wireKeybindingPresetConfigSync({ config, keymap, log }); + // `AssemblyRoot.applyClipboardSystemSetting`/`clipboardConfigSync` (Issue + // #91) — see that field's TSDoc for why this wiring lives here rather + // than in `editor-core`, despite that extension owning the setting's + // schema. The key string is duplicated (not imported from `editor-core/ + // manifest.ts`'s `CLIPBOARD_USE_SYSTEM_CONFIG_KEY`) — matching `stubs. + // ts`'s `createThemesStub`'s own precedent for this exact kind of small + // cross-boundary string duplication — since this composition root + // reading an extension's manifest CONSTANT for host-side wiring would + // invert the "extensions are data the host reads, not code the host + // imports internals from" boundary this repo otherwise keeps clean. Must + // stay in sync with `editor-core/manifest.ts`'s own + // `CLIPBOARD_USE_SYSTEM_CONFIG_KEY`. + const CLIPBOARD_USE_SYSTEM_CONFIG_KEY = "clipboard.useSystemClipboard"; + const applyClipboardSystemSetting = () => { + clipboard.setSystemClipboardEnabled( + config.get(CLIPBOARD_USE_SYSTEM_CONFIG_KEY) ?? true, + ); + }; + const clipboardConfigSync = config.onDidChange((event) => { + if (event.affectsConfiguration(CLIPBOARD_USE_SYSTEM_CONFIG_KEY)) applyClipboardSystemSetting(); + }); + const context = createContextService(); const layoutState = createLayoutStateService({ log, sink }); @@ -823,6 +891,7 @@ export function buildAssemblyRoot( languageRegistry, modalService, windowMessageService, + clipboard, }); // Must run before any extension module is imported (see this function's @@ -938,6 +1007,7 @@ export function buildAssemblyRoot( commands, documents, fs, + clipboard, config, context, api, @@ -958,6 +1028,8 @@ export function buildAssemblyRoot( applyKittyKeyboardVerdict, applyConfiguredKeybindingPreset, keybindingPresetConfigSync, + applyClipboardSystemSetting, + clipboardConfigSync, chordMachine, chordPendingIndicator, editorSession, @@ -1208,6 +1280,7 @@ export interface ShutdownRoot { hostErrorSink: Pick; highlightService: Pick; languageRegistry: Pick; + clipboardConfigSync: Pick; hostRef: { current?: Pick }; } @@ -1286,6 +1359,7 @@ export function createShutdown(root: ShutdownRoot, deps: ShutdownDeps = {}): () root.editorLangIdSync.dispose(); root.themeConfigSync.dispose(); root.keybindingPresetConfigSync.dispose(); + root.clipboardConfigSync.dispose(); root.themeSelectCommand.dispose(); root.openFileCommand.dispose(); root.tabCommands.dispose(); @@ -1484,6 +1558,12 @@ export async function runTecode( // initial application. root.applyConfiguredKeybindingPreset(); + // Apply the ACTUAL configured `clipboard.useSystemClipboard` now that + // `config.ready` has settled (Issue #91) — same "schema default only, + // until ready" reasoning as `keybindings.preset` above + // (`AssemblyRoot.applyClipboardSystemSetting`'s TSDoc). + root.applyClipboardSystemSetting(); + const { shutdown } = wireProcessExit(root); const renderShell = options.renderShell ?? (headless ? renderShellHeadless : renderShellToTerminal); @@ -1502,6 +1582,21 @@ export async function runTecode( chordMachine: root.chordMachine, editorInputRouter: root.editorInputRouter, modalService: root.modalService, + // Issue #91: the terminal's OSC 52 write function is handed to + // `root.clipboard` exactly once, as soon as the render seam resolves + // one (`ShellRenderDeps.onClipboardWriterReady`'s TSDoc) — + // `renderShellHeadless` never calls this, leaving `root.clipboard`'s + // system-clipboard sync permanently inert for a headless run, which is + // correct: there is no real terminal to write an OSC 52 escape + // sequence to. + onClipboardWriterReady: (write) => root.clipboard.setSystemWriter(write), + // Issue #91: bracketed-paste text, already decoded to UTF-8 by + // `renderShellToTerminal` (`ShellRenderDeps.onPaste`'s TSDoc), routed + // through `keyRouting.ts`'s `handlePasteEvent` into + // `root.editorInputRouter.insertText` — the same "pulled out for + // testability, wired here" shape `handleKeyEvent`'s own `renderer. + // keyInput.on("keypress", ...)` registration above already uses. + onPaste: (text) => handlePasteEvent({ editorInputRouter: root.editorInputRouter }, text), // Task 4.2's Kitty Keyboard Protocol wiring (Req 4.7, 13.3, design.md // §6.5): `renderShell.tsx`'s `onCapabilitiesResolved` delivers the raw // `@opentui/core` capabilities value (at most twice — synchronously, diff --git a/packages/cli/src/renderShell.test.ts b/packages/cli/src/renderShell.test.ts index aa8f02e..d929a00 100644 --- a/packages/cli/src/renderShell.test.ts +++ b/packages/cli/src/renderShell.test.ts @@ -8,7 +8,7 @@ import { createCommandRegistry, } from "@tecode/core"; import { createBaseTheme } from "@tecode/core"; -import { renderShellHeadless } from "./renderShell"; +import { decodePastedBytes, renderShellHeadless } from "./renderShell"; // renderShellToTerminal is intentionally NOT exercised here: it opens a // real @opentui/core CliRenderer/TTY, which bun test's sandboxed, non-TTY @@ -39,3 +39,19 @@ test("renderShellHeadless resolves without touching a real terminal", async () = await expect(renderShellHeadless(deps)).resolves.toBeUndefined(); }); + +// Issue #91 regression: a DEFAULT `new TextDecoder()` has `ignoreBOM: +// false`, which silently drops a leading U+FEFF — so pasting a BOM-prefixed +// payload used to insert one character fewer than was pasted. +test("decodePastedBytes keeps a leading BOM instead of silently dropping it", () => { + const withBom = new Uint8Array([0xef, 0xbb, 0xbf, 0x68, 0x69]); // U+FEFF + "hi" + expect(decodePastedBytes(withBom)).toBe("hi"); + // Sanity: this is exactly what the default decoder would have produced, + // pinning WHICH behaviour is being guarded against. + expect(new TextDecoder().decode(withBom)).toBe("hi"); +}); + +test("decodePastedBytes leaves ordinary multi-line UTF-8 payloads untouched", () => { + const bytes = new TextEncoder().encode("héllo\nwörld\n日本語"); + expect(decodePastedBytes(bytes)).toBe("héllo\nwörld\n日本語"); +}); diff --git a/packages/cli/src/renderShell.tsx b/packages/cli/src/renderShell.tsx index 07ca06f..957e725 100644 --- a/packages/cli/src/renderShell.tsx +++ b/packages/cli/src/renderShell.tsx @@ -176,6 +176,40 @@ export interface ShellRenderDeps { * every other optional dependency in this module. */ onDestroy?: () => void; + /** + * Delivers the terminal's OSC 52 write function exactly ONCE (Issue #91), + * without exposing the `CliRenderer` itself — the same "hand over a + * value/callback, not the renderer" convention as {@link + * onCapabilitiesResolved}/{@link onDestroy} above. {@link + * renderShellToTerminal} calls this synchronously, right after the + * renderer is created, with `renderer.copyToClipboardOSC52` bound to that + * renderer instance — the returned function's own boolean return value + * (`true`/`false` for accepted/not) is `@opentui/core`'s only per-call + * feedback; there is no separate "supported" query this callback also + * needs to report, since a terminal that does not support OSC 52 simply + * reports `false` (or is silently ignored) on every call, which the + * clipboard service (`@tecode/core`'s `clipboard/clipboard.ts`) already + * logs and swallows. Optional and never required: {@link + * renderShellHeadless} never calls this (no real `CliRenderer`/terminal + * exists to write an OSC 52 escape sequence to), matching every other + * optional terminal-seam callback in this module. + */ + onClipboardWriterReady?: (write: (text: string) => boolean) => void; + /** + * Delivers bracketed-paste text as it arrives (Issue #91): {@link + * renderShellToTerminal} listens for `renderer.keyInput`'s `"paste"` + * event (`@opentui/core`'s `PasteEvent`, `lib/KeyHandler.d.ts`) and + * decodes its `bytes` (a `Uint8Array`) as UTF-8 before calling this with + * the resulting string — extension/router code never sees raw bytes. + * Wired ONLY when this callback is supplied, the same "register nothing + * unless the caller actually wants it" pattern the `chordMachine`/ + * `editorInputRouter` pairing above already uses for `"keypress"` — a + * caller/test that omits this leaves paste entirely unhandled, exactly + * like every other optional callback here. {@link renderShellHeadless} + * never calls this either (this module's TSDoc's "First frame for a + * headless run" — there is no real terminal to receive a paste from). + */ + onPaste?: (text: string) => void; } /** The render seam's shape: resolves once "first frame" has happened (see @@ -183,6 +217,30 @@ export interface ShellRenderDeps { * one call site in `main.ts` — an implementation that throws still leaves * `runTecode`'s own never-throwing startup contract to that call site, not * to this type. */ +/** + * Decode a bracketed-paste payload (`PasteEvent.bytes`) into the string + * handed to {@link ShellRenderDeps.onPaste} — and, through it, straight + * into `EditorInputRouter.insertText` (Issue #91). + * + * `ignoreBOM: true` is load-bearing, and is the opposite of what the name + * suggests: per the WHATWG Encoding Standard a DEFAULT `new TextDecoder()` + * has `ignoreBOM: false`, which makes it treat a leading U+FEFF as an + * encoding marker and SILENTLY DROP it; `true` makes it treat that U+FEFF + * as ordinary text and keep it. Paste has to insert exactly the characters + * the user pasted — a round trip that copies a BOM-prefixed line and pastes + * it back must not quietly lose a character. Only a LEADING BOM is affected + * either way: one in the middle of the payload already survives the default + * decoder. + * + * Exported for its own test: {@link renderShellToTerminal}, its only + * caller, opens a real `CliRenderer`/TTY that `bun test` cannot provide + * (see this module's TSDoc), so this seam is where the behaviour is + * assertable at all. + */ +export function decodePastedBytes(bytes: Uint8Array): string { + return new TextDecoder("utf-8", { ignoreBOM: true }).decode(bytes); +} + export type RenderShell = (deps: ShellRenderDeps) => Promise; /** @@ -241,6 +299,26 @@ export const renderShellToTerminal: RenderShell = async (deps) => { }); } + // OSC 52 system-clipboard write (Issue #91, `ShellRenderDeps. + // onClipboardWriterReady`'s TSDoc): delivered exactly once, bound to + // THIS renderer instance — never exposes `renderer` itself, only the + // bound write function. + if (deps.onClipboardWriterReady) { + deps.onClipboardWriterReady((text) => renderer.copyToClipboardOSC52(text)); + } + + // Bracketed-paste terminal input (Issue #91, `ShellRenderDeps.onPaste`'s + // TSDoc): `PasteEvent.bytes` is decoded to a UTF-8 string before ever + // reaching `deps.onPaste` — registered only when the caller actually + // wants it, the same "nothing wired unless asked" pattern the + // `chordMachine && editorInputRouter` pairing above already uses. + if (deps.onPaste) { + const onPaste = deps.onPaste; + renderer.keyInput.on("paste", (event) => { + onPaste(decodePastedBytes(event.bytes)); + }); + } + // Terminal-capability reporting (Req 4.7, 13.3; design.md §6.5; Task // 4.2) — see `ShellRenderDeps.onCapabilitiesResolved`'s TSDoc for the // "at most twice, `.once` not `.on`" contract this implements. diff --git a/packages/cli/src/shutdownOnDestroy.test.ts b/packages/cli/src/shutdownOnDestroy.test.ts index 1795750..573550f 100644 --- a/packages/cli/src/shutdownOnDestroy.test.ts +++ b/packages/cli/src/shutdownOnDestroy.test.ts @@ -101,6 +101,7 @@ function createFakeShutdownRoot(overrides: { flush?: () => Promise } = {}) editorLangIdSync: disposable(), themeConfigSync: disposable(), keybindingPresetConfigSync: disposable(), + clipboardConfigSync: disposable(), themeSelectCommand: disposable(), openFileCommand: disposable(), tabCommands: disposable(), @@ -371,7 +372,7 @@ test("createShutdown's returned function is idempotent: destroy-then-signal runs await Promise.all([destroyCall, signalCall]); expect(calls.flush).toBe(1); - expect(calls.dispose).toBe(19); // one per disposable field in ShutdownRoot + expect(calls.dispose).toBe(20); // one per disposable field in ShutdownRoot expect(calls.disposeAll).toBe(1); }); @@ -387,7 +388,7 @@ test("createShutdown's returned function is idempotent: signal-then-destroy runs await Promise.all([signalCall, destroyCall]); expect(calls.flush).toBe(1); - expect(calls.dispose).toBe(19); + expect(calls.dispose).toBe(20); expect(calls.disposeAll).toBe(1); // A THIRD call, after the sequence has already fully settled, is still @@ -395,7 +396,7 @@ test("createShutdown's returned function is idempotent: signal-then-destroy runs // from as many quit paths as ever call it). await shutdown(); expect(calls.flush).toBe(1); - expect(calls.dispose).toBe(19); + expect(calls.dispose).toBe(20); expect(calls.disposeAll).toBe(1); }); diff --git a/packages/core/src/api/create.clipboard.test.ts b/packages/core/src/api/create.clipboard.test.ts new file mode 100644 index 0000000..3020e6a --- /dev/null +++ b/packages/core/src/api/create.clipboard.test.ts @@ -0,0 +1,65 @@ +/** + * Tests for `createTecodeApi`'s real `tecode.clipboard` wiring (Issue #91): + * `read`/`write` delegate to an injected `Clipboard`/`ClipboardNamespace` + * when supplied, and fall back to `stubs.ts`'s `createClipboardStub` + * exactly as before otherwise (`CreateTecodeApiDeps.clipboard`'s TSDoc). + */ + +import { describe, expect, test } from "bun:test"; +import { createCommandRegistry } from "../commands/registry"; +import { createDocumentManager } from "../buffer/documentManager"; +import { createFileSystem } from "../buffer/fileSystem"; +import type { ConfigServiceFs } from "../config/service"; +import { createConfigService } from "../config/service"; +import { createContextService } from "../keymap/context"; +import { createHostLog } from "../host/errors"; +import { createClipboard } from "../clipboard/clipboard"; +import { createTecodeApi } from "./create"; + +function createEmptyConfigFs(): ConfigServiceFs { + return { + readFile: () => Promise.reject(Object.assign(new Error("ENOENT"), { code: "ENOENT" })), + watch: () => ({ close() {} }), + }; +} + +async function buildBaseDeps() { + const log = createHostLog(); + const sink = { error() {} }; + const commands = createCommandRegistry({ log, sink }); + const documents = createDocumentManager({ log, sink }); + const fs = createFileSystem({ log }); + const config = createConfigService({ log, sink, fs: createEmptyConfigFs() }); + await config.ready; + const context = createContextService(); + return { commands, documents, fs, config, context, sink }; +} + +describe("createTecodeApi's tecode.clipboard (Issue #91)", () => { + test("falls back to the stub when no clipboard is supplied: read() resolves '', write() is a no-op", async () => { + const deps = await buildBaseDeps(); + const api = createTecodeApi(deps); + + expect(await api.clipboard.read()).toBe(""); + await expect(api.clipboard.write("ignored")).resolves.toBeUndefined(); + expect(await api.clipboard.read()).toBe(""); // still "" — the stub never remembers a write + }); + + test("read/write delegate to the real Clipboard when supplied", async () => { + const deps = await buildBaseDeps(); + const clipboard = createClipboard(); + const api = createTecodeApi({ ...deps, clipboard }); + + await api.clipboard.write("copied via tecode.clipboard"); + expect(await api.clipboard.read()).toBe("copied via tecode.clipboard"); + // Reading straight off the real service proves this is genuine + // delegation, not two independent buffers that happen to agree. + expect(await clipboard.read()).toBe("copied via tecode.clipboard"); + }); + + test("tecode.clipboard is frozen — assigning to it is a no-op (or throws in strict mode), never mutates behavior", async () => { + const deps = await buildBaseDeps(); + const api = createTecodeApi(deps); + expect(Object.isFrozen(api.clipboard)).toBe(true); + }); +}); diff --git a/packages/core/src/api/create.ts b/packages/core/src/api/create.ts index 9d17ea5..9992a23 100644 --- a/packages/core/src/api/create.ts +++ b/packages/core/src/api/create.ts @@ -30,6 +30,7 @@ */ import type { + ClipboardNamespace, CommandsNamespace, ConfigNamespace, ContextNamespace, @@ -62,6 +63,7 @@ import type { WindowMessageService } from "../ui/windowMessageService"; import type { LanguageRegistry } from "../languages/languageRegistry"; import { cloneSelection, createEditorNamespace } from "./editorNamespace"; import { + createClipboardStub, createEditorStub, createLanguagesStub, createThemesStub, @@ -212,6 +214,17 @@ export interface CreateTecodeApiDeps { * that never render anywhere, so a mismatch falls back to the stub. */ windowMessageService?: Pick; + /** + * Backs the REAL `tecode.clipboard` (Issue #91, `clipboard/clipboard.ts`'s + * `createClipboard`) — `read`/`write` delegate straight through, same + * "same function references, no wrapper closures" shape as + * `commandsNamespace`/`configNamespace` above. Optional: a caller that + * omits this (every test that predates Issue #91) keeps `stubs.ts`'s + * `createClipboardStub()` — an always-`""` `read()` and a no-op `write()` + * — exactly like every other real-backing dependency's fallback in this + * file. + */ + clipboard?: Pick; } /** @@ -421,6 +434,15 @@ export function createTecodeApi(deps: CreateTecodeApiDeps): Tecode { getLanguage: languageRegistry ? languageRegistry.getLanguage : languagesStub.getLanguage, }); + // Real backing (Issue #91) when a `Clipboard`/`ClipboardNamespace` is + // supplied; otherwise `stubs.ts`'s `createClipboardStub()` — see + // `CreateTecodeApiDeps.clipboard`'s TSDoc. + const clipboardStub = createClipboardStub(); + const clipboardNamespace: ClipboardNamespace = Object.freeze({ + read: deps.clipboard ? deps.clipboard.read : clipboardStub.read, + write: deps.clipboard ? deps.clipboard.write : clipboardStub.write, + }); + return Object.freeze({ commands: commandsNamespace, workspace: workspaceNamespace, @@ -431,5 +453,6 @@ export function createTecodeApi(deps: CreateTecodeApiDeps): Tecode { context: contextNamespace, languages: languagesNamespace, themes: themesNamespace, + clipboard: clipboardNamespace, }); } diff --git a/packages/core/src/api/index.ts b/packages/core/src/api/index.ts index d1f67b0..c54ee8d 100644 --- a/packages/core/src/api/index.ts +++ b/packages/core/src/api/index.ts @@ -13,6 +13,7 @@ export { } from "./editorNamespace"; export { createBaseTheme, + createClipboardStub, createEditorStub, createFindStub, createLanguagesStub, diff --git a/packages/core/src/api/stubs.ts b/packages/core/src/api/stubs.ts index 1c93297..449da6d 100644 --- a/packages/core/src/api/stubs.ts +++ b/packages/core/src/api/stubs.ts @@ -36,6 +36,7 @@ */ import type { + ClipboardNamespace, Disposable, EditorNamespace, FindNamespace, @@ -416,6 +417,28 @@ export interface ThemesStub extends ThemesNamespace { * {@link createBaseTheme} palette until a real theme loader (design.md §9) * can resolve a registered theme and track the active selection. */ +/** + * Build the `tecode.clipboard` stub (Issue #91) — `create.ts`'s fallback + * for a caller that supplies no `Clipboard` dependency at all (every test + * that predates this task). Unlike `createFileSystem`'s real backing + * (always constructed, never stubbed — `main.ts` builds one + * unconditionally), a clipboard genuinely has nothing useful to do with no + * backing buffer: `read()` always resolves `""`, and `write()` is a + * documented no-op that never throws (this module's TSDoc's never-throw + * discipline) — no extension can observe anything it "wrote" surviving + * past this stub, exactly as if no clipboard existed at all. + */ +export function createClipboardStub(): ClipboardNamespace { + return Object.freeze({ + read() { + return Promise.resolve(""); + }, + write() { + return Promise.resolve(); + }, + }); +} + export function createThemesStub(): ThemesStub { const registrations = createRegistrySet(); const baseTheme = createBaseTheme(); diff --git a/packages/core/src/api/tecode-module.d.ts b/packages/core/src/api/tecode-module.d.ts index 653c895..314b7b0 100644 --- a/packages/core/src/api/tecode-module.d.ts +++ b/packages/core/src/api/tecode-module.d.ts @@ -8,7 +8,13 @@ * "object"` actually does at runtime (project the registered object's own * enumerable properties onto the module's named exports — `alias.ts` * registers the `Tecode` object itself, whose own properties are these - * nine namespaces). + * ten namespaces). + * + * Every namespace `createTecodeApi` puts on the frozen `Tecode` object + * MUST appear below. A namespace missing here still works at runtime — the + * `loader: "object"` projection does not consult this file — so the only + * symptom is that `import { } from "tecode"` fails to type-check in + * an extension, which no test in this repo would catch. * * **Why this file, here, is enough**: this repo's root `tsconfig.json` sets * no `"include"`, so a single `bunx tsc --noEmit` run from the repo root @@ -32,6 +38,7 @@ declare module "tecode" { import type { + ClipboardNamespace, CommandsNamespace, ConfigNamespace, ContextNamespace, @@ -43,6 +50,7 @@ declare module "tecode" { WorkspaceNamespace, } from "@tecode/api"; + export const clipboard: ClipboardNamespace; export const commands: CommandsNamespace; export const workspace: WorkspaceNamespace; export const window: WindowNamespace; diff --git a/packages/core/src/clipboard/clipboard.test.ts b/packages/core/src/clipboard/clipboard.test.ts new file mode 100644 index 0000000..fe5b421 --- /dev/null +++ b/packages/core/src/clipboard/clipboard.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, test } from "bun:test"; +import { createHostLog } from "../host/errors"; +import { createClipboard } from "./clipboard"; + +describe("createClipboard (Issue #91)", () => { + test("read() starts empty", async () => { + const clipboard = createClipboard(); + expect(await clipboard.read()).toBe(""); + }); + + test("write() then read() round-trips through the internal buffer, with no system writer wired", async () => { + const clipboard = createClipboard(); + await clipboard.write("hello"); + expect(await clipboard.read()).toBe("hello"); + }); + + test("write() calls the injected system writer with the same text when sync is enabled (the default)", async () => { + const clipboard = createClipboard(); + const calls: string[] = []; + clipboard.setSystemWriter((text) => { + calls.push(text); + return true; + }); + + await clipboard.write("copied text"); + + expect(calls).toEqual(["copied text"]); + expect(await clipboard.read()).toBe("copied text"); + }); + + test("setSystemClipboardEnabled(false) updates the internal buffer but never calls the system writer", async () => { + const clipboard = createClipboard(); + let calls = 0; + clipboard.setSystemWriter(() => { + calls++; + return true; + }); + clipboard.setSystemClipboardEnabled(false); + + await clipboard.write("still buffered"); + + expect(calls).toBe(0); + expect(await clipboard.read()).toBe("still buffered"); + }); + + test("setSystemWriter(undefined) clears the writer — write() still resolves and updates the buffer", async () => { + const clipboard = createClipboard(); + clipboard.setSystemWriter(() => true); + clipboard.setSystemWriter(undefined); + + await expect(clipboard.write("x")).resolves.toBeUndefined(); + expect(await clipboard.read()).toBe("x"); + }); + + test("an OSC 52 write that THROWS is logged, swallowed, and write() still resolves with the buffer updated", async () => { + const log = createHostLog(); + const clipboard = createClipboard({ log }); + clipboard.setSystemWriter(() => { + throw new Error("terminal escape sequence rejected"); + }); + + await expect(clipboard.write("payload")).resolves.toBeUndefined(); + + expect(await clipboard.read()).toBe("payload"); + const entries = log.entries(); + expect(entries).toHaveLength(1); + expect(entries[0]!.level).toBe("warning"); + expect(entries[0]!.error.message).toContain("terminal escape sequence rejected"); + }); + + test("an OSC 52 write that returns false (not thrown) is logged as a not-accepted warning, write() still resolves", async () => { + const log = createHostLog(); + const clipboard = createClipboard({ log }); + clipboard.setSystemWriter(() => false); + + await expect(clipboard.write("payload")).resolves.toBeUndefined(); + + expect(await clipboard.read()).toBe("payload"); + const entries = log.entries(); + expect(entries).toHaveLength(1); + expect(entries[0]!.level).toBe("warning"); + expect(entries[0]!.error.message).toContain("not accepted"); + }); + + test("with no `log` supplied, a throwing writer is still swallowed silently — write() never rejects", async () => { + const clipboard = createClipboard(); + clipboard.setSystemWriter(() => { + throw new Error("boom"); + }); + await expect(clipboard.write("x")).resolves.toBeUndefined(); + expect(await clipboard.read()).toBe("x"); + }); + + test("later write()s update the buffer, always reflecting the most recent value", async () => { + const clipboard = createClipboard(); + await clipboard.write("first"); + await clipboard.write("second"); + expect(await clipboard.read()).toBe("second"); + }); +}); diff --git a/packages/core/src/clipboard/clipboard.ts b/packages/core/src/clipboard/clipboard.ts new file mode 100644 index 0000000..e832f75 --- /dev/null +++ b/packages/core/src/clipboard/clipboard.ts @@ -0,0 +1,148 @@ +/** + * `createClipboard`: the implementation behind `tecode.clipboard` (Issue + * #91). Wraps a single in-memory buffer — a thin host-resource seam, the + * same shape `buffer/fileSystem.ts`'s `createFileSystem` wraps + * `node:fs/promises` behind — with a write-through OSC 52 sync to the + * terminal's own system clipboard, injected lazily once the real terminal + * seam (`packages/cli/src/renderShell.tsx`'s `onClipboardWriterReady`) has + * resolved one. + * + * **Why an internal buffer, not a live OSC 52 round-trip**: OSC 52 + * *reading* is not portable — many terminals never implement the query + * form at all, and those that do commonly gate it behind an interactive + * user prompt (`@tecode/api`'s `ClipboardNamespace` TSDoc explains this to + * extension authors too). `read()` below therefore only ever reports this + * buffer's own last-written value; nothing in this module ever attempts an + * OSC 52 *read*. + * + * **Never crashes the process** (matches `fileSystem.ts`'s own contract for + * `watch`): the injected OSC 52 writer is host-provided terminal-escape- + * sequence plumbing with no reliable failure signal — a terminal that + * silently ignores it looks identical, from here, to one that briefly + * failed. A writer that returns `false`, or throws outright, is reported + * through `deps.log` (when supplied) and otherwise swallowed; `write()` + * itself always resolves once the INTERNAL buffer has been updated, + * regardless of what the system-clipboard half did. + */ + +import type { ClipboardNamespace } from "@tecode/api"; +import type { HostError, HostLog } from "../host/errors"; + +/** Render a caught `unknown` value as a message string without risking a + * second throw (matches `fileSystem.ts`'s/`documentManager.ts`'s + * `describeError`). */ +function describeError(err: unknown): string { + try { + if (err instanceof Error) return err.message; + return String(err); + } catch { + return "Unknown error"; + } +} + +/** Dependencies for {@link createClipboard}. Every field is optional — + * `createClipboard()` with no arguments is a complete, working clipboard + * (internal buffer only, no system-clipboard sync until {@link + * Clipboard.setSystemWriter} injects one); `log` only adds visibility into + * OSC 52 write failures that would otherwise be silently swallowed. */ +export interface ClipboardDeps { + /** Structured log for OSC 52 write failures (design.md §14). Omitted + * (the default) swallows these silently — {@link Clipboard.write} still + * never throws or rejects either way. */ + log?: HostLog; +} + +/** + * {@link createClipboard}'s return type: the `ClipboardNamespace` extension + * code sees (`read`/`write`), plus two host-only setters `create.ts`/ + * `main.ts` use to wire this instance up to the real terminal and to the + * live `clipboard.useSystemClipboard` setting — narrowed away when + * `create.ts` assembles the frozen `tecode.clipboard` namespace extensions + * actually receive (matches `stubs.ts`'s `WindowStub`/`LanguagesStub` TSDoc + * on why a factory here returns more than its `@tecode/api` namespace + * shape). + */ +export interface Clipboard extends ClipboardNamespace { + /** + * Inject (or clear, with `undefined`) the OSC 52 write function + * (`packages/cli/src/renderShell.tsx`'s `onClipboardWriterReady`, + * bound to `@opentui/core`'s `CliRenderer.copyToClipboardOSC52`). A + * terminal-render seam that never resolves one (`renderShellHeadless`, + * or any test) leaves system-clipboard sync permanently inert — {@link + * write} still updates the internal buffer either way. + */ + setSystemWriter(write: ((text: string) => boolean) | undefined): void; + /** + * Enable or disable OSC 52 sync without touching the injected writer + * itself — the live backing for `editor-core`'s `clipboard. + * useSystemClipboard` configuration (Issue #91): `index.ts`'s + * `activate` calls this once at startup and again on every live + * `tecode.config.onDidChange` for that key. Defaults to `true` (this + * module's TSDoc), matching that setting's own schema default so a + * caller that never wires config sync at all still gets sync-when- + * possible, not silently-disabled, behavior. + */ + setSystemClipboardEnabled(enabled: boolean): void; +} + +/** + * Build a {@link Clipboard} (Issue #91). `deps.log` is optional — see + * {@link ClipboardDeps}. + */ +export function createClipboard(deps: ClipboardDeps = {}): Clipboard { + let buffer = ""; + let systemWriter: ((text: string) => boolean) | undefined; + // Matches `clipboard.useSystemClipboard`'s own `default: true` schema + // (`editor-core/manifest.ts`) — see `setSystemClipboardEnabled`'s TSDoc. + let systemClipboardEnabled = true; + + function logSafely(err: HostError): void { + if (!deps.log) return; + try { + deps.log.append("warning", err); + } catch { + // Swallowed: reporting a reporting failure has nowhere left to go + // (matches `fileSystem.ts`'s own `logSafely`). + } + } + + async function read(): Promise { + return buffer; + } + + async function write(text: string): Promise { + // The internal buffer is updated UNCONDITIONALLY, before any + // system-clipboard sync is even attempted — every caller (`read()` + // here, and `editor-core`'s own paste handler) must see the new + // value regardless of whether OSC 52 is enabled, wired, or working. + buffer = text; + + if (!systemClipboardEnabled || !systemWriter) return; + + try { + const accepted = systemWriter(text); + if (!accepted) { + logSafely({ + message: "Clipboard: OSC 52 system-clipboard write was not accepted by the terminal.", + }); + } + } catch (cause) { + // Never let an injected writer's failure propagate past this seam + // (this module's TSDoc) — logged, not rethrown, not left as a + // rejection. + logSafely({ + message: `Clipboard: OSC 52 system-clipboard write threw: ${describeError(cause)}`, + }); + } + } + + function setSystemWriter(write: ((text: string) => boolean) | undefined): void { + systemWriter = write; + } + + function setSystemClipboardEnabled(enabled: boolean): void { + systemClipboardEnabled = enabled; + } + + return { read, write, setSystemWriter, setSystemClipboardEnabled }; +} diff --git a/packages/core/src/clipboard/index.ts b/packages/core/src/clipboard/index.ts new file mode 100644 index 0000000..def89c1 --- /dev/null +++ b/packages/core/src/clipboard/index.ts @@ -0,0 +1,4 @@ +// The clipboard domain (Issue #91): an internal buffer with write-through +// OSC 52 sync to the terminal's system clipboard (`clipboard.ts`), backing +// `tecode.clipboard` and `editor-core`'s copy/cut/paste commands. +export { createClipboard, type Clipboard, type ClipboardDeps } from "./clipboard"; diff --git a/packages/core/src/editor/inputRouter.test.ts b/packages/core/src/editor/inputRouter.test.ts index e7a3be3..22bcc24 100644 --- a/packages/core/src/editor/inputRouter.test.ts +++ b/packages/core/src/editor/inputRouter.test.ts @@ -257,3 +257,139 @@ describe("createEditorInputRouter (Task 2.2, Req 4.6, 6.6, design.md §6.1, §8. expect(document.undo()).toBeUndefined(); }); }); + +describe("EditorInputRouter.insertText (Issue #91's paste path, Req 6.6)", () => { + test("no-op when editorTextFocus is falsy", () => { + const document = createTestDocument("hello"); + const session = createFakeSession(document, [cursorAt(0, 0)]); + const context = createContextService(); // editorTextFocus never set + const router = buildRouter({ context, editorSession: session }); + + router.insertText("X"); + expect(document.getLine(0)).toBe("hello"); + expect(session.setStateCallCount()).toBe(0); + }); + + test("no-op when there is no active document", () => { + const session = createFakeSession(undefined, [cursorAt(0, 0)]); + const router = buildRouter({ editorSession: session }); + expect(() => router.insertText("X")).not.toThrow(); + expect(session.setStateCallCount()).toBe(0); + }); + + test("no-op on a readonly document", () => { + const document = createTestDocument("hello", { readonly: true }); + const session = createFakeSession(document, [cursorAt(0, 0)]); + const router = buildRouter({ editorSession: session }); + + router.insertText("X"); + expect(document.getLine(0)).toBe("hello"); + }); + + test("a single cursor: multi-line text lands as ONE document.applyEdits call, not one per line", () => { + // The mutation this test is built to catch: an implementation that + // loops `document.applyEdits(...)` once per line of the pasted text + // (instead of building one `TextEdit[]` batch and calling `applyEdits` + // exactly once) would still leave the BUFFER content correct — the + // assertions below on `getLine` alone would not catch it — but would + // call through `applyEdits` more than once. Wrapping the real + // `CoreDocument.applyEdits` to count invocations is what actually + // proves the "one call" contract (this module's `EditorInputRouter. + // insertText` TSDoc, Req 6.6). + const document = createTestDocument("ac"); + let applyEditsCallCount = 0; + const originalApplyEdits = document.applyEdits.bind(document); + document.applyEdits = (edits, opts) => { + applyEditsCallCount++; + originalApplyEdits(edits, opts); + }; + const session = createFakeSession(document, [cursorAt(0, 1)]); + const router = buildRouter({ editorSession: session }); + + router.insertText("line1\nline2\nline3"); + + expect(applyEditsCallCount).toBe(1); + expect(document.getLine(0)).toBe("aline1"); + expect(document.getLine(1)).toBe("line2"); + expect(document.getLine(2)).toBe("line3c"); + expect(session.currentSelections()).toEqual([cursorAt(2, 5)]); + }); + + test("a multi-line paste across multiple cursors is a SINGLE undo entry", () => { + const document = createTestDocument("a\nc"); + const original = [cursorAt(0, 1), cursorAt(1, 1)]; + const session = createFakeSession(document, original); + const router = buildRouter({ editorSession: session }); + + router.insertText("X\nY"); + // Original text "a\nc" with "X\nY" inserted after 'a' (offset 1) AND + // after 'c' (offset 3, the buffer's end) — both in ONE batch, computed + // against the ORIGINAL (pre-batch) coordinates: "aX\nY\ncX\nY". + expect(document.getLine(0)).toBe("aX"); + expect(document.getLine(1)).toBe("Y"); + expect(document.getLine(2)).toBe("cX"); + expect(document.getLine(3)).toBe("Y"); + + const restoredSelections = document.undo(); + expect(document.getLine(0)).toBe("a"); + expect(document.getLine(1)).toBe("c"); + expect(restoredSelections).toEqual(original); + + // A second undo has nothing left to do — the whole multi-cursor, + // multi-line paste really was ONE undo entry, not one per cursor/line. + expect(document.undo()).toBeUndefined(); + }); + + test("replaces a non-collapsed FORWARD selection and lands the cursor after the inserted text", () => { + const document = createTestDocument("abcdef"); + const start = pos(0, 1); + const end = pos(0, 4); + const selection: Selection = { start, end, anchor: start, active: end }; + const session = createFakeSession(document, [selection]); + const router = buildRouter({ editorSession: session }); + + router.insertText("XY"); + expect(document.getLine(0)).toBe("aXYef"); + expect(session.currentSelections()).toEqual([cursorAt(0, 3)]); + }); + + test("replaces a non-collapsed BACKWARD selection (active at the start) and still lands after the inserted text", () => { + const document = createTestDocument("abcdef"); + const start = pos(0, 1); + const end = pos(0, 4); + // Backward selection: anchor at the far end, active at the near end — + // `range.start`/`range.end` are still `[1, 4)` (Selection extends + // Range), but `active` is `start`, not `end`. This is exactly the case + // `buildInsertTextBatch`'s TSDoc explains: tracking `active` directly + // would land the cursor at the WRONG end of the inserted text. + const selection: Selection = { start, end, anchor: end, active: start }; + const session = createFakeSession(document, [selection]); + const router = buildRouter({ editorSession: session }); + + router.insertText("XY"); + expect(document.getLine(0)).toBe("aXYef"); + expect(session.currentSelections()).toEqual([cursorAt(0, 3)]); + }); + + test("two cursors on the same line both insert and both advance correctly", () => { + const document = createTestDocument("abcdef"); + const session = createFakeSession(document, [cursorAt(0, 2), cursorAt(0, 5)]); + const router = buildRouter({ editorSession: session }); + + router.insertText("Z"); + expect(document.getLine(0)).toBe("abZcdeZf"); + expect(session.currentSelections()).toEqual([cursorAt(0, 3), cursorAt(0, 7)]); + }); + + test("bypasses the single-code-point restriction: a multi-character sequence is not rejected", () => { + // `routeKeyEvent`'s own `classifyKeyEvent`/`isPrintableSequence` would + // reject any `sequence` longer than one code point outright — proving + // `insertText` never goes through that path at all. + const document = createTestDocument(""); + const session = createFakeSession(document, [cursorAt(0, 0)]); + const router = buildRouter({ editorSession: session }); + + router.insertText("hello world"); + expect(document.getLine(0)).toBe("hello world"); + }); +}); diff --git a/packages/core/src/editor/inputRouter.ts b/packages/core/src/editor/inputRouter.ts index 5fcc2ab..08d2b21 100644 --- a/packages/core/src/editor/inputRouter.ts +++ b/packages/core/src/editor/inputRouter.ts @@ -203,6 +203,67 @@ function buildEditBatch( return { edits, newSelections }; } +/** Build the single `TextEdit` a call to {@link createEditorInputRouter}'s + * `insertText` produces for one selection (Issue #91's paste path): replace + * the selection's whole range (a collapsed cursor's zero-width `[active, + * active)`, or a real selection's `[start, end)`) with `text` wholesale — + * deliberately the SAME "insert, replacing any active selection" shape + * `editor-core`'s `editing.ts` uses for `buildInsertEdit` (Tab/Enter), just + * duplicated here rather than imported: `editor-core` is a `builtin` + * extension and this module lives in `@tecode/core` — the ESLint layering + * rule (`no-restricted-imports`) only allows the reverse direction. Unlike + * {@link buildEditForCursor}'s `"insert"` case (a single code point at a + * collapsed cursor, `classifyKeyEvent`'s domain), `text` here is arbitrary — + * one or many lines, from a paste — which is exactly why `insertText` is a + * SEPARATE public method rather than a new `EditOp` value: routing it + * through `classifyKeyEvent`/`isPrintableSequence` would reject anything + * longer than one code point outright (this module's TSDoc's "Scope"). */ +function buildInsertTextEdit(selection: Selection, text: string): TextEdit { + return { range: { start: selection.start, end: selection.end }, newText: text }; +} + +/** + * Build the full multi-cursor batch {@link createEditorInputRouter}'s + * `insertText` applies for one paste (Issue #91, Req 6.6's "multi-cursor + * batching" — same one-`applyEdits`-call contract this module's TSDoc + * states for a keystroke): dedupe cursors sharing one `active` point + * (matching {@link buildEditBatch}'s own first step), build one + * {@link buildInsertTextEdit} per surviving selection, drop any that + * overlaps one already kept, and compute each selection's resulting + * collapsed cursor. + * + * **Tracks each selection's OWN edit's `range.end`, not its `active`** — + * unlike this module's private `buildEditBatch` above (whose selections are + * always collapsed pre-Task-2.3, so `active` and `range.end` are always the + * same point, making the distinction moot there): a PASTE can replace a + * genuine, possibly-BACKWARD selection, whose `active` can be the far + * (leftward/upward) end of `range` rather than `range.end` — tracking + * `active` directly would land the post-paste cursor at the wrong end of a + * backward selection's now-inserted text. `editor-core`'s `editing.ts`'s + * real `buildEditBatch` (Task 2.3) already solves this identical problem + * the identical way; this is that same "own edit's `range.end`, else the + * original `active`, run through `transformPosition`" shape, independently + * implemented here since `editing.ts` cannot be imported (this function's + * sibling {@link buildInsertTextEdit} TSDoc's layering note). + */ +function buildInsertTextBatch( + selections: readonly Selection[], + text: string, +): { edits: TextEdit[]; newSelections: Selection[] } { + const deduped = dedupeByActive(selections); + const rawEdits = deduped.map((selection) => buildInsertTextEdit(selection, text)); + const edits = dropOverlapping(rawEdits); + const survivingSet = new Set(edits); + + const newPositions = deduped.map((selection, i) => { + const own = rawEdits[i]!; + const trackPoint = survivingSet.has(own) ? own.range.end : selection.active; + return transformPosition(trackPoint, edits); + }); + + return { edits, newSelections: toMergedSelections(newPositions) }; +} + /** Dependencies for {@link createEditorInputRouter}. Narrowed to `Pick`s of * the real services (matching `keymap/chords.ts`'s `ChordStateMachineDeps` * pattern) so tests can inject minimal fakes. */ @@ -232,6 +293,28 @@ export interface EditorInputRouter { * `chords.ts`'s/`bindingTable.ts`'s own guarded-boundary discipline). */ routeKeyEvent(event: KeyEventLike): boolean; + /** + * Insert `text` at every cursor, replacing each selection's range if it + * has one (Issue #91's paste path, design.md §6.1/§8.3's "focused + * component" destination extended to bracketed-paste terminal input, not + * just single-keystroke fallthrough). ALWAYS applied as exactly one + * `document.applyEdits(...)` call across every selection ({@link + * buildInsertTextBatch}'s TSDoc, Req 6.6) — never one call per line or + * per cursor — so a multi-line paste is a single undo step, matching + * `routeKeyEvent`'s own one-`applyEdits`-per-invocation contract. + * Deliberately bypasses `classifyKeyEvent`/`isPrintableSequence`'s + * single-code-point restriction entirely: `text` is not run through + * either at all, so an arbitrary-length (and multi-line) paste is never + * rejected the way a `KeyEventLike` with a multi-character `sequence` + * would be. + * + * No-ops exactly like `routeKeyEvent` does (same guards, same order): no + * `editorTextFocus`, no active document, or a readonly document (Req + * 5.5) — `applyEdits` is skipped entirely and `text` never reaches the + * buffer. Never throws (this interface's own "guarded boundary" + * discipline, matching `routeKeyEvent`). + */ + insertText(text: string): void; } /** Build an {@link EditorInputRouter} (Task 2.2). */ @@ -276,5 +359,40 @@ export function createEditorInputRouter(deps: EditorInputRouterDeps): EditorInpu } } - return { routeKeyEvent }; + /** {@link EditorInputRouter.insertText} — see that TSDoc for the + * contract. Same guard order/shape as {@link routeKeyEvent} above, minus + * the `classifyKeyEvent` step it deliberately bypasses. */ + function insertText(text: string): void { + try { + if (!context.get("editorTextFocus")) return; + + const document = editorSession.getActiveDocument(); + if (!document) return; + + // Same read-only guard as `routeKeyEvent` (Req 5.5) — no cursor + // movement either, for the same "must not desync from an edit + // `applyEdits` would silently drop" reason. + if (document.readonly) return; + + const state = editorSession.getState(document.uri); + const { edits, newSelections } = buildInsertTextBatch(state.selections, text); + + if (edits.length > 0) { + // ONE `applyEdits` call for the whole batch (this method's TSDoc, + // Req 6.6) — every selection's replacement is one `TextEdit` in + // `edits`, so a multi-line/multi-cursor paste is exactly one + // atomic buffer mutation and one `UndoStack` entry, never a loop + // that calls `applyEdits` once per line or per cursor. + document.applyEdits(edits, { + selectionsBefore: state.selections, + selectionsAfter: newSelections, + }); + } + editorSession.setState(document.uri, { ...state, selections: newSelections }); + } catch { + // Never throw past this seam — see `routeKeyEvent`'s own catch. + } + } + + return { routeKeyEvent, insertText }; } diff --git a/packages/core/src/editor/positionTransform.test.ts b/packages/core/src/editor/positionTransform.test.ts index 1eb2788..b8de7b2 100644 --- a/packages/core/src/editor/positionTransform.test.ts +++ b/packages/core/src/editor/positionTransform.test.ts @@ -95,6 +95,46 @@ describe("transformPosition (Task 2.2, editor/positionTransform.ts)", () => { expect(transformPosition(pos(3, 3), [])).toEqual(pos(3, 3)); }); + test("Issue #91: a multi-line insert's own cursor lands on the tail line, at the tail line's length — not the original column plus the tail length", () => { + // A collapsed insert of multi-line text (a paste) at (0,1): before this + // fix, this function unconditionally added `range.start.character` + // (here, 1) to the tail line's length, landing one character too far + // right whenever the edit's own start column was not 0 — this is that + // exact regression, pinned (`editor/inputRouter.ts`'s `insertText`, + // `positionTransform.ts`'s TSDoc "Issue #91" note). + const result = transformPosition(pos(0, 1), [edit(0, 1, 0, 1, "line1\nline2\nline3")]); + expect(result).toEqual(pos(2, 5)); // "line3".length, not 1 + "line3".length + }); + + test("Issue #91: a multi-line insert earlier on the same line RESETS a later position's character, not merely shifts it", () => { + const result = transformPosition(pos(0, 10), [edit(0, 2, 0, 2, "AA\nBBB")]); + // Line: +1 (one inserted newline). Character: the target position was + // 8 characters into the "remainder" after the edit's original end + // (10 - 2); that remainder now follows "BBB" on the new tail line — + // 3 + 8 = 11 — NOT `range.start.character(2) + "BBB".length(3) + + // (10-2)` or any formula that keeps referencing the ORIGINAL line's + // column space past the multi-line edit. + expect(result).toEqual(pos(1, 11)); + }); + + test("Issue #91: two same-line preceding edits, one single-line and one multi-line — the CLOSER multi-line edit resets the column, the further single-line edit no longer contributes", () => { + // Two independent cursors' own edits on the same original line: one at + // column 2 inserting a single character "X" (no newline), another at + // column 5 inserting the two-line "AA\nBB" — both strictly before the + // target position (10), closer-to-farther: column 5, then column 2. + const edits = [edit(0, 2, 0, 2, "X"), edit(0, 5, 0, 5, "AA\nBB")]; + const forward = transformPosition(pos(0, 10), edits); + const reversed = transformPosition(pos(0, 10), [...edits].reverse()); + // Line: +1 (only the column-5 edit inserted a newline). Character: + // walked closest-first, the column-5 multi-line edit is hit FIRST and + // resets the column ("BB".length(2) + the 5 characters of original + // remainder past column 5 = 7); the column-2 single-character edit, + // though also "preceding", no longer affects character at all once a + // closer multi-line edit has already reset the origin. + expect(forward).toEqual(pos(1, 7)); + expect(reversed).toEqual(forward); + }); + test("a clamped position still shifts by a preceding edit, regardless of edit order", () => { // Position (0,6) sits strictly inside the replacement (0,4)-(0,10), so // it clamps to that edit's start (0,4) — which the insert of "x" at diff --git a/packages/core/src/editor/positionTransform.ts b/packages/core/src/editor/positionTransform.ts index ad6b5fc..f5ae5c2 100644 --- a/packages/core/src/editor/positionTransform.ts +++ b/packages/core/src/editor/positionTransform.ts @@ -90,31 +90,67 @@ export function transformPosition(position: Position, edits: readonly TextEdit[] ); const anchor = containingEdit ? containingEdit.range.start : position; let line = anchor.line; - let character = anchor.character; - for (const edit of edits) { - if (edit === containingEdit) continue; // contributes nothing beyond the clamp - const { range, newText } = edit; - const insertedLines = splitIntoLines(newText); - const insertedLineCount = insertedLines.length - 1; + // Every edit "before" the anchor (bucket 1 above), regardless of `edits`' + // own order — `line` is a pure sum, exactly as this module's TSDoc + // describes: each preceding edit contributes its own `netLineDelta` + // independently of every other one. + const preceding = edits.filter( + (edit) => edit !== containingEdit && comparePositions(anchor, edit.range.end) >= 0, + ); + for (const { range, newText } of preceding) { + const insertedLineCount = splitIntoLines(newText).length - 1; const removedLineCount = range.end.line - range.start.line; - const netLineDelta = insertedLineCount - removedLineCount; + line += insertedLineCount - removedLineCount; + } - if (comparePositions(anchor, range.end) >= 0) { - // Bucket 1 (this module's TSDoc): the anchor is at-or-after this - // edit's end, in ORIGINAL coordinates. - if (anchor.line === range.end.line) { - const lastInsertedLineLength = insertedLines[insertedLines.length - 1]!.length; - const newEndCharacter = - range.start.character + (insertedLineCount === 0 ? newText.length : lastInsertedLineLength); - character = character - range.end.character + newEndCharacter; - } - line += netLineDelta; + // `character`, unlike `line`, is NOT a pure sum once more than one + // preceding edit shares the anchor's ORIGINAL line — Issue #91's + // `EditorInputRouter.insertText` is this codebase's first caller to ever + // hand `transformPosition` a genuinely multi-line `newText` (every edit + // shape `routeKeyEvent`'s own `buildEditBatch` builds — a single typed + // character, or an always-`newText: ""` backspace/delete — is single-line, + // so this branch was previously unexercised here). A SINGLE-line preceding + // edit's character contribution is a fixed, order-independent delta + // (`newEndCharacter - range.end.character`, added on top of whatever + // `character` already is) — that's what makes the reassignment loop below + // correct regardless of processing order for that case, matching this + // module's own TSDoc reasoning. A MULTI-line preceding edit is different: + // it does not shift the anchor's column, it RESETS it — the anchor's line + // no longer starts at any original column at all once at least one + // newline has been inserted before it, it starts fresh at whatever column + // that edit's OWN last inserted line ends at. So same-line preceding + // edits must be walked CLOSEST-TO-THE-ANCHOR FIRST (descending + // `range.end`): every single-line edit encountered before the first + // multi-line one accumulates the usual per-edit delta, and the moment a + // multi-line edit is reached, `character` is reset to that edit's own + // tail-line length and the walk stops — any edit further left (closer to + // the original line's start) no longer affects `character` at all (it + // already contributed to `line`, in the sum above, since its own inserted + // newlines happened before this position either way). This is + // `@tecode/builtin/editor-core`'s `positionTransform.ts` — a module that + // cannot import this one (the `builtin`/`core` ESLint layering rule) and + // so carries its own copy — documents as a deliberate fix over an + // earlier version of THIS function; ported back here now that this + // module has its own multi-line caller. When every same-line preceding + // edit is single-line (every case exercised before this task), this + // reduces to exactly the previous per-edit-in-any-order accumulation, + // since plain addition commutes — `positionTransform.test.ts`'s existing + // cases are unchanged by this rewrite. + let character = anchor.character; + const sameLine = preceding + .filter((edit) => edit.range.end.line === anchor.line) + .sort((a, b) => comparePositions(b.range.end, a.range.end)); + for (const { range, newText } of sameLine) { + const insertedLines = splitIntoLines(newText); + const insertedLineCount = insertedLines.length - 1; + if (insertedLineCount === 0) { + character = character - range.end.character + range.start.character + newText.length; + continue; } - - // Bucket 2: this edit starts at-or-after the anchor — unaffected. - // (Non-overlap guarantees no OTHER edit can strictly contain the - // anchor once the containing edit has been factored out.) + const lastInsertedLineLength = insertedLines[insertedLines.length - 1]!.length; + character = character - range.end.character + lastInsertedLineLength; + break; } return { line, character }; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 7fc61df..45ff637 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -327,6 +327,7 @@ export { } from "./config/index"; export { createBaseTheme, + createClipboardStub, createEditorStub, createFindStub, createLanguagesStub, @@ -350,3 +351,4 @@ export { type EditorInputRouterDeps, type LineReader, } from "./editor/index"; +export { createClipboard, type Clipboard, type ClipboardDeps } from "./clipboard/index"; diff --git a/samples/settings.json b/samples/settings.json index 81f8a2b..096fe0c 100644 --- a/samples/settings.json +++ b/samples/settings.json @@ -42,7 +42,15 @@ // Show hidden (dot-prefixed) and .gitignore-ignored files in the // explorer sidebar. - "explorer.showHidden": false + "explorer.showHidden": false, + + // --- editor-core (packages/builtin/editor-core/manifest.ts) --- + + // Sync copy/cut to the terminal's system clipboard via OSC 52, when the + // terminal supports it (Issue #91). ctrl+x/ctrl+v are bound to cut/paste; + // copy has no default keybinding (still reachable via the command + // palette) — see editor-core/manifest.ts's TSDoc for why. + "clipboard.useSystemClipboard": true // Req 9.5 also names "editor.wordWrap" and "files.autoSave" among the // MVP settings. As of this release, NEITHER is implemented: no