From 0aa6be83bd8900f16153b4001ab302bbf714ad54 Mon Sep 17 00:00:00 2001 From: Mitsuki Fukunaga Date: Tue, 10 Nov 2026 08:10:35 +0900 Subject: [PATCH 1/9] refactor(paste): extract text decisions and pending paste operation metadata Split table/URL/list/rich paste into pure decisions returning a replacement range plus render, and add the immutable pending-operation StateField (mapping, conflict cancellation, history/reseed cancel, delegated-paste supersession). Not yet wired into production. --- src/webview/cm/paste/html-table-paste.ts | 27 +- src/webview/cm/paste/list-reindent.ts | 95 +++--- src/webview/cm/paste/paste-operation.ts | 81 +++++ src/webview/cm/paste/rich-html-paste.ts | 47 ++- src/webview/cm/paste/url-link-paste.ts | 48 +-- test/webview/helpers/clipboard-double.ts | 25 +- .../webview/paste/cm-html-table-paste.test.ts | 18 ++ .../paste/cm-list-reindent-paste.test.ts | 16 +- test/webview/paste/cm-paste-operation.test.ts | 293 ++++++++++++++++++ test/webview/paste/cm-paste-url-link.test.ts | 18 ++ .../paste/cm-rich-html-image-paste.test.ts | 21 +- test/webview/paste/cm-rich-html-paste.test.ts | 43 ++- 12 files changed, 638 insertions(+), 94 deletions(-) create mode 100644 src/webview/cm/paste/paste-operation.ts create mode 100644 test/webview/paste/cm-paste-operation.test.ts diff --git a/src/webview/cm/paste/html-table-paste.ts b/src/webview/cm/paste/html-table-paste.ts index d3686930..ca1b0ef5 100644 --- a/src/webview/cm/paste/html-table-paste.ts +++ b/src/webview/cm/paste/html-table-paste.ts @@ -10,7 +10,7 @@ // dispatch → updateListener → edit-sync → host write-lock → applyEdit settlement // pipeline (no raw write path). -import { type Extension, Prec } from "@codemirror/state"; +import { type EditorState, type Extension, Prec } from "@codemirror/state"; import { EditorView } from "@codemirror/view"; import { htmlTableToGfm } from "./html-table-to-gfm.js"; @@ -36,6 +36,21 @@ function blockSuffix(after: string): string { return after.startsWith("\n") ? "\n" : "\n\n"; } +/** Capture conversion once; frame it against the mapped replacement range. */ +export function decideTablePaste(state: EditorState, html: string) { + const gfm = htmlTableToGfm(html); + if (gfm === null) { + return null; + } + const { from, to } = state.selection.main; + return { + from, + to, + render: (live: EditorState, start: number, end: number) => + blockPrefix(live.doc.sliceString(0, start)) + gfm + blockSuffix(live.doc.sliceString(end)), + }; +} + export function htmlTablePaste(opts: { canWrite: () => boolean }): Extension { return Prec.high( EditorView.domEventHandlers({ @@ -44,8 +59,8 @@ export function htmlTablePaste(opts: { canWrite: () => boolean }): Extension { if (!html) { return false; // no HTML flavour → let normal paste handlers run } - const gfm = htmlTableToGfm(html); - if (gfm === null) { + const decision = decideTablePaste(view.state, html); + if (decision === null) { return false; // not a convertible table → defer to the other handlers } // preventDefault ONLY here, AFTER the null-check. This is intentionally the @@ -62,10 +77,8 @@ export function htmlTablePaste(opts: { canWrite: () => boolean }): Extension { // table under multi-cursor is degenerate — acceptable). The handler runs // synchronously inside the live DOM event, so the view is always alive at // dispatch time (unlike imagePaste's async FileReader path). - const { from, to } = view.state.selection.main; - const before = view.state.doc.sliceString(0, from); - const after = view.state.doc.sliceString(to); - const insert = blockPrefix(before) + gfm + blockSuffix(after); + const { from, to } = decision; + const insert = decision.render(view.state, from, to); view.dispatch({ changes: { from, to, insert }, selection: { anchor: from + insert.length }, diff --git a/src/webview/cm/paste/list-reindent.ts b/src/webview/cm/paste/list-reindent.ts index 5adb4978..8a7128e2 100644 --- a/src/webview/cm/paste/list-reindent.ts +++ b/src/webview/cm/paste/list-reindent.ts @@ -13,7 +13,7 @@ // DEFERRED (inserted unchanged), because re-basing those cases could corrupt // the Markdown. Re-basing around a preserved fence is future work. -import { type Extension, Prec } from "@codemirror/state"; +import { type EditorState, type Extension, Prec } from "@codemirror/state"; import { EditorView } from "@codemirror/view"; import { columnAt } from "../list/list-geometry.js"; @@ -168,50 +168,63 @@ function prefixIsSpacesOnly(prefix: string): boolean { return /^ *$/.test(prefix); } +/** The leading whitespace belongs to this replacement, not to its caller. */ +export function decideListPaste(state: EditorState, text: string) { + // Multi-line only. Accept any of \n, \r\n, or lone \r as a line separator + // so the handler reaches the (CR-normalising) transform for every + // real-world clipboard; a single-line paste defers. + if (!text || (!text.includes("\n") && !text.includes("\r"))) { + return null; + } + const { from, empty } = state.selection.main; + // Empty selection only: a non-empty (possibly reverse) selection has an + // ambiguous caret and would make the [line.from, from) replace cross + // selected text. Defer to default paste. + if (!empty) { + return null; + } + const line = state.doc.lineAt(from); + // Only paste-at-line-start is safe: a non-spaces prefix would glue a + // pasted marker mid-line, and a tab prefix would be rewritten to spaces. + if (!prefixIsSpacesOnly(state.doc.sliceString(line.from, from))) { + return null; + } + // The rest of the caret line must ALSO be blank. Without this, a caret + // resting in the leading indentation of a NON-blank list line (e.g. click + // / smart-Home before ` - existing`'s marker) still passes the prefix + // and listItemAt gates, and the prefix-swallow would strip that line's + // indentation — silently de-indenting / gluing pre-existing content the + // user never pasted. The feature targets a caret at the blank end of a + // line inside a list. (A spaces-only remainder is allowed — trim() empty.) + if (state.doc.sliceString(from, line.to).trim() !== "") { + return null; + } + // Must be an existing list context (spec), and never inside code. + if (caretInCode(state, from) || listItemAt(state, from) === null) { + return null; + } + const destColumn = columnAt(state, from); + const rebased = reindentPastedList(text, destColumn, state.tabSize); + if (rebased === null) { + return null; // not a clean list fragment / ambiguous → default paste + } + return { + from: line.from, + to: from, + render: (live: EditorState, _start: number, end: number) => + reindentPastedList(text, columnAt(live, end), live.tabSize) ?? rebased, + }; +} + export function listReindentPaste(opts: { canWrite: () => boolean }): Extension { return Prec.high( EditorView.domEventHandlers({ paste: (event, view) => { const text = event.clipboardData?.getData("text/plain"); - // Multi-line only. Accept any of \n, \r\n, or lone \r as a line separator - // so the handler reaches the (CR-normalising) transform for every - // real-world clipboard; a single-line paste defers. - if (!text || (!text.includes("\n") && !text.includes("\r"))) { - return false; - } - const { state } = view; - const { from, empty } = state.selection.main; - // Empty selection only: a non-empty (possibly reverse) selection has an - // ambiguous caret and would make the [line.from, from) replace cross - // selected text. Defer to default paste. - if (!empty) { - return false; - } - const line = state.doc.lineAt(from); - // Only paste-at-line-start is safe: a non-spaces prefix would glue a - // pasted marker mid-line, and a tab prefix would be rewritten to spaces. - if (!prefixIsSpacesOnly(state.doc.sliceString(line.from, from))) { + const decision = text ? decideListPaste(view.state, text) : null; + if (decision === null) { return false; } - // The rest of the caret line must ALSO be blank. Without this, a caret - // resting in the leading indentation of a NON-blank list line (e.g. click - // / smart-Home before ` - existing`'s marker) still passes the prefix - // and listItemAt gates, and the prefix-swallow would strip that line's - // indentation — silently de-indenting / gluing pre-existing content the - // user never pasted. The feature targets a caret at the blank end of a - // line inside a list. (A spaces-only remainder is allowed — trim() empty.) - if (state.doc.sliceString(from, line.to).trim() !== "") { - return false; - } - // Must be an existing list context (spec), and never inside code. - if (caretInCode(state, from) || listItemAt(state, from) === null) { - return false; - } - const destColumn = columnAt(state, from); - const rebased = reindentPastedList(text, destColumn, state.tabSize); - if (rebased === null) { - return false; // not a clean list fragment / ambiguous → default paste - } // preventDefault ONLY here, AFTER committing to the re-base (mirrors // htmlTablePaste): moving it earlier would swallow non-qualifying pastes. event.preventDefault(); @@ -222,9 +235,11 @@ export function listReindentPaste(opts: { canWrite: () => boolean }): Extension } // Replace from the LINE START (swallowing the spaces prefix) so the // transform's absolute indentation is not double-counted with the prefix. + const { from, to } = decision; + const insert = decision.render(view.state, from, to); view.dispatch({ - changes: { from: line.from, to: from, insert: rebased }, - selection: { anchor: line.from + rebased.length }, + changes: { from, to, insert }, + selection: { anchor: from + insert.length }, scrollIntoView: true, userEvent: "input.paste", }); diff --git a/src/webview/cm/paste/paste-operation.ts b/src/webview/cm/paste/paste-operation.ts new file mode 100644 index 00000000..5fd597b5 --- /dev/null +++ b/src/webview/cm/paste/paste-operation.ts @@ -0,0 +1,81 @@ +// Pending paste metadata only. Async resources belong to the coordinator. +import { Annotation, MapMode, StateEffect, StateField } from "@codemirror/state"; + +import { hostDocumentReseed } from "../host-reseed.js"; + +export interface PendingPasteOperation { + readonly id: string; + readonly from: number; + readonly to: number; + readonly selectionMoved: boolean; +} + +export const completingPasteOperation = Annotation.define(); + +export const registerPasteOperation = StateEffect.define(); + +function rangesConflict(a: { from: number; to: number }, b: { from: number; to: number }): boolean { + if (a.from === a.to) { + return a.from >= b.from && a.from <= b.to; + } + if (b.from === b.to) { + return b.from >= a.from && b.from <= a.to; + } + return a.from < b.to && b.from < a.to; +} + +export const pendingPasteOperations = StateField.define({ + create: () => [], + update(value, tr) { + if (tr.annotation(hostDocumentReseed) && tr.docChanged) { + return []; + } + if (tr.isUserEvent("undo") || tr.isUserEvent("redo")) { + return []; + } + const completingId = tr.annotation(completingPasteOperation); + let next = value + .filter((target) => { + if (tr.docChanged && tr.isUserEvent("input.paste") && target.id !== completingId) { + let conflict = false; + tr.changes.iterChanges((from, to) => { + if (rangesConflict(target, { from, to })) { + conflict = true; + } + }); + if (conflict) { + return false; + } + } + if (target.from === target.to) { + return tr.changes.mapPos(target.from, 1, MapMode.TrackDel) !== null; + } + let overwritten = false; + if (target.from !== target.to) { + tr.changes.iterChanges((from, to) => { + if (from < target.to && to > target.from) { + overwritten = true; + } + }); + } + return !overwritten; + }) + .map((target) => { + const from = tr.changes.mapPos(target.from, 1); + const to = target.from === target.to ? from : tr.changes.mapPos(target.to, -1); + const selectionMoved = + target.selectionMoved || + (!!tr.selection && !tr.newSelection.eq(tr.startState.selection.map(tr.changes))); + return { ...target, from, to, selectionMoved }; + }); + for (const effect of tr.effects) { + if (effect.is(registerPasteOperation)) { + next = [ + ...next.filter((target) => !rangesConflict(target, effect.value)), + { ...effect.value }, + ]; + } + } + return next; + }, +}); diff --git a/src/webview/cm/paste/rich-html-paste.ts b/src/webview/cm/paste/rich-html-paste.ts index 3d2e604d..dc565fcb 100644 --- a/src/webview/cm/paste/rich-html-paste.ts +++ b/src/webview/cm/paste/rich-html-paste.ts @@ -263,6 +263,34 @@ function canDeferWithoutDataLoss(event: ClipboardEvent, from: number, to: number return !pasteWouldBeDropped(event) || from === to; } +/** Freeze conversion bytes, but decide inline placement against live neighbours. */ +export function decideRichPaste( + state: EditorState, + html: string, + plainFallback = "", + forceBlock = false +) { + const converted = htmlToMarkdown(html); + if (converted === null) { + return null; + } + const md = converted.markdown; + const { from, to } = state.selection.main; + return { + from, + to, + emittedMarkdownSyntax: converted.emittedMarkdownSyntax, + render: (live: EditorState, start: number, end: number) => { + if (caretInCode(live, start) || caretInCode(live, end)) { + return plainFallback; + } + return !forceBlock && insertsInline(live, start, end, md) + ? md + : blockPrefix(live.doc.sliceString(0, start)) + md + blockSuffix(live.doc.sliceString(end)); + }, + }; +} + export function richHtmlPaste(opts: { canWrite: () => boolean }): Extension { return Prec.high( EditorView.domEventHandlers({ @@ -303,7 +331,15 @@ export function richHtmlPaste(opts: { canWrite: () => boolean }): Extension { event.preventDefault(); return true; } - const converted = htmlToMarkdown(html); + const imageRidesAlong = hasImageFileItem(event); + const converted = decideRichPaste( + view.state, + html, + event.clipboardData?.getData("text/plain") || + event.clipboardData?.getData("text/uri-list") || + "", + imageRidesAlong + ); if (converted === null) { // Nothing convertible (whitespace-only, cap breached, parse error) → the // clipboard's own bytes are the best available paste. Same requirement as @@ -365,7 +401,6 @@ export function richHtmlPaste(opts: { canWrite: () => boolean }): Extension { // EditorView.editable (editor.ts's `editableComp`) are reconfigured from // the same `canWrite` wire value that drives opts.canWrite(), so the two // cannot diverge — the same invariant html-table-paste.ts relies on. - const imageRidesAlong = hasImageFileItem(event); if (!converted.emittedMarkdownSyntax && hasPlainFallback(event) && !imageRidesAlong) { return false; } @@ -384,7 +419,6 @@ export function richHtmlPaste(opts: { canWrite: () => boolean }): Extension { // ordinary history, a property of ANY pending image — this path is a new // way to reach it. Pinned in cm-rich-html-image-paste.test.ts; closing it // is left to the PASTE-03 clipboard-arbitration work. - const md = converted.markdown; if (!opts.canWrite()) { event.preventDefault(); return true; // read-only: swallow, no fallback insert (mirrors siblings) @@ -396,12 +430,7 @@ export function richHtmlPaste(opts: { canWrite: () => boolean }): Extension { // plain paste — bold text pasted into a sentence must not split it. Anything // else gets blank-line separators so it stands as its own block, and so does // prose with an image riding along: the image lands after it, on its own line. - const insert = - !imageRidesAlong && insertsInline(view.state, from, to, md) - ? md - : blockPrefix(view.state.doc.sliceString(0, from)) + - md + - blockSuffix(view.state.doc.sliceString(to)); + const insert = converted.render(view.state, from, to); view.dispatch({ changes: { from, to, insert }, selection: { anchor: from + insert.length }, diff --git a/src/webview/cm/paste/url-link-paste.ts b/src/webview/cm/paste/url-link-paste.ts index db35d495..accfd7a3 100644 --- a/src/webview/cm/paste/url-link-paste.ts +++ b/src/webview/cm/paste/url-link-paste.ts @@ -19,7 +19,7 @@ import { markdownLanguage } from "@codemirror/lang-markdown"; import { ensureSyntaxTree } from "@codemirror/language"; -import { type Extension, Prec } from "@codemirror/state"; +import { type EditorState, type Extension, Prec } from "@codemirror/state"; import { EditorView } from "@codemirror/view"; import { type AllowlistedUrl, isAllowedUrl } from "../../../markdown/url-allowlist.js"; @@ -140,33 +140,33 @@ function insertLinkOverSelection(view: EditorView, url: AllowlistedUrl): void { }); } +/** Capture the URL and label range; rendering does not retain the view. */ +export function decideUrlPaste(view: EditorView, text: string) { + const { state } = view; + const { from, to, empty } = state.selection.main; + if (empty || state.doc.lineAt(from).number !== state.doc.lineAt(to).number) { + return null; + } + const url = detectPasteLinkUrl(text); + if (url === null || !selectionIsPlainText(view, from, to)) { + return null; + } + return { + from, + to, + url, + render: (live: EditorState, start: number, end: number) => + `[${live.doc.sliceString(start, end)}](${linkDestination(url)})`, + }; +} + export function pasteUrlOverSelection(opts: { canWrite: () => boolean }): Extension { return Prec.high( EditorView.domEventHandlers({ paste: (event, view) => { - const { main } = view.state.selection; - // No selection → nothing to wrap; defer to plain paste / other handlers. - if (main.empty) { - return false; - } - // Single-line only: a multi-line selection is a block operation, not a - // link label. Defer to plain paste. - const startLine = view.state.doc.lineAt(main.from).number; - const endLine = view.state.doc.lineAt(main.to).number; - if (startLine !== endLine) { - return false; - } const text = event.clipboardData?.getData("text/plain"); - if (!text) { - return false; - } - const url = detectPasteLinkUrl(text); - if (url === null) { - return false; // not a bare http(s) URL → normal paste - } - // Don't wrap inside code / an existing link / emphasis / raw HTML — - // mirrors the built-in pasteURLAsLink guard we replaced. - if (!selectionIsPlainText(view, main.from, main.to)) { + const decision = text ? decideUrlPaste(view, text) : null; + if (decision === null) { return false; } // preventDefault ONLY here, AFTER we commit to wrapping (mirrors @@ -177,7 +177,7 @@ export function pasteUrlOverSelection(opts: { canWrite: () => boolean }): Extens if (!opts.canWrite()) { return true; } - insertLinkOverSelection(view, url); + insertLinkOverSelection(view, decision.url); return true; }, }) diff --git a/test/webview/helpers/clipboard-double.ts b/test/webview/helpers/clipboard-double.ts index 0a794047..db3afa9e 100644 --- a/test/webview/helpers/clipboard-double.ts +++ b/test/webview/helpers/clipboard-double.ts @@ -60,17 +60,20 @@ export function makeClipboardData(data: ClipboardFlavours): { type, getAsFile: () => null, })); - const fileItems = (data.files ?? []).map((spec) => ({ - kind: "file" as const, - type: spec.type, - // `"file" in spec`, not `spec.file === undefined`: the latter cannot tell an - // omitted key from an explicit `file: undefined`, which is one of the two - // no-File shapes this helper exists to be able to construct. - // Non-empty content deliberately: imagePaste skips a zero-byte file (`file.size - // === 0`), so a `new File([""], …)` default would make every ingestion test - // pass the membership predicate and then silently do nothing. - getAsFile: () => ("file" in spec ? spec.file : new File(["x"], "f", { type: spec.type })), - })); + const fileItems = (data.files ?? []).map((spec) => { + const file = "file" in spec ? spec.file : new File(["x"], "f", { type: spec.type }); + return { + kind: "file" as const, + type: spec.type, + // `"file" in spec`, not `spec.file === undefined`: the latter cannot tell an + // omitted key from an explicit `file: undefined`, which is one of the two + // no-File shapes this helper exists to be able to construct. + // Non-empty content deliberately: imagePaste skips a zero-byte file (`file.size + // === 0`), so a `new File([""], …)` default would make every ingestion test + // pass the membership predicate and then silently do nothing. + getAsFile: () => file, + }; + }); const files = fileItems.map((item) => item.getAsFile()).filter((f): f is File => !!f); return { diff --git a/test/webview/paste/cm-html-table-paste.test.ts b/test/webview/paste/cm-html-table-paste.test.ts index eef9b502..efc383e7 100644 --- a/test/webview/paste/cm-html-table-paste.test.ts +++ b/test/webview/paste/cm-html-table-paste.test.ts @@ -3,6 +3,7 @@ import { EditorState } from "@codemirror/state"; import { EditorView } from "@codemirror/view"; import { describe, expect, it } from "vitest"; +import { decideTablePaste } from "../../../src/webview/cm/paste/html-table-paste.js"; import { htmlTablePaste, htmlTableToGfm } from "../../../src/webview/cm/paste/index.js"; describe("htmlTableToGfm — structure", () => { @@ -460,3 +461,20 @@ describe("htmlTablePaste — handler", () => { view.destroy(); }); }); + +it("table decision preserves framing and surrounding prose", () => { + const state = EditorState.create({ + doc: "beforeTARGETafter", + selection: { anchor: 6, head: 12 }, + }); + const decision = decideTablePaste(state, "
A
"); + expect(decision).not.toBeNull(); + const { from, to } = decision!; + expect({ from, to }).toEqual({ from: 6, to: 12 }); + const insert = decision!.render(state, from, to); + expect(state.update({ changes: { from, to, insert } }).state.doc.toString()).toBe( + "before\n\n| A |\n| --- |\n\nafter" + ); + const live = state.update({ changes: { from: 0, insert: "intro\n\n" } }).state; + expect(decision!.render(live, from + 7, to + 7)).toBe(insert); +}); diff --git a/test/webview/paste/cm-list-reindent-paste.test.ts b/test/webview/paste/cm-list-reindent-paste.test.ts index 69c177bc..5d146f4f 100644 --- a/test/webview/paste/cm-list-reindent-paste.test.ts +++ b/test/webview/paste/cm-list-reindent-paste.test.ts @@ -3,8 +3,8 @@ import { markdown } from "@codemirror/lang-markdown"; import { EditorState } from "@codemirror/state"; import { EditorView } from "@codemirror/view"; import { describe, expect, it } from "vitest"; - import { listReindentPaste, reindentPastedList } from "../../../src/webview/cm/paste/index.js"; +import { decideListPaste } from "../../../src/webview/cm/paste/list-reindent.js"; describe("reindentPastedList — pure transform", () => { it("re-bases a top-level list fragment to the destination column (delta +2)", () => { @@ -258,3 +258,17 @@ describe("listReindentPaste — handler", () => { view.destroy(); }); }); + +it("list decision owns indentation replacement range", () => { + const doc = "- a\n - b\n \n more"; + const state = EditorState.create({ doc, selection: { anchor: 12 }, extensions: [markdown()] }); + const decision = decideListPaste(state, "- a\n - b\n- c"); + expect(decision).not.toBeNull(); + expect({ from: decision!.from, to: decision!.to }).toEqual({ from: 10, to: 12 }); + const insert = decision!.render(state, decision!.from, decision!.to); + expect( + state + .update({ changes: { from: decision!.from, to: decision!.to, insert } }) + .state.doc.toString() + ).toBe("- a\n - b\n - a\n - b\n - c\n more"); +}); diff --git a/test/webview/paste/cm-paste-operation.test.ts b/test/webview/paste/cm-paste-operation.test.ts new file mode 100644 index 00000000..1669afe9 --- /dev/null +++ b/test/webview/paste/cm-paste-operation.test.ts @@ -0,0 +1,293 @@ +// @vitest-environment happy-dom +import { history, redo, redoDepth, undo } from "@codemirror/commands"; +import { EditorSelection, EditorState, StateEffect } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +import { expect, it } from "vitest"; + +import { hostDocumentReseed } from "../../../src/webview/cm/host-reseed.js"; +import { + completingPasteOperation, + pendingPasteOperations, + registerPasteOperation, +} from "../../../src/webview/cm/paste/paste-operation.js"; + +import { firePasteAt } from "../helpers/clipboard-double.js"; + +function pending(from: number, to = from, id = "A") { + return { id, from, to, selectionMoved: false }; +} + +function stateWith(...targets: ReturnType[]) { + const state = EditorState.create({ + doc: "0123456789abcdefghij", + extensions: [pendingPasteOperations], + }); + return state.update({ effects: targets.map((target) => registerPasteOperation.of(target)) }) + .state; +} + +it("pending full range maps through preceding edits", () => { + const state = stateWith(pending(4, 8), pending(12, 12, "B")); + const live = state.update({ changes: { from: 1, insert: "abc" } }).state; + expect(live.field(pendingPasteOperations)).toEqual([pending(7, 11), pending(15, 15, "B")]); + expect(state.field(pendingPasteOperations)).toEqual([pending(4, 8), pending(12, 12, "B")]); +}); + +it("editing replacement interior cancels pending operation", () => { + for (const changes of [ + { from: 5, insert: "x" }, + { from: 3, to: 6 }, + { from: 6, to: 9 }, + { from: 4, to: 8 }, + ]) { + expect( + stateWith(pending(4, 8)).update({ changes }).state.field(pendingPasteOperations) + ).toEqual([]); + } + for (const changes of [ + { from: 2, to: 4 }, + { from: 8, to: 10 }, + { from: 4, insert: "x" }, + { from: 8, insert: "x" }, + ]) { + const state = stateWith(pending(4, 8)); + const tr = state.update({ changes }); + expect(tr.state.field(pendingPasteOperations)).toEqual([ + pending(tr.changes.mapPos(4, 1), tr.changes.mapPos(8, -1)), + ]); + } +}); + +it("deletion strictly across pending caret cancels", () => { + const state = stateWith(pending(6)); + expect(state.update({ changes: { from: 5, to: 7 } }).state.field(pendingPasteOperations)).toEqual( + [] + ); +}); + +it("boundary deletion preserves caret and same-position typing maps right", () => { + for (const [changes, position] of [ + [{ from: 4, to: 6 }, 4], + [{ from: 6, to: 8 }, 6], + [{ from: 6, insert: "abc" }, 9], + ] as const) { + expect(stateWith(pending(6)).update({ changes }).state.field(pendingPasteOperations)).toEqual([ + pending(position), + ]); + } +}); + +it("selection movement metadata survives document mapping", () => { + const state = stateWith(pending(6)); + const moved = state.update({ selection: { anchor: 10 } }).state; + expect(moved.field(pendingPasteOperations)).toEqual([{ ...pending(6), selectionMoved: true }]); + const live = moved.update({ changes: { from: 1, insert: "abc" } }).state; + expect(live.field(pendingPasteOperations)).toEqual([{ ...pending(9), selectionMoved: true }]); + expect(moved.doc.eq(state.doc)).toBe(true); + expect(state.update({ selection: state.selection }).state.field(pendingPasteOperations)).toEqual([ + pending(6), + ]); +}); + +it("successful undo preserves redo after late result", () => { + const view = new EditorView({ doc: "base", extensions: [history(), pendingPasteOperations] }); + try { + view.dispatch({ effects: registerPasteOperation.of(pending(1)) }); + expect(undo(view)).toBe(false); + expect(view.state.field(pendingPasteOperations)).toEqual([pending(1)]); + view.dispatch({ changes: { from: 4, insert: "typed" }, userEvent: "input.type" }); + expect(undo(view)).toBe(true); + expect(view.state.field(pendingPasteOperations)).toEqual([]); + expect(redoDepth(view.state)).toBe(1); + // A late callback may insert only while its operation id remains live. + if (view.state.field(pendingPasteOperations).some((target) => target.id === "A")) { + view.dispatch({ changes: { from: 1, insert: "late" } }); + } + expect(redoDepth(view.state)).toBe(1); + view.dispatch({ effects: registerPasteOperation.of(pending(1, 1, "B")) }); + expect(redo(view)).toBe(true); + expect(view.state.field(pendingPasteOperations)).toEqual([]); + expect(view.state.doc.toString()).toBe("basetyped"); + } finally { + view.destroy(); + } +}); + +it("changed reseed cancels but identical ack retains pending target", () => { + const state = stateWith(pending(6)); + const ack = state.update({ annotations: hostDocumentReseed.of(true) }).state; + expect(ack.field(pendingPasteOperations)).toEqual([pending(6)]); + expect(ack.doc.eq(state.doc)).toBe(true); + const changed = ack.update({ + changes: { from: 0, to: 1, insert: "x" }, + annotations: hostDocumentReseed.of(true), + }).state; + expect(changed.field(pendingPasteOperations)).toEqual([]); +}); + +it("new conflicting custom paste supersedes old", () => { + for (const [a, b] of [ + [pending(6), pending(6, 6, "B")], + [pending(4, 8), pending(6, 10, "B")], + [pending(4, 8), pending(4, 4, "B")], + [pending(8), pending(4, 8, "B")], + ]) { + const state = stateWith(a, pending(15, 16, "C")); + const live = state.update({ effects: registerPasteOperation.of(b) }).state; + expect(live.field(pendingPasteOperations)).toEqual([pending(15, 16, "C"), b]); + expect(state.field(pendingPasteOperations)).toEqual([a, pending(15, 16, "C")]); + expect(live.doc.eq(state.doc)).toBe(true); + } + const state = stateWith(pending(4, 8)); + expect( + state + .update({ effects: registerPasteOperation.of(pending(8, 10, "B")) }) + .state.field(pendingPasteOperations) + ).toEqual([pending(4, 8), pending(8, 10, "B")]); +}); + +function copyText(view: EditorView): string { + const copied = new Map(); + const event = new Event("copy", { bubbles: true, cancelable: true }); + Object.defineProperty(event, "clipboardData", { + value: { + clearData: () => copied.clear(), + setData: (type: string, value: string) => copied.set(type, value), + }, + }); + view.contentDOM.dispatchEvent(event); + expect(event.defaultPrevented).toBe(true); + return copied.get("text/plain")!; +} + +it("linewise CM paste cancels at its actual insertion point", () => { + const parent = document.body.appendChild(document.createElement("div")); + const view = new EditorView({ + parent, + doc: "source\nTARGET\nother", + extensions: [pendingPasteOperations], + }); + try { + view.dispatch({ selection: { anchor: 2 } }); + view.focus(); + const copied = copyText(view); + expect(copied).toBe("source"); + view.dispatch({ selection: { anchor: 10 } }); + view.dispatch({ + effects: [ + registerPasteOperation.of(pending(7)), + registerPasteOperation.of(pending(18, 18, "B")), + ], + }); + firePasteAt(view.contentDOM, { text: copied }); + expect(view.state.doc.toString()).toBe("source\nsource\nTARGET\nother"); + expect(view.state.field(pendingPasteOperations)).toEqual([pending(25, 25, "B")]); + // Coordinator completion excludes its own id from paste-conflict cancellation. + view.dispatch({ effects: registerPasteOperation.of(pending(0, 0, "own")) }); + view.dispatch({ + changes: { from: 0, insert: "x" }, + userEvent: "input.paste", + annotations: completingPasteOperation.of("own"), + }); + expect(view.state.field(pendingPasteOperations).find((target) => target.id === "own")).toEqual( + pending(1, 1, "own") + ); + } finally { + view.destroy(); + parent.remove(); + } +}); + +it("secondary CM paste range cancels its conflicting operation", () => { + const view = new EditorView({ + state: EditorState.create({ + doc: "0123456789abcdefghij", + selection: EditorSelection.create([EditorSelection.cursor(2), EditorSelection.cursor(10)]), + extensions: [EditorState.allowMultipleSelections.of(true), pendingPasteOperations], + }), + }); + try { + view.dispatch({ + effects: [ + registerPasteOperation.of(pending(10)), + registerPasteOperation.of(pending(18, 18, "B")), + ], + }); + firePasteAt(view.contentDOM, { text: "x\ny" }); + expect(view.state.doc.toString()).toBe("01x23456789yabcdefghij"); + expect(view.state.field(pendingPasteOperations)).toEqual([ + { ...pending(20, 20, "B"), selectionMoved: true }, + ]); + } finally { + view.destroy(); + } +}); + +it("filtered CM paste cancels only actual changed ranges", () => { + const parent = document.body.appendChild(document.createElement("div")); + const view = new EditorView({ + parent, + state: EditorState.create({ + doc: "source\nTARGET\nother", + extensions: [ + EditorState.allowMultipleSelections.of(true), + pendingPasteOperations, + EditorView.clipboardInputFilter.of(() => "source"), + ], + }), + }); + try { + view.dispatch({ selection: { anchor: 2 } }); + view.focus(); + expect(copyText(view)).toBe("source"); + view.dispatch({ + selection: EditorSelection.create([EditorSelection.cursor(2), EditorSelection.cursor(10)]), + }); + view.dispatch({ + effects: [ + registerPasteOperation.of(pending(0)), + registerPasteOperation.of(pending(2, 2, "B")), + registerPasteOperation.of(pending(10, 10, "C")), + ], + }); + // The filter turns two input lines into the last linewise copy. Native CM + // therefore inserts whole lines at 0 and 7, rather than at the two carets. + firePasteAt(view.contentDOM, { text: "x\ny" }); + expect(view.state.doc.toString()).toBe("source\nsource\nsource\nTARGET\nother"); + expect(view.state.field(pendingPasteOperations)).toEqual([ + pending(9, 9, "B"), + pending(24, 24, "C"), + ]); + } finally { + view.destroy(); + parent.remove(); + } +}); + +it("vetoed CM paste preserves pending operations", () => { + for (const keepPasteAnnotation of [false, true]) { + const view = new EditorView({ state: stateWith(pending(0), pending(8, 12, "B")) }); + try { + view.dispatch({ + effects: StateEffect.appendConfig.of( + EditorState.transactionFilter.of((tr) => + tr.isUserEvent("input.paste") + ? keepPasteAnnotation + ? { userEvent: "input.paste" } + : [] + : tr + ) + ), + }); + const before = view.state; + firePasteAt(view.contentDOM, { text: "blocked" }); + expect(view.state.doc.eq(before.doc)).toBe(true); + expect(view.state.selection.eq(before.selection)).toBe(true); + expect(view.state.field(pendingPasteOperations)).toEqual( + before.field(pendingPasteOperations) + ); + } finally { + view.destroy(); + } + } +}); diff --git a/test/webview/paste/cm-paste-url-link.test.ts b/test/webview/paste/cm-paste-url-link.test.ts index ae457423..2e5da254 100644 --- a/test/webview/paste/cm-paste-url-link.test.ts +++ b/test/webview/paste/cm-paste-url-link.test.ts @@ -6,6 +6,7 @@ import { describe, expect, it } from "vitest"; import { quollMarkdownLanguage } from "../../../src/webview/cm/markdown.js"; import { detectPasteLinkUrl, pasteUrlOverSelection } from "../../../src/webview/cm/paste/index.js"; +import { decideUrlPaste } from "../../../src/webview/cm/paste/url-link-paste.js"; import { settledMount } from "../helpers/settled-view.js"; describe("detectPasteLinkUrl — URL detection boundary", () => { @@ -215,3 +216,20 @@ describe("pasteUrlOverSelection — syntax-context guard", () => { // deterministically (a probe test passed identically with and without the fix). // The fix is a defensive robustness improvement over the ported built-in; the // tree-available guard behaviour is covered by the code/link defer tests above. + +it("URL decision preserves selected label", async () => { + const view = await settledMount({ + doc: "before label after", + selection: { anchor: 7, head: 12 }, + extensions: [quollMarkdownLanguage()], + }); + try { + const decision = decideUrlPaste(view, "https://example.com/a(b)"); + expect(decision).not.toBeNull(); + expect({ from: decision!.from, to: decision!.to }).toEqual({ from: 7, to: 12 }); + expect(decision!.render(view.state, 7, 12)).toBe("[label]()"); + expect(view.state.doc.toString()).toBe("before label after"); + } finally { + view.destroy(); + } +}); diff --git a/test/webview/paste/cm-rich-html-image-paste.test.ts b/test/webview/paste/cm-rich-html-image-paste.test.ts index 6e50efd1..960dd262 100644 --- a/test/webview/paste/cm-rich-html-image-paste.test.ts +++ b/test/webview/paste/cm-rich-html-image-paste.test.ts @@ -14,7 +14,7 @@ import { pendingImageAnchors, } from "../../../src/webview/cm/image/image-paste.js"; import { richHtmlPaste } from "../../../src/webview/cm/paste/rich-html-paste.js"; -import { firePasteAt, IMAGE_FILE } from "../helpers/clipboard-double.js"; +import { firePasteAt, IMAGE_FILE, makeClipboardData } from "../helpers/clipboard-double.js"; import { imageWrites, sizedImageFile } from "../helpers/image-paste-doubles.js"; const CAPTION_HTML = "
caption
"; @@ -174,3 +174,22 @@ describe("richHtmlPaste + imagePaste — one clipboard carrying HTML and an imag view.destroy(); }); }); + +it("clipboard snapshot acquires stable File references", () => { + const supplied = new File(["given"], "given.png", { type: "image/png" }); + const data = makeClipboardData({ + text: "caption", + files: [ + ...IMAGE_FILE, + { type: "image/png", file: supplied }, + { type: "image/png", file: null }, + { type: "image/png", file: undefined }, + ], + }); + const items = data.items.filter((item) => item.kind === "file"); + expect(items[0].getAsFile()).toBe(items[0].getAsFile()); + expect(items[0].getAsFile()).toBe(data.files[0]); + expect(items[1].getAsFile()).toBe(supplied); + expect(items[2].getAsFile()).toBeNull(); + expect(items[3].getAsFile()).toBeUndefined(); +}); diff --git a/test/webview/paste/cm-rich-html-paste.test.ts b/test/webview/paste/cm-rich-html-paste.test.ts index 5553d128..3c3a7c90 100644 --- a/test/webview/paste/cm-rich-html-paste.test.ts +++ b/test/webview/paste/cm-rich-html-paste.test.ts @@ -7,7 +7,7 @@ import type { Tree } from "@lezer/common"; import { describe, expect, it } from "vitest"; import { quollMarkdownLanguage } from "../../../src/webview/cm/markdown.js"; import { htmlToMarkdown } from "../../../src/webview/cm/paste/html-to-markdown.js"; -import { richHtmlPaste } from "../../../src/webview/cm/paste/rich-html-paste.js"; +import { decideRichPaste, richHtmlPaste } from "../../../src/webview/cm/paste/rich-html-paste.js"; import { type ClipboardFlavours, firePasteAt, IMAGE_FILE } from "../helpers/clipboard-double.js"; function mount(doc: string, canWrite = true) { @@ -754,3 +754,44 @@ describe("richHtmlPaste — plain-text-like fragments defer", () => { view.destroy(); }); }); + +it("changed neighbours force safe block framing", () => { + const state = EditorState.create({ + doc: "hello world", + selection: { anchor: 6 }, + extensions: [markdown()], + }); + const decision = decideRichPaste(state, "bold"); + expect(decision).not.toBeNull(); + expect(decision!.render(state, 6, 6)).toBe("**bold**"); + const live = state.update({ changes: { from: 6, insert: "\\" } }).state; + expect(decision!.render(live, 7, 7)).toBe("\n\n**bold**\n\n"); + expect(state.doc.toString()).toBe("hello world"); +}); + +it("rich render uses captured plain after an outside fence insertion", () => { + const state = EditorState.create({ + doc: "intro\n\nlabel", + selection: { anchor: 7, head: 12 }, + extensions: [markdown()], + }); + const decision = decideRichPaste(state, "bold", "captured plain"); + expect(decision).not.toBeNull(); + const live = state.update({ changes: { from: 0, insert: "```\n" } }).state; + expect(decision!.render(live, 11, 16)).toBe("captured plain"); + expect(live.doc.sliceString(11, 16)).toBe("label"); +}); + +it("rich render emits no text in code without captured fallback", () => { + for (const prefix of ["```\n", " "]) { + const state = EditorState.create({ + doc: "label", + selection: { anchor: 1, head: 4 }, + extensions: [markdown()], + }); + const decision = decideRichPaste(state, "bold"); + expect(decision).not.toBeNull(); + const live = state.update({ changes: { from: 0, insert: prefix } }).state; + expect(decision!.render(live, 1 + prefix.length, 4 + prefix.length)).toBe(""); + } +}); From 1b353d0e7788d66e4c9a20a90a7dd64535be2dd0 Mon Sep 17 00:00:00 2001 From: Mitsuki Fukunaga Date: Tue, 10 Nov 2026 08:18:39 +0900 Subject: [PATCH 2/9] feat(paste): coordinate pending paste settlement Verify registration before any I/O, settle File slots in clipboard order, compose one replacement, and dispatch the filtered transaction once; filter vetoes, deadlines and dispose terminate the operation without inserting. --- src/webview/cm/paste/paste-operation.ts | 236 +++++++++- test/webview/paste/cm-paste-operation.test.ts | 420 +++++++++++++++++- 2 files changed, 653 insertions(+), 3 deletions(-) diff --git a/src/webview/cm/paste/paste-operation.ts b/src/webview/cm/paste/paste-operation.ts index 5fd597b5..a8717b6c 100644 --- a/src/webview/cm/paste/paste-operation.ts +++ b/src/webview/cm/paste/paste-operation.ts @@ -1,5 +1,8 @@ -// Pending paste metadata only. Async resources belong to the coordinator. -import { Annotation, MapMode, StateEffect, StateField } from "@codemirror/state"; +// Pending paste metadata and its async coordinator. +import { isolateHistory } from "@codemirror/commands"; +import { Annotation, type EditorState, MapMode, StateEffect, StateField } from "@codemirror/state"; + +import { type EditorView, ViewPlugin } from "@codemirror/view"; import { hostDocumentReseed } from "../host-reseed.js"; @@ -13,6 +16,7 @@ export interface PendingPasteOperation { export const completingPasteOperation = Annotation.define(); export const registerPasteOperation = StateEffect.define(); +const removePasteOperation = StateEffect.define(); function rangesConflict(a: { from: number; to: number }, b: { from: number; to: number }): boolean { if (a.from === a.to) { @@ -69,6 +73,9 @@ export const pendingPasteOperations = StateField.define target.id !== effect.value); + } if (effect.is(registerPasteOperation)) { next = [ ...next.filter((target) => !rangesConflict(target, effect.value)), @@ -79,3 +86,228 @@ export const pendingPasteOperations = StateField.define string; +} + +export function createPasteOperationCoordinator(options: { + onFailure: () => void; + startResources?: (id: string) => (() => void) | undefined; + onReady?: (id: string) => void; + onUnlinkedPath?: (path: string) => void; +}) { + let nextId = 0; + let disposed = false; + const operations = new Map< + string, + { + view: EditorView; + decision: PasteDecision; + slots: (string | null | undefined)[]; + deadline?: ReturnType; + release?: () => void; + } + >(); + function clearMetadata(view: EditorView, id: string) { + if (!view.state.field(pendingPasteOperations).some((target) => target.id === id)) { + return; + } + // Only private metadata bypasses filters; content edits always remain filtered. + const tr = view.state.update({ effects: removePasteOperation.of(id), filter: false }); + if (!tr.newDoc.eq(view.state.doc)) { + throw new Error("Paste cleanup must preserve the document"); + } + view.dispatch(tr); + } + function release(id: string) { + const operation = operations.get(id); + operations.delete(id); + clearTimeout(operation?.deadline); + operation?.release?.(); + return operation; + } + function dispose(clearState = true) { + disposed = true; + for (const [id, operation] of operations) { + release(id); + if (clearState) { + clearMetadata(operation.view, id); + } + for (const path of operation.slots) { + if (typeof path === "string") { + options.onUnlinkedPath?.(path); + } + } + } + } + function isLive(id: string) { + const operation = operations.get(id); + return !!operation?.view.state.field(pendingPasteOperations).some((target) => target.id === id); + } + function settle(id: string, slot: number, path: string | null) { + const operation = operations.get(id); + if (!operation || !isLive(id) || slot < 0 || slot >= operation.slots.length) { + return; + } + if (operation.slots[slot] !== undefined) { + return; + } + operation.slots[slot] = path; + if (operation.slots.every((result) => result !== undefined)) { + clearTimeout(operation.deadline); + options.onReady?.(id); + } + } + function fail(view: EditorView, id: string) { + const operation = release(id); + clearMetadata(view, id); + for (const path of operation?.slots ?? []) { + if (typeof path === "string") { + options.onUnlinkedPath?.(path); + } + } + options.onFailure(); + } + return { + extension: [ + pendingPasteOperations, + ViewPlugin.define(() => ({ + update(update) { + for (const [id, operation] of operations) { + if ( + operation.view !== update.view || + isLive(id) || + update.transactions.some((tr) => tr.annotation(completingPasteOperation) === id) + ) { + continue; + } + release(id); + for (const path of operation.slots) { + if (typeof path === "string") { + options.onUnlinkedPath?.(path); + } + } + // Notices may dispatch, so deliver them outside CM's update. + queueMicrotask(() => { + if (!disposed) { + options.onFailure(); + } + }); + } + }, + destroy() { + dispose(false); + }, + })), + ], + dispose: () => dispose(), + capture(view: EditorView, decision: PasteDecision, slotCount = 0): string | null { + if (disposed) { + return null; + } + const id = `paste-${++nextId}`; + try { + view.dispatch({ + effects: registerPasteOperation.of({ + id, + from: decision.from, + to: decision.to, + selectionMoved: false, + }), + }); + } catch { + clearMetadata(view, id); + options.onFailure(); + return null; + } + if (!view.state.field(pendingPasteOperations).some((target) => target.id === id)) { + options.onFailure(); + return null; + } + operations.set(id, { view, decision, slots: Array.from({ length: slotCount }) }); + const cleanup = options.startResources?.(id); + const operation = operations.get(id); + if (operation) { + operation.release = cleanup; + } else { + cleanup?.(); + } + if (operation?.slots.some((result) => result === undefined)) { + operation.deadline = setTimeout(() => { + if (!isLive(id)) { + operations.delete(id); + return; + } + operation.slots.forEach((result, slot) => { + if (result === undefined) { + settle(id, slot, null); + } + }); + }, 30_000); + } + if (slotCount === 0) { + options.onReady?.(id); + } + return id; + }, + complete(id: string) { + const operation = operations.get(id); + if (!operation || operation.slots.some((result) => result === undefined)) { + return; + } + const { view, decision, slots } = operation; + const target = view.state.field(pendingPasteOperations).find((entry) => entry.id === id); + if (!target) { + return; + } + const state = view.state; + const insert = composePasteReplacement(state, decision, target, slots); + const spec = { + changes: { from: target.from, to: target.to, insert }, + selection: target.selectionMoved ? undefined : { anchor: target.from + insert.length }, + scrollIntoView: !target.selectionMoved, + userEvent: "input.paste", + annotations: [completingPasteOperation.of(id), isolateHistory.of("full")], + effects: removePasteOperation.of(id), + }; + const expected = state.changes(spec.changes).apply(state.doc); + const tr = state.update(spec); + if (!tr.newDoc.eq(expected)) { + fail(view, id); + return; + } + view.dispatch(tr); + release(id); + clearMetadata(view, id); + }, + settle, + results: (id: string) => operations.get(id)?.slots.slice(), + has: isLive, + }; +} + +/** Normalize before computing separators or offsets. The decision already owns indentation. */ +export function composePasteReplacement( + state: EditorState, + decision: PasteDecision, + target: PendingPasteOperation, + slots: readonly (string | null | undefined)[] +) { + let text = state.toText(decision.render(state, target.from, target.to)); + const paths = slots.filter((path): path is string => typeof path === "string"); + if (paths.length) { + const atLineStart = target.from === state.doc.lineAt(target.from).from; + const prefix = text.length + ? text.sliceString(text.length - 1) === "\n" + ? "" + : "\n" + : atLineStart + ? "" + : "\n"; + text = text.append(state.toText(prefix + paths.map((path) => `![](${path})\n`).join(""))); + } + return text; +} diff --git a/test/webview/paste/cm-paste-operation.test.ts b/test/webview/paste/cm-paste-operation.test.ts index 1669afe9..bd431229 100644 --- a/test/webview/paste/cm-paste-operation.test.ts +++ b/test/webview/paste/cm-paste-operation.test.ts @@ -2,11 +2,16 @@ import { history, redo, redoDepth, undo } from "@codemirror/commands"; import { EditorSelection, EditorState, StateEffect } from "@codemirror/state"; import { EditorView } from "@codemirror/view"; -import { expect, it } from "vitest"; +import { expect, it, vi } from "vitest"; + +import { frontmatterBlockField } from "../../../src/webview/cm/frontmatter/frontmatter-field.js"; +import { revealFrontmatterAt } from "../../../src/webview/cm/frontmatter/reveal-state.js"; import { hostDocumentReseed } from "../../../src/webview/cm/host-reseed.js"; import { completingPasteOperation, + composePasteReplacement, + createPasteOperationCoordinator, pendingPasteOperations, registerPasteOperation, } from "../../../src/webview/cm/paste/paste-operation.js"; @@ -291,3 +296,416 @@ it("vetoed CM paste preserves pending operations", () => { } } }); + +function coordinatorFixture(reject = false) { + const failure = vi.fn(); + const coordinator = createPasteOperationCoordinator({ onFailure: failure }); + const view = new EditorView({ + doc: "base", + extensions: [ + coordinator.extension, + ...(reject ? [EditorState.transactionFilter.of(() => [])] : []), + ], + }); + const capture = () => coordinator.capture(view, { from: 1, to: 2, render: () => "caption" }); + return { coordinator, view, failure, capture }; +} + +it("registration veto leaves no new pending operation", () => { + const f = coordinatorFixture(true); + try { + const before = f.view.state; + expect(f.capture()).toBeNull(); + expect(f.view.state.field(pendingPasteOperations)).toEqual([]); + expect(f.view.state.doc.eq(before.doc)).toBe(true); + expect(f.view.state.selection.eq(before.selection)).toBe(true); + expect(f.failure).toHaveBeenCalledOnce(); + } finally { + f.view.destroy(); + } +}); + +it("registration rejection starts zero reads posts and timers", () => { + const read = vi.fn(class {}); + vi.stubGlobal("FileReader", read); + const timer = vi.spyOn(globalThis, "setTimeout"); + const post = vi.fn(); + const startResources = vi.fn(() => { + new FileReader(); + post(); + const deadline = setTimeout(() => {}, 30_000); + return () => clearTimeout(deadline); + }); + const coordinator = createPasteOperationCoordinator({ onFailure: vi.fn(), startResources }); + const view = new EditorView({ + extensions: [coordinator.extension, EditorState.transactionFilter.of(() => [])], + }); + try { + read.mockClear(); + timer.mockClear(); + expect(coordinator.capture(view, { from: 0, to: 0, render: () => "x" }, 1)).toBeNull(); + expect(startResources).not.toHaveBeenCalled(); + expect(read).not.toHaveBeenCalled(); + expect(post).not.toHaveBeenCalled(); + expect(timer).not.toHaveBeenCalled(); + const allowed = new EditorView({ extensions: [coordinator.extension] }); + try { + coordinator.capture(allowed, { from: 0, to: 0, render: () => "x" }); + expect(startResources).toHaveBeenCalledOnce(); + } finally { + allowed.destroy(); + } + } finally { + view.destroy(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + } +}); + +it("rejected registration retains existing conflicting operation", () => { + const f = coordinatorFixture(); + try { + const id = f.capture()!; + f.view.dispatch({ + effects: StateEffect.appendConfig.of(EditorState.transactionFilter.of(() => [])), + }); + expect(f.capture()).toBeNull(); + expect(f.view.state.field(pendingPasteOperations).map((target) => target.id)).toEqual([id]); + expect(f.coordinator.has(id)).toBe(true); + expect(f.failure).toHaveBeenCalledOnce(); + } finally { + f.view.destroy(); + } +}); + +it("registration dispatch throw starts no I/O", () => { + const startResources = vi.fn(); + const failure = vi.fn(); + const coordinator = createPasteOperationCoordinator({ onFailure: failure, startResources }); + const view = new EditorView({ extensions: [coordinator.extension] }); + try { + vi.spyOn(view, "dispatch").mockImplementation(() => { + throw new Error("dispatch failed"); + }); + expect(coordinator.capture(view, { from: 0, to: 0, render: () => "x" })).toBeNull(); + expect(view.state.doc.toString()).toBe(""); + expect(view.state.field(pendingPasteOperations)).toEqual([]); + expect(startResources).not.toHaveBeenCalled(); + expect(failure).toHaveBeenCalledOnce(); + } finally { + view.destroy(); + } +}); + +it("post-adoption registration throw clears new metadata", () => { + const startResources = vi.fn(); + const failure = vi.fn(); + const coordinator = createPasteOperationCoordinator({ onFailure: failure, startResources }); + const view = new EditorView({ extensions: [coordinator.extension] }); + try { + const dispatch = view.dispatch.bind(view); + vi.spyOn(view, "dispatch").mockImplementationOnce((...input) => { + dispatch(...input); + throw new Error("adopted then failed"); + }); + expect(coordinator.capture(view, { from: 0, to: 0, render: () => "x" })).toBeNull(); + expect(view.state.field(pendingPasteOperations)).toEqual([]); + expect(startResources).not.toHaveBeenCalled(); + expect(failure).toHaveBeenCalledOnce(); + expect(coordinator.has("paste-1")).toBe(false); + } finally { + view.destroy(); + } +}); + +it("reverse replies preserve slot order", () => { + const f = coordinatorFixture(); + try { + const id = f.coordinator.capture(f.view, { from: 1, to: 2, render: () => "caption" }, 3)!; + f.coordinator.settle(id, 2, "./c.png"); + f.coordinator.settle(id, 0, "./a.png"); + f.coordinator.settle(id, 1, null); + expect(f.coordinator.results(id)).toEqual(["./a.png", null, "./c.png"]); + expect(f.view.state.doc.toString()).toBe("base"); + } finally { + f.view.destroy(); + } +}); + +it("duplicate reply cannot settle twice", () => { + const f = coordinatorFixture(); + try { + const id = f.coordinator.capture(f.view, { from: 1, to: 2, render: () => "x" }, 2)!; + f.coordinator.settle(id, 0, "./first.png"); + f.coordinator.settle(id, 0, null); + expect(f.coordinator.results(id)).toEqual(["./first.png", undefined]); + f.coordinator.settle(id, 1, null); + f.coordinator.settle(id, 1, "./duplicate.png"); + expect(f.coordinator.results(id)).toEqual(["./first.png", null]); + } finally { + f.view.destroy(); + } +}); + +it("operation becomes ready only after its last slot", () => { + const ready = vi.fn(); + const coordinator = createPasteOperationCoordinator({ onFailure: vi.fn(), onReady: ready }); + const view = new EditorView({ extensions: [coordinator.extension] }); + try { + const id = coordinator.capture(view, { from: 0, to: 0, render: () => "x" }, 3)!; + coordinator.settle(id, 2, null); + coordinator.settle(id, 0, "./a.png"); + coordinator.settle(id, 0, "./duplicate.png"); + expect(ready).not.toHaveBeenCalled(); + coordinator.settle(id, 1, null); + expect(ready).toHaveBeenCalledExactlyOnceWith(id); + coordinator.settle(id, 1, null); + expect(ready).toHaveBeenCalledOnce(); + const textOnly = coordinator.capture(view, { from: 0, to: 0, render: () => "text" })!; + expect(ready).toHaveBeenLastCalledWith(textOnly); + } finally { + view.destroy(); + } +}); + +it("composed replacement keeps text and accepted links in order", () => { + const state = EditorState.create({ doc: "before TARGET after" }); + const render = vi.fn((live: EditorState, from: number, to: number) => { + expect(live).toBe(state); + expect([from, to]).toEqual([7, 13]); + return "caption\r\nsecond"; + }); + const replacement = composePasteReplacement(state, { from: 0, to: 0, render }, pending(7, 13), [ + "./a.png", + null, + "./c.png", + ]); + expect(replacement.toString()).toBe("caption\nsecond\n![](./a.png)\n![](./c.png)\n"); + expect(replacement.length).toBe(replacement.toString().length); + expect(render).toHaveBeenCalledOnce(); + expect(state.doc.toString()).toBe("before TARGET after"); + expect( + composePasteReplacement(state, { from: 0, to: 0, render: () => " - item\n" }, pending(0, 7), [ + "./a.png", + ]).toString() + ).toBe(" - item\n![](./a.png)\n"); +}); + +it("accepted replacement dispatches the same filtered transaction once", () => { + const f = coordinatorFixture(); + try { + const id = f.coordinator.capture(f.view, { from: 1, to: 2, render: () => "caption" }, 1)!; + const filtered: import("@codemirror/state").Transaction[] = []; + let documentChanges = 0; + f.view.dispatch({ + effects: StateEffect.appendConfig.of([ + EditorState.transactionFilter.of((tr) => { + filtered.push(tr); + return tr; + }), + EditorView.updateListener.of((update) => { + if (update.docChanged) { + documentChanges++; + } + }), + ]), + }); + const dispatch = vi.spyOn(f.view, "dispatch"); + f.coordinator.settle(id, 0, "./a.png"); + f.coordinator.complete(id); + expect(filtered).toHaveLength(1); + expect(dispatch).toHaveBeenCalledExactlyOnceWith(filtered[0]); + expect(documentChanges).toBe(1); + expect(f.view.state.doc.toString()).toBe("bcaption\n![](./a.png)\nse"); + expect(f.view.state.selection.main.head).toBe("bcaption\n![](./a.png)\n".length); + expect(f.coordinator.has(id)).toBe(false); + expect(f.view.state.field(pendingPasteOperations)).toEqual([]); + } finally { + f.view.destroy(); + } +}); + +it("reject-all filter clears pending metadata without inserting", () => { + const failure = vi.fn(); + const unlinked = vi.fn(); + const coordinator = createPasteOperationCoordinator({ + onFailure: failure, + onUnlinkedPath: unlinked, + }); + const view = new EditorView({ doc: "base", extensions: [coordinator.extension] }); + try { + const id = coordinator.capture(view, { from: 1, to: 2, render: () => "caption" }, 1)!; + view.dispatch({ + effects: StateEffect.appendConfig.of(EditorState.transactionFilter.of(() => [])), + }); + const before = view.state; + coordinator.settle(id, 0, "./a.png"); + coordinator.complete(id); + expect(view.state.doc.eq(before.doc)).toBe(true); + expect(view.state.selection.eq(before.selection)).toBe(true); + expect(view.state.field(pendingPasteOperations)).toEqual([]); + expect(coordinator.has(id)).toBe(false); + expect(failure).toHaveBeenCalledOnce(); + expect(unlinked).toHaveBeenCalledExactlyOnceWith("./a.png"); + coordinator.settle(id, 0, "./a.png"); + coordinator.complete(id); + expect(view.state.doc.eq(before.doc)).toBe(true); + expect(failure).toHaveBeenCalledOnce(); + } finally { + view.destroy(); + } +}); + +it("collapsed frontmatter rejects paste without leaking pending work", () => { + const failure = vi.fn(); + const unlinked = vi.fn(); + const coordinator = createPasteOperationCoordinator({ + onFailure: failure, + onUnlinkedPath: unlinked, + }); + const view = new EditorView({ + doc: "---\ntitle: test\n---\nbody", + extensions: [coordinator.extension, frontmatterBlockField], + }); + try { + expect(revealFrontmatterAt(view, 7)).toBe(true); + expect(view.state.field(frontmatterBlockField).kind).toBe("revealed"); + const id = coordinator.capture(view, { from: 7, to: 7, render: () => "caption" }, 1)!; + view.dispatch({ selection: { anchor: view.state.doc.length } }); + expect(view.state.field(frontmatterBlockField).kind).toBe("collapsed"); + const before = view.state; + coordinator.settle(id, 0, "./a.png"); + coordinator.complete(id); + expect(view.state.doc.eq(before.doc)).toBe(true); + expect(view.state.selection.eq(before.selection)).toBe(true); + expect(view.state.field(pendingPasteOperations)).toEqual([]); + expect(coordinator.has(id)).toBe(false); + expect(failure).toHaveBeenCalledOnce(); + expect(unlinked).toHaveBeenCalledExactlyOnceWith("./a.png"); + coordinator.complete(id); + expect(failure).toHaveBeenCalledOnce(); + } finally { + view.destroy(); + } +}); + +it("completed paste undoes and redoes as one step", () => { + const startResources = vi.fn(); + const coordinator = createPasteOperationCoordinator({ onFailure: vi.fn(), startResources }); + const view = new EditorView({ doc: "base", extensions: [coordinator.extension, history()] }); + try { + view.dispatch({ + changes: { from: 4, insert: "before" }, + selection: { anchor: 10 }, + userEvent: "input.type", + }); + const id = coordinator.capture(view, { from: 10, to: 10, render: () => "caption" }, 2)!; + coordinator.settle(id, 1, "./b.png"); + coordinator.settle(id, 0, "./a.png"); + coordinator.complete(id); + const pasted = view.state.doc.toString(); + view.dispatch({ + changes: { from: view.state.doc.length, insert: "after" }, + userEvent: "input.type", + }); + expect(undo(view)).toBe(true); + expect(view.state.doc.toString()).toBe(pasted); + expect(undo(view)).toBe(true); + expect(view.state.doc.toString()).toBe("basebefore"); + expect(undo(view)).toBe(true); + expect(view.state.doc.toString()).toBe("base"); + expect(redo(view)).toBe(true); + expect(redo(view)).toBe(true); + expect(view.state.doc.toString()).toBe(pasted); + expect(redo(view)).toBe(true); + expect(view.state.doc.toString()).toBe(`${pasted}after`); + expect(startResources).toHaveBeenCalledOnce(); + } finally { + view.destroy(); + } +}); + +it("superseding B times out without reviving A", () => { + vi.useFakeTimers(); + const failure = vi.fn(); + const ready = vi.fn((id: string) => coordinator.complete(id)); + const coordinator = createPasteOperationCoordinator({ onFailure: failure, onReady: ready }); + const view = new EditorView({ doc: "base", extensions: [coordinator.extension] }); + try { + const a = coordinator.capture(view, { from: 1, to: 2, render: () => "A" }, 1)!; + const b = coordinator.capture(view, { from: 1, to: 2, render: () => "B" }, 2)!; + coordinator.settle(a, 0, "./late-a.png"); + coordinator.settle(b, 1, "./b.png"); + expect(view.state.doc.toString()).toBe("base"); + vi.advanceTimersByTime(29_999); + expect(ready).not.toHaveBeenCalled(); + vi.advanceTimersByTime(1); + expect(view.state.doc.toString()).toBe("bB\n![](./b.png)\nse"); + expect(ready).toHaveBeenCalledExactlyOnceWith(b); + expect(view.state.field(pendingPasteOperations)).toEqual([]); + expect(coordinator.has(a)).toBe(false); + expect(coordinator.has(b)).toBe(false); + const settled = view.state.doc; + coordinator.settle(a, 0, "./late-a.png"); + coordinator.settle(b, 0, "./late-b.png"); + vi.advanceTimersByTime(30_000); + expect(view.state.doc.eq(settled)).toBe(true); + expect(ready).toHaveBeenCalledOnce(); + } finally { + view.destroy(); + vi.useRealTimers(); + } +}); + +it("dispose aborts readers and clears timers", () => { + vi.useFakeTimers(); + const readers: FileReader[] = []; + const ready = vi.fn(); + const coordinator = createPasteOperationCoordinator({ + onFailure: vi.fn(), + onReady: ready, + startResources: () => { + const reader = new FileReader(); + readers.push(reader); + vi.spyOn(reader, "abort"); + return () => reader.abort(); + }, + }); + const view = new EditorView({ doc: "base", extensions: [coordinator.extension] }); + try { + const timersBefore = vi.getTimerCount(); + const id = coordinator.capture(view, { from: 1, to: 2, render: () => "caption" }, 1)!; + expect(vi.getTimerCount()).toBeGreaterThan(timersBefore); + coordinator.dispose(); + expect(readers[0].abort).toHaveBeenCalledOnce(); + expect(vi.getTimerCount()).toBe(timersBefore); + expect(view.state.field(pendingPasteOperations)).toEqual([]); + expect(coordinator.has(id)).toBe(false); + coordinator.settle(id, 0, "./late.png"); + coordinator.complete(id); + vi.advanceTimersByTime(30_000); + expect(view.state.doc.toString()).toBe("base"); + expect(ready).not.toHaveBeenCalled(); + expect(coordinator.capture(view, { from: 0, to: 0, render: () => "late" }, 1)).toBeNull(); + coordinator.dispose(); + expect(readers[0].abort).toHaveBeenCalledOnce(); + const abort = vi.fn(); + const destroyed = createPasteOperationCoordinator({ + onFailure: vi.fn(), + onReady: ready, + startResources: () => abort, + }); + const other = new EditorView({ extensions: [destroyed.extension] }); + const otherId = destroyed.capture(other, { from: 0, to: 0, render: () => "x" }, 1)!; + other.destroy(); + expect(abort).toHaveBeenCalledOnce(); + destroyed.settle(otherId, 0, "./late.png"); + destroyed.complete(otherId); + vi.advanceTimersByTime(30_000); + expect(other.state.doc.toString()).toBe(""); + expect(ready).not.toHaveBeenCalled(); + } finally { + view.destroy(); + vi.useRealTimers(); + } +}); From 365555a87a9c1101d2c7417e4c3662ccf53e3ec5 Mon Sep 17 00:00:00 2001 From: Mitsuki Fukunaga Date: Tue, 10 Nov 2026 08:28:49 +0900 Subject: [PATCH 3/9] feat(paste): route clipboard snapshots through one paste entry Extract File transport behind a temporary legacy adapter, capture every obtainable File candidate, and route table/URL/list/rich decisions through the coordinator with live URL-syntax revalidation. --- src/webview/cm/image/image-paste.ts | 279 ++++++++++-------- src/webview/cm/paste/clipboard-paste.ts | 113 +++++++ src/webview/cm/paste/index.ts | 1 + src/webview/cm/paste/url-link-paste.ts | 18 +- test/webview/image/cm-image-paste.test.ts | 76 +++++ test/webview/paste/cm-clipboard-paste.test.ts | 257 ++++++++++++++++ test/webview/paste/cm-paste-url-link.test.ts | 35 ++- 7 files changed, 652 insertions(+), 127 deletions(-) create mode 100644 src/webview/cm/paste/clipboard-paste.ts create mode 100644 test/webview/paste/cm-clipboard-paste.test.ts diff --git a/src/webview/cm/image/image-paste.ts b/src/webview/cm/image/image-paste.ts index d00f0fe7..ae49217c 100644 --- a/src/webview/cm/image/image-paste.ts +++ b/src/webview/cm/image/image-paste.ts @@ -1,7 +1,5 @@ -// Capture pasted/dropped image files and round-trip them through the host write -// path. Capture-only re: the host: this module posts `image-write` and, on the -// host's reply, inserts `![](relativePath)` at a position-mapped anchor. The -// insert rides the normal CM updateListener → edit-sync → host write pipeline. +// Callback-based File transport and the temporary legacy paste/drop adapter. +// Only the adapter inserts links; transport never changes the document. import { type Extension, StateEffect, StateField } from "@codemirror/state"; import { EditorView } from "@codemirror/view"; @@ -124,6 +122,131 @@ function estimatedBase64Length(size: number): number { return Math.ceil(size / 3) * 4; } +/** Shared transport bounds preserve enumeration order and stop at the aggregate ceiling. */ +function visitTransferFiles( + files: readonly File[], + accept: (file: File, slot: number) => void, + refuse: (slot: number) => void +) { + if (files.length > MAX_IMAGES_PER_EVENT) { + console.warn( + `[quoll] dropped ${files.length - MAX_IMAGES_PER_EVENT} image(s) (per-event count cap of ${MAX_IMAGES_PER_EVENT} reached)` + ); + } + let totalBytes = 0; + let aggregateExceeded = false; + files.forEach((file, slot) => { + if (slot >= MAX_IMAGES_PER_EVENT || aggregateExceeded) { + refuse(slot); + } else if (estimatedBase64Length(file.size) > MAX_IMAGE_DATA_LENGTH) { + console.warn("[quoll] dropped oversized image (exceeds transfer ceiling)"); + refuse(slot); + } else if (totalBytes + file.size > MAX_TOTAL_IMAGE_BYTES_PER_EVENT) { + console.warn("[quoll] dropped image (per-event aggregate byte cap reached)"); + aggregateExceeded = true; + refuse(slot); + } else { + totalBytes += file.size; + accept(file, slot); + } + }); +} + +/** File transport settles callbacks only; it never retains or edits an EditorView. */ +export function createImageFileTransport(opts: { post: (message: WebviewToHost) => void }) { + let seq = 0; + // Late replies from an earlier webview session must not resolve a fresh request. + const sessionNonce = crypto.randomUUID(); + const pending = new Map void>(); + function resolve(requestId: string, path: string | null): boolean { + const callback = pending.get(requestId); + if (!callback) { + return false; + } + pending.delete(requestId); + callback(path); + return true; + } + function submit( + file: File, + onResult: (path: string | null) => void, + onRequest?: (requestId: string) => void, + onFailure?: () => void + ): () => void { + const requestId = `${sessionNonce}-${++seq}`; + pending.set(requestId, onResult); + onRequest?.(requestId); + const failed = () => { + if (pending.delete(requestId)) { + if (onFailure) { + onFailure(); + } else { + onResult(null); + } + } + }; + const reader = new FileReader(); + reader.onload = () => { + const result = reader.result; + if (typeof result !== "string") { + // Defensive browser-contract failure; log metadata without clipboard bytes. + console.warn("[quoll] dropped pasted image (FileReader returned a non-string result)", { + requestId, + }); + failed(); + return; + } + const comma = result.indexOf(","); + const base64 = comma >= 0 ? result.slice(comma + 1) : ""; + if (base64 === "" && (file.size !== 0 || comma < 0)) { + // Empty Files are forwarded, but malformed or unexpectedly empty reads fail locally. + console.warn("[quoll] dropped pasted image (data URL carried no base64 payload)", { + requestId, + }); + failed(); + return; + } + const ok = safePostMessage( + { postMessage: opts.post }, + { protocol: PROTOCOL_VERSION, type: "image-write", requestId, data: base64 }, + "image-write" + ); + if (!ok) { + failed(); + } + }; + reader.onerror = () => { + // The browser error distinguishes read failures without exposing file contents. + console.error("[quoll] failed to read pasted image", { err: reader.error, requestId }); + failed(); + }; + reader.onabort = failed; + try { + reader.readAsDataURL(file); + } catch { + failed(); + } + return () => { + pending.delete(requestId); + reader.onload = reader.onerror = reader.onabort = null; + reader.abort?.(); + }; + } + function start(files: readonly File[], onResult: (slot: number, path: string | null) => void) { + const cancel: (() => void)[] = []; + visitTransferFiles( + files, + (file, slot) => cancel.push(submit(file, (path) => onResult(slot, path))), + (slot) => onResult(slot, null) + ); + return () => + cancel.forEach((release) => { + release(); + }); + } + return { submit, start, resolve }; +} + export function createImagePasteDrop(opts: { canWrite: () => boolean; post: (message: WebviewToHost) => void; @@ -131,14 +254,6 @@ export function createImagePasteDrop(opts: { extension: Extension; resolve: (view: EditorView, requestId: string, relativePath: string | null) => void; } { - let seq = 0; - // Per-session nonce so requestIds never collide across a webview reload. A bare - // counter resets to 1 on reload; a late image-write-result from the previous - // session (same "1") would otherwise resolve a fresh pending anchor and insert - // the wrong image path. With the nonce, a stale reply falls into resolve()'s - // unknown-requestId no-op. - const sessionNonce = crypto.randomUUID(); - // Dispatch guarded against a view torn down mid-round-trip (tab closed between // FileReader start and callback). CM 6.43 does NOT actually throw there — // `EditorView.update` early-returns on `this.destroyed` (measured; see the @@ -170,66 +285,18 @@ export function createImagePasteDrop(opts: { } }; + const transport = createImageFileTransport({ post: opts.post }); const submit = (view: EditorView, file: File, anchor: number): void => { - const requestId = `${sessionNonce}-${++seq}`; - // Register the anchor BEFORE the async read so it maps from capture time. - // Synchronous, inside the live event handler → no destroyed-view risk here. - view.dispatch({ effects: addPendingAnchor.of({ requestId, anchor }) }); - const reader = new FileReader(); - reader.onload = () => { - const result = reader.result; - if (typeof result !== "string") { - // Defensive: `readAsDataURL` is specified to yield a string, so this arm is - // not reachable through a conforming browser. Logged anyway, for the same - // reason clearPending's catch is: an unreachable branch that fires is - // exactly the one nobody will guess at, and a paste that vanishes with no - // trace is the failure this module keeps having. - // `requestId` carried like every other log here: one paste event can start - // several concurrent reads, and two bare copies of this line would be - // indistinguishable — you could not tell which image of the batch died. - console.warn("[quoll] dropped pasted image (FileReader returned a non-string result)", { - requestId, - }); - clearPending(view, requestId); - return; - } - const comma = result.indexOf(","); - const base64 = comma >= 0 ? result.slice(comma + 1) : ""; - if (base64 === "") { - // Reachable only for a malformed data URL — a zero-byte file, the other way - // to land an empty payload here, is already refused (with its own warn) back - // in `handle`. Either way the paste is abandoned on a writable doc, so it says so. - console.warn("[quoll] dropped pasted image (data URL carried no base64 payload)", { - requestId, - }); - clearPending(view, requestId); - return; - } - const ok = safePostMessage( - { postMessage: opts.post }, - { protocol: PROTOCOL_VERSION, type: "image-write", requestId, data: base64 }, - "image-write" - ); - if (!ok) { - clearPending(view, requestId); - } - }; - reader.onerror = () => { - // No webview toast channel; rare browser-internal failure. Log + clear the - // pending anchor so nothing leaks. (Documented in the security-audit note.) - // `reader.error` is passed on because it is the ONLY account of the failure - // that exists — a DOMException whose `name` separates NotReadableError (the - // file moved/permissions) from the rest. It is metadata about the read, not - // image content, so it is safe to log. Matches the policy in - // `paste/html-to-markdown.ts`, which logs its caught `err` for the same reason. - // Paired with `requestId` for the reason the two warns above are: concurrent - // reads from one paste event are only tellable apart by it, and this is the arm - // most likely to hit several at once (a batch dropped from a folder that then - // moves). - console.error("[quoll] failed to read pasted image", { err: reader.error, requestId }); - clearPending(view, requestId); - }; - reader.readAsDataURL(file); + let requestId = ""; + transport.submit( + file, + (path) => resolveLegacy(view, requestId, path), + (id) => { + requestId = id; + view.dispatch({ effects: addPendingAnchor.of({ requestId, anchor }) }); + }, + () => clearPending(view, requestId) + ); }; // Caller contract: invoked only with files.length > 0 AND canWrite() === true, after the @@ -243,50 +310,17 @@ export function createImagePasteDrop(opts: { // regardless. Kept as belt-and-braces, and because it is the honest answer to "did this // handler own the event?" on the branch where every file was refused. const handle = (view: EditorView, files: File[], anchor: number): boolean => { - // Warned OUTSIDE the loop because this refusal is a property of the EVENT, not - // of any one file: the overflow is discarded whole, so the loop never meets the - // files it drops and could only ever report "and some more". Stated up front, - // with the exact count, it is the one refusal a reader can act on directly. - if (files.length > MAX_IMAGES_PER_EVENT) { - const dropped = files.length - MAX_IMAGES_PER_EVENT; - console.warn( - `[quoll] dropped ${dropped} image(s) (per-event count cap of ${MAX_IMAGES_PER_EVENT} reached)` - ); - } - let totalBytes = 0; - for (const file of files.slice(0, MAX_IMAGES_PER_EVENT)) { - if (file.size === 0) { - // The only refusal in this loop that used to be silent, and the one with the - // widest blast radius. `handle` is shared by the paste and drop paths: on the - // PASTE path `isIngestibleImageItem` never looks at size, so richHtmlPaste has - // already handed the event on for this item; on the DROP path there is no such - // upstream. Either way this handler then preventDefaults for the event, skips - // every file and returns true — the event is fully consumed, no image is - // inserted, and (on paste) CM's plain-text fallback is suppressed too. Unless a - // converted HTML flavour rode along (richHtmlPaste inserted that first), the - // paste vanishes with no trace anywhere. - // - // Deliberately NOT hoisted into `isIngestibleImageItem`: the per-event - // aggregate cap below is order-dependent and cannot live in a per-item - // membership predicate at all, so hoisting the size refusals would only make - // the shared floor LOOK like imagePaste's acceptance set while still not - // being it. The predicate's honest contract is "this item is an image - // candidate"; refusals belong in `handle`, and every one of them warns — - // this loop's three plus the per-event count cap above it. - console.warn("[quoll] dropped empty image file (zero bytes)"); - continue; - } - if (estimatedBase64Length(file.size) > MAX_IMAGE_DATA_LENGTH) { - console.warn("[quoll] dropped oversized image (exceeds transfer ceiling)"); - continue; - } - if (totalBytes + file.size > MAX_TOTAL_IMAGE_BYTES_PER_EVENT) { - console.warn("[quoll] dropped image (per-event aggregate byte cap reached)"); - break; - } - totalBytes += file.size; - submit(view, file, anchor); - } + visitTransferFiles( + files, + (file) => { + if (file.size === 0) { + console.warn("[quoll] dropped empty image file (zero bytes)"); + return; + } + submit(view, file, anchor); + }, + () => {} + ); return true; }; @@ -325,7 +359,11 @@ export function createImagePasteDrop(opts: { }), ]; - const resolve = (view: EditorView, requestId: string, relativePath: string | null): void => { + const resolveLegacy = ( + view: EditorView, + requestId: string, + relativePath: string | null + ): void => { const pending = view.state.field(pendingImageAnchors).find((p) => p.requestId === requestId); if (!pending) { // unknown / duplicate / already-resolved / cleared by a reseed. The last of @@ -468,5 +506,12 @@ export function createImagePasteDrop(opts: { } }; - return { extension, resolve }; + return { + extension, + resolve: (view: EditorView, requestId: string, path: string | null) => { + if (!transport.resolve(requestId, path)) { + resolveLegacy(view, requestId, path); + } + }, + }; } diff --git a/src/webview/cm/paste/clipboard-paste.ts b/src/webview/cm/paste/clipboard-paste.ts new file mode 100644 index 00000000..c6540ec1 --- /dev/null +++ b/src/webview/cm/paste/clipboard-paste.ts @@ -0,0 +1,113 @@ +import { type EditorState, Prec } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; + +import type { WebviewToHost } from "../../../shared/protocol.js"; +import { createImageFileTransport } from "../image/image-paste.js"; +import { decideTablePaste } from "./html-table-paste.js"; +import { decideListPaste } from "./list-reindent.js"; +import { createPasteOperationCoordinator, type PasteDecision } from "./paste-operation.js"; +import { decideRichPaste } from "./rich-html-paste.js"; +import { decideUrlPaste } from "./url-link-paste.js"; + +// Capture each clipboard flavour and File once, before the DOM event returns. +export function snapshotClipboard(data: DataTransfer | null) { + const types = Array.from(data?.types ?? []); + const flavour = (type: string) => ({ + present: types.includes(type), + value: data?.getData(type) ?? "", + }); + const files: File[] = []; + for (const item of Array.from(data?.items ?? [])) { + if (item.kind !== "file") { + continue; + } + const file = item.getAsFile(); + if (file) { + files.push(file); + } + } + return { + plain: flavour("text/plain"), + html: flavour("text/html"), + uri: flavour("text/uri-list"), + files, + }; +} + +/** First applicable representation owns its complete replacement range. */ +export function decideClipboardPaste( + snapshot: ReturnType, + state: EditorState +): PasteDecision | null { + const table = decideTablePaste(state, snapshot.html.value); + if (table) { + return table; + } + const url = decideUrlPaste(state, snapshot.plain.value); + if (url) { + return url; + } + const list = decideListPaste(state, snapshot.plain.value); + if (list) { + return list; + } + const rich = decideRichPaste( + state, + snapshot.html.value, + snapshot.plain.value || snapshot.uri.value, + snapshot.files.length > 0 + ); + if (rich?.emittedMarkdownSyntax) { + return rich; + } + return null; +} + +/** Independent clipboard entry; production remains on the temporary legacy adapter. */ +export function createClipboardPaste(options: { + canWrite: () => boolean; + post: (message: WebviewToHost) => void; + onFailure: () => void; + onUnlinkedPath?: (path: string) => void; +}) { + const transport = createImageFileTransport({ post: options.post }); + let capturedFiles: readonly File[] = []; + const coordinator = createPasteOperationCoordinator({ + onFailure: options.onFailure, + onUnlinkedPath: options.onUnlinkedPath, + startResources: (id) => + transport.start(capturedFiles, (slot, path) => coordinator.settle(id, slot, path)), + onReady: (id): void => { + coordinator.complete(id); + }, + }); + return { + extension: [ + coordinator.extension, + Prec.high( + EditorView.domEventHandlers({ + paste(event, view) { + const snapshot = snapshotClipboard(event.clipboardData); + const text = snapshot.plain.value || snapshot.uri.value; + const decided = decideClipboardPaste(snapshot, view.state); + if (!snapshot.files.length && text && !decided) { + return false; + } + event.preventDefault(); + if (!options.canWrite()) { + return true; + } + const { from, to } = view.state.selection.main; + const decision: PasteDecision = decided ?? { from, to, render: () => text }; + capturedFiles = snapshot.files; + coordinator.capture(view, decision, snapshot.files.length); + capturedFiles = []; + return true; + }, + }) + ), + ], + resolve: transport.resolve, + dispose: coordinator.dispose, + }; +} diff --git a/src/webview/cm/paste/index.ts b/src/webview/cm/paste/index.ts index ef910c15..78cfccbb 100644 --- a/src/webview/cm/paste/index.ts +++ b/src/webview/cm/paste/index.ts @@ -1,3 +1,4 @@ +export { createClipboardPaste } from "./clipboard-paste.js"; export { htmlTablePaste } from "./html-table-paste.js"; export { htmlTableToGfm, tableElementToGfm } from "./html-table-to-gfm.js"; export { type HtmlToMarkdownResult, htmlToMarkdown } from "./html-to-markdown.js"; diff --git a/src/webview/cm/paste/url-link-paste.ts b/src/webview/cm/paste/url-link-paste.ts index accfd7a3..bb91e1c0 100644 --- a/src/webview/cm/paste/url-link-paste.ts +++ b/src/webview/cm/paste/url-link-paste.ts @@ -79,11 +79,11 @@ const GUARD_PARSE_BUDGET_MS = 50; * (defer) when the tree can't be produced within the budget, so an unclassified * selection is never wrapped. */ -function selectionIsPlainText(view: EditorView, from: number, to: number): boolean { - if (!markdownLanguage.isActiveAt(view.state, from, 1)) { +export function selectionIsPlainText(state: EditorState, from: number, to: number): boolean { + if (!markdownLanguage.isActiveAt(state, from, 1)) { return false; } - const tree = ensureSyntaxTree(view.state, to, GUARD_PARSE_BUDGET_MS); + const tree = ensureSyntaxTree(state, to, GUARD_PARSE_BUDGET_MS); if (tree === null) { return false; // tree unavailable for the selection → can't verify → defer } @@ -141,14 +141,13 @@ function insertLinkOverSelection(view: EditorView, url: AllowlistedUrl): void { } /** Capture the URL and label range; rendering does not retain the view. */ -export function decideUrlPaste(view: EditorView, text: string) { - const { state } = view; +export function decideUrlPaste(state: EditorState, text: string) { const { from, to, empty } = state.selection.main; if (empty || state.doc.lineAt(from).number !== state.doc.lineAt(to).number) { return null; } const url = detectPasteLinkUrl(text); - if (url === null || !selectionIsPlainText(view, from, to)) { + if (url === null || !selectionIsPlainText(state, from, to)) { return null; } return { @@ -156,7 +155,10 @@ export function decideUrlPaste(view: EditorView, text: string) { to, url, render: (live: EditorState, start: number, end: number) => - `[${live.doc.sliceString(start, end)}](${linkDestination(url)})`, + live.doc.lineAt(start).number === live.doc.lineAt(end).number && + selectionIsPlainText(live, start, end) + ? `[${live.doc.sliceString(start, end)}](${linkDestination(url)})` + : text, }; } @@ -165,7 +167,7 @@ export function pasteUrlOverSelection(opts: { canWrite: () => boolean }): Extens EditorView.domEventHandlers({ paste: (event, view) => { const text = event.clipboardData?.getData("text/plain"); - const decision = text ? decideUrlPaste(view, text) : null; + const decision = text ? decideUrlPaste(view.state, text) : null; if (decision === null) { return false; } diff --git a/test/webview/image/cm-image-paste.test.ts b/test/webview/image/cm-image-paste.test.ts index db637980..be4c5e74 100644 --- a/test/webview/image/cm-image-paste.test.ts +++ b/test/webview/image/cm-image-paste.test.ts @@ -12,6 +12,7 @@ import { import { hostDocumentReseed } from "../../../src/webview/cm/host-reseed.js"; import { addPendingAnchor, + createImageFileTransport, createImagePasteDrop, isIngestibleImageItem, pendingImageAnchors, @@ -1093,3 +1094,78 @@ describe("imagePaste — drop and dragover", () => { view.destroy(); }); }); + +it("extracted transport never inserts and legacy adapter preserves paste behavior", async () => { + const { view, paste, post } = mount("hello"); + const settled = vi.fn(); + const transport = createImageFileTransport({ post }); + transport.submit(pngFile(), settled); + await vi.waitFor(() => expect(imageWrites(post)).toHaveLength(1)); + const first = imageWrites(post)[0]; + transport.resolve(first.requestId, "./assets/transport.png"); + expect(settled).toHaveBeenCalledWith("./assets/transport.png"); + expect(view.state.doc.toString()).toBe("hello"); + firePasteAt(view.contentDOM, { files: IMAGE_FILE }); + await vi.waitFor(() => expect(imageWrites(post)).toHaveLength(2)); + const second = imageWrites(post)[1]; + paste.resolve(view, second.requestId, "./assets/legacy.png"); + expect(view.state.doc.toString()).toBe("![](./assets/legacy.png)\nhello"); + expect(view.state.field(pendingImageAnchors)).toEqual([]); + view.destroy(); +}); + +it.each([ + "encoded", + "count", + "aggregate", +])("transport ceiling refusal settles without posting (%s)", (ceiling) => { + stubFileReader((reader) => { + reader.result = "data:image/png;base64,eA=="; + reader.onload?.(); + }); + const post = vi.fn<(message: WebviewToHost) => void>(); + const settled = vi.fn(); + const transport = createImageFileTransport({ post }); + const files = + ceiling === "encoded" + ? [sizedImageFile(20 * MIB)] + : ceiling === "count" + ? Array.from({ length: 17 }, pngFile) + : Array.from({ length: 6 }, () => sizedImageFile(8 * MIB)); + transport.start(files, settled); + const accepted = ceiling === "encoded" ? 0 : ceiling === "count" ? 16 : 5; + expect(imageWrites(post)).toHaveLength(accepted); + expect(settled.mock.calls).toEqual([[accepted, null]]); +}); + +it("zero-byte candidate reaches host as empty base64", async () => { + const post = vi.fn<(message: WebviewToHost) => void>(); + const settled = vi.fn(); + const transport = createImageFileTransport({ post }); + transport.start([new File([], "empty", { type: "image/png" })], settled); + await vi.waitFor(() => expect(imageWrites(post)).toHaveLength(1)); + expect(imageWrites(post)[0]).toEqual({ + protocol: PROTOCOL_VERSION, + type: "image-write", + requestId: expect.any(String), + data: "", + }); + expect(settled).not.toHaveBeenCalled(); + transport.resolve(imageWrites(post)[0].requestId, null); + expect(settled).toHaveBeenCalledWith(0, null); +}); + +it.each([ + "application/pdf", + "", +])("non-image MIME candidate reaches host byte decision (%s)", async (type) => { + const post = vi.fn<(message: WebviewToHost) => void>(); + const settled = vi.fn(); + const transport = createImageFileTransport({ post }); + transport.start([new File([PNG_BYTES], "untyped", { type })], settled); + await vi.waitFor(() => expect(imageWrites(post)).toHaveLength(1)); + const request = imageWrites(post)[0]; + expect(decodeBase64(request.data)).toEqual(PNG_BYTES); + transport.resolve(request.requestId, "./assets/accepted.png"); + expect(settled).toHaveBeenCalledWith(0, "./assets/accepted.png"); +}); diff --git a/test/webview/paste/cm-clipboard-paste.test.ts b/test/webview/paste/cm-clipboard-paste.test.ts new file mode 100644 index 00000000..ce7f0589 --- /dev/null +++ b/test/webview/paste/cm-clipboard-paste.test.ts @@ -0,0 +1,257 @@ +// @vitest-environment happy-dom +import { EditorState } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +import { afterEach, expect, it, vi } from "vitest"; + +import { quollMarkdownLanguage } from "../../../src/webview/cm/markdown.js"; +import { + createClipboardPaste, + snapshotClipboard, +} from "../../../src/webview/cm/paste/clipboard-paste.js"; +import { pendingPasteOperations } from "../../../src/webview/cm/paste/paste-operation.js"; +import { firePasteAt, makeClipboardData } from "../helpers/clipboard-double.js"; + +it("all obtainable Files become transfer candidates", () => { + const files = [ + new File(["png"], "a", { type: "image/png" }), + new File(["pdf"], "b", { type: "application/pdf" }), + new File(["unknown"], "c"), + new File([], "d", { type: "image/png" }), + ]; + const data = makeClipboardData({ + text: "", + html: "

caption

", + files: [...files.map((file) => ({ type: file.type, file })), { type: "image/png", file: null }], + }); + const getters = data.items.map((item) => vi.spyOn(item, "getAsFile")); + const snapshot = snapshotClipboard(data as unknown as DataTransfer); + expect(snapshot.files).toEqual(files); + expect(snapshot.plain).toEqual({ present: true, value: "" }); + expect(snapshot.uri).toEqual({ present: false, value: "" }); + expect(snapshot.html).toEqual({ present: true, value: "

caption

" }); + getters.forEach((getter, index) => { + expect(getter).toHaveBeenCalledTimes(data.items[index].kind === "file" ? 1 : 0); + }); +}); + +const views: EditorView[] = []; +afterEach(() => { + views.forEach((view) => { + view.destroy(); + }); + views.length = 0; + vi.restoreAllMocks(); +}); +function mount(doc = "", anchor = 0, head = anchor) { + const post = vi.fn(); + const onFailure = vi.fn(); + const paste = createClipboardPaste({ canWrite: () => true, post, onFailure }); + let edits = 0; + const view = new EditorView({ + state: EditorState.create({ + doc, + selection: { anchor, head }, + extensions: [ + quollMarkdownLanguage(), + paste.extension, + EditorView.updateListener.of((update) => { + if (update.docChanged) { + edits++; + } + }), + ], + }), + }); + views.push(view); + return { view, paste, post, onFailure, edits: () => edits }; +} +it("ordinary text-only paste delegates once to CM", () => { + const { view, post, edits } = mount("hello", 5); + firePasteAt(view.contentDOM, { text: " world" }); + expect(view.state.doc.toString()).toBe("hello world"); + expect(edits()).toBe(1); + expect(post).not.toHaveBeenCalled(); +}); + +it("table and File produce one decided table replacement", async () => { + const { view, paste, post, edits } = mount(); + firePasteAt(view.contentDOM, { + html: "
A
B
", + text: "wrong plain", + files: [{ type: "image/png" }], + }); + expect(edits()).toBe(0); + await vi.waitFor(() => expect(post).toHaveBeenCalledTimes(1)); + paste.resolve(post.mock.calls[0][0].requestId, "./assets/a.png"); + expect(view.state.doc.toString()).toBe("| A |\n| --- |\n| B |\n![](./assets/a.png)\n"); + expect(edits()).toBe(1); + expect(view.state.field(pendingPasteOperations)).toEqual([]); +}); + +it("URL and File preserve selected label in one replacement", async () => { + const { view, paste, post, edits } = mount("label", 0, 5); + firePasteAt(view.contentDOM, { text: "https://example.com", files: [{ type: "image/png" }] }); + expect(edits()).toBe(0); + await vi.waitFor(() => expect(post).toHaveBeenCalledTimes(1)); + paste.resolve(post.mock.calls[0][0].requestId, "./assets/a.png"); + expect(view.state.doc.toString()).toBe("[label](https://example.com)\n![](./assets/a.png)\n"); + expect(edits()).toBe(1); + expect(view.state.field(pendingPasteOperations)).toEqual([]); +}); + +async function pendingUrl() { + const mounted = mount("label", 0, 5); + firePasteAt(mounted.view.contentDOM, { + text: "https://example.com", + files: [{ type: "image/png" }], + }); + await vi.waitFor(() => expect(mounted.post).toHaveBeenCalledTimes(1)); + return { ...mounted, requestId: mounted.post.mock.calls[0][0].requestId as string }; +} +it("pending URL wrap rechecks live syntax", async () => { + const { view, paste, requestId, edits } = await pendingUrl(); + view.dispatch({ + changes: [ + { from: 0, insert: "[" }, + { from: 5, insert: "](https://outer.example)" }, + ], + }); + paste.resolve(requestId, null); + expect(view.state.doc.toString()).toBe("[https://example.com](https://outer.example)"); + expect(edits()).toBe(2); + expect(view.state.field(pendingPasteOperations)).toEqual([]); +}); + +it("pending URL fallback retains accepted image", async () => { + const { view, paste, requestId, edits } = await pendingUrl(); + view.dispatch({ + changes: [ + { from: 0, insert: "[" }, + { from: 5, insert: "](https://outer.example)" }, + ], + }); + paste.resolve(requestId, "./assets/a.png"); + expect(view.state.doc.toString()).toBe( + "[https://example.com\n![](./assets/a.png)\n](https://outer.example)" + ); + expect(edits()).toBe(2); + expect(view.state.field(pendingPasteOperations)).toEqual([]); +}); + +it("pending URL falls back after outside fence insertion", async () => { + const { view, paste, requestId } = await pendingUrl(); + view.dispatch({ changes: { from: 0, insert: "```\n" } }); + paste.resolve(requestId, null); + expect(view.state.doc.toString()).toBe("```\nhttps://example.com"); + expect(view.state.field(pendingPasteOperations)).toEqual([]); +}); + +it("unrelated prose edit retains valid pending URL wrapping", async () => { + const { view, paste, post } = mount("label\n\n`code`", 0, 5); + firePasteAt(view.contentDOM, { text: "https://example.com", files: [{ type: "image/png" }] }); + await vi.waitFor(() => expect(post).toHaveBeenCalledTimes(1)); + view.dispatch({ + changes: { from: 0, insert: "prefix\n\n" }, + selection: { anchor: 16, head: 20 }, + }); + paste.resolve(post.mock.calls[0][0].requestId, null); + expect(view.state.doc.toString()).toBe("prefix\n\n[label](https://example.com)\n\n`code`"); + expect(view.state.sliceDoc(view.state.selection.main.from, view.state.selection.main.to)).toBe( + "code" + ); +}); + +it.each([ + null, + "./assets/a.png", +])("list fragment + File with leading whitespace (%s)", async (path) => { + const { view, paste, post, edits } = mount("- a\n - b\n \n more", 12); + firePasteAt(view.contentDOM, { text: "- x\n - y\n- z", files: [{ type: "image/png" }] }); + expect(edits()).toBe(0); + await vi.waitFor(() => expect(post).toHaveBeenCalledTimes(1)); + expect(view.state.field(pendingPasteOperations)[0]).toMatchObject({ from: 10, to: 12 }); + paste.resolve(post.mock.calls[0][0].requestId, path); + expect(view.state.doc.toString()).toBe( + `- a\n - b\n - x\n - y\n - z${path ? "\n![](./assets/a.png)\n" : ""}\n more` + ); + expect(edits()).toBe(1); + expect(view.state.field(pendingPasteOperations)).toEqual([]); +}); + +it("rich syntax and File survive in one insertion", async () => { + const { view, paste, post, edits } = mount(); + firePasteAt(view.contentDOM, { + html: "

bold

", + text: "bold", + files: [{ type: "image/png" }], + }); + expect(edits()).toBe(0); + await vi.waitFor(() => expect(post).toHaveBeenCalledTimes(1)); + paste.resolve(post.mock.calls[0][0].requestId, "./assets/a.png"); + expect(view.state.doc.toString()).toBe("**bold**\n![](./assets/a.png)\n"); + expect(edits()).toBe(1); + expect(view.state.field(pendingPasteOperations)).toEqual([]); +}); + +it.each([ + false, + true, +])("syntax-bearing HTML yields to applicable URL plain over selection (%s)", async (withFile) => { + const { view, paste, post, edits } = mount("label", 0, 5); + firePasteAt(view.contentDOM, { + html: "

rich

", + text: "https://example.com", + files: withFile ? [{ type: "image/png" }] : [], + }); + if (withFile) { + expect(edits()).toBe(0); + await vi.waitFor(() => expect(post).toHaveBeenCalledTimes(1)); + paste.resolve(post.mock.calls[0][0].requestId, "./assets/a.png"); + } + expect(view.state.doc.toString()).toBe( + `[label](https://example.com)${withFile ? "\n![](./assets/a.png)\n" : ""}` + ); + expect(edits()).toBe(1); + expect(post).toHaveBeenCalledTimes(withFile ? 1 : 0); +}); + +it.each([ + false, + true, +])("syntax-bearing HTML yields to applicable list plain at eligible blank line (%s)", async (withFile) => { + const { view, paste, post, edits } = mount("- a\n - b\n \n more", 12); + firePasteAt(view.contentDOM, { + html: "

rich

", + text: "- x\n - y\n- z", + files: withFile ? [{ type: "image/png" }] : [], + }); + if (withFile) { + expect(edits()).toBe(0); + await vi.waitFor(() => expect(post).toHaveBeenCalledTimes(1)); + paste.resolve(post.mock.calls[0][0].requestId, "./assets/a.png"); + } + expect(view.state.doc.toString()).toBe( + `- a\n - b\n - x\n - y\n - z${withFile ? "\n![](./assets/a.png)\n" : ""}\n more` + ); + expect(edits()).toBe(1); + expect(post).toHaveBeenCalledTimes(withFile ? 1 : 0); +}); + +async function pendingRich(text?: string) { + const mounted = mount("target", 0, 6); + firePasteAt(mounted.view.contentDOM, { + html: "

rich

", + text, + files: [{ type: "image/png" }], + }); + await vi.waitFor(() => expect(mounted.post).toHaveBeenCalledTimes(1)); + mounted.view.dispatch({ changes: { from: 0, insert: "```\n" } }); + return { ...mounted, requestId: mounted.post.mock.calls[0][0].requestId as string }; +} +it("rich prose-to-code fallback retains accepted image", async () => { + const { view, paste, requestId, edits } = await pendingRich("captured"); + paste.resolve(requestId, "./assets/a.png"); + expect(view.state.doc.toString()).toBe("```\ncaptured\n![](./assets/a.png)\n"); + expect(edits()).toBe(2); + expect(view.state.field(pendingPasteOperations)).toEqual([]); +}); diff --git a/test/webview/paste/cm-paste-url-link.test.ts b/test/webview/paste/cm-paste-url-link.test.ts index 2e5da254..08c804d0 100644 --- a/test/webview/paste/cm-paste-url-link.test.ts +++ b/test/webview/paste/cm-paste-url-link.test.ts @@ -6,7 +6,10 @@ import { describe, expect, it } from "vitest"; import { quollMarkdownLanguage } from "../../../src/webview/cm/markdown.js"; import { detectPasteLinkUrl, pasteUrlOverSelection } from "../../../src/webview/cm/paste/index.js"; -import { decideUrlPaste } from "../../../src/webview/cm/paste/url-link-paste.js"; +import { + decideUrlPaste, + selectionIsPlainText, +} from "../../../src/webview/cm/paste/url-link-paste.js"; import { settledMount } from "../helpers/settled-view.js"; describe("detectPasteLinkUrl — URL detection boundary", () => { @@ -224,7 +227,7 @@ it("URL decision preserves selected label", async () => { extensions: [quollMarkdownLanguage()], }); try { - const decision = decideUrlPaste(view, "https://example.com/a(b)"); + const decision = decideUrlPaste(view.state, "https://example.com/a(b)"); expect(decision).not.toBeNull(); expect({ from: decision!.from, to: decision!.to }).toEqual({ from: 7, to: 12 }); expect(decision!.render(view.state, 7, 12)).toBe("[label]()"); @@ -233,3 +236,31 @@ it("URL decision preserves selected label", async () => { view.destroy(); } }); + +it("URL syntax guard uses supplied state and owned range", () => { + const state = EditorState.create({ + doc: "label `code`", + selection: { anchor: 7, head: 11 }, + extensions: [quollMarkdownLanguage()], + }); + expect(selectionIsPlainText(state, 0, 5)).toBe(true); + expect(selectionIsPlainText(state, 7, 11)).toBe(false); +}); + +it.each([ + "multiline", + "unclassified", +])("URL live guard falls back for multiline or unclassified range (%s)", (kind) => { + const initial = EditorState.create({ + doc: "label", + selection: { anchor: 0, head: 5 }, + extensions: [quollMarkdownLanguage()], + }); + const decision = decideUrlPaste(initial, "https://example.com"); + expect(decision).not.toBeNull(); + const live = EditorState.create({ + doc: kind === "multiline" ? "label\nmore" : "label", + extensions: kind === "multiline" ? [quollMarkdownLanguage()] : [], + }); + expect(decision?.render(live, 0, live.doc.length)).toBe("https://example.com"); +}); From 051a8ecc3f19c869d50cdda468a3e4512f28db97 Mon Sep 17 00:00:00 2001 From: Mitsuki Fukunaga Date: Tue, 10 Nov 2026 08:33:33 +0900 Subject: [PATCH 4/9] fix(paste): keep the target when a paste yields no content Rich code-context fallback, caption-on-rejection and pre/post-commit failure handling: an empty composed replacement terminates the operation without deleting the selection, and an adopted insertion is never retried. --- src/webview/cm/paste/clipboard-paste.ts | 8 + src/webview/cm/paste/paste-operation.ts | 47 ++++-- test/webview/paste/cm-clipboard-paste.test.ts | 67 +++++++- test/webview/paste/cm-paste-operation.test.ts | 153 +++++++++++++++++- 4 files changed, 257 insertions(+), 18 deletions(-) diff --git a/src/webview/cm/paste/clipboard-paste.ts b/src/webview/cm/paste/clipboard-paste.ts index c6540ec1..456be430 100644 --- a/src/webview/cm/paste/clipboard-paste.ts +++ b/src/webview/cm/paste/clipboard-paste.ts @@ -60,6 +60,10 @@ export function decideClipboardPaste( if (rich?.emittedMarkdownSyntax) { return rich; } + if (rich) { + const text = snapshot.plain.value || snapshot.uri.value; + return text ? { from: rich.from, to: rich.to, render: () => text } : rich; + } return null; } @@ -97,6 +101,10 @@ export function createClipboardPaste(options: { if (!options.canWrite()) { return true; } + if (!snapshot.files.length && !text && !decided) { + options.onFailure(); + return true; + } const { from, to } = view.state.selection.main; const decision: PasteDecision = decided ?? { from, to, render: () => text }; capturedFiles = snapshot.files; diff --git a/src/webview/cm/paste/paste-operation.ts b/src/webview/cm/paste/paste-operation.ts index a8717b6c..0fb95b9d 100644 --- a/src/webview/cm/paste/paste-operation.ts +++ b/src/webview/cm/paste/paste-operation.ts @@ -264,24 +264,39 @@ export function createPasteOperationCoordinator(options: { return; } const state = view.state; - const insert = composePasteReplacement(state, decision, target, slots); - const spec = { - changes: { from: target.from, to: target.to, insert }, - selection: target.selectionMoved ? undefined : { anchor: target.from + insert.length }, - scrollIntoView: !target.selectionMoved, - userEvent: "input.paste", - annotations: [completingPasteOperation.of(id), isolateHistory.of("full")], - effects: removePasteOperation.of(id), - }; - const expected = state.changes(spec.changes).apply(state.doc); - const tr = state.update(spec); - if (!tr.newDoc.eq(expected)) { + try { + const insert = composePasteReplacement(state, decision, target, slots); + if (!insert.length) { + fail(view, id); + return; + } + const spec = { + changes: { from: target.from, to: target.to, insert }, + selection: target.selectionMoved ? undefined : { anchor: target.from + insert.length }, + scrollIntoView: !target.selectionMoved, + userEvent: "input.paste", + annotations: [completingPasteOperation.of(id), isolateHistory.of("full")], + effects: removePasteOperation.of(id), + }; + const expected = state.changes(spec.changes).apply(state.doc); + const tr = state.update(spec); + if (!tr.newDoc.eq(expected)) { + fail(view, id); + return; + } + view.dispatch(tr); + release(id); + clearMetadata(view, id); + } catch { + if (view.state !== state) { + // Adoption is terminal even when dispatch did not finish successfully. + release(id); + clearMetadata(view, id); + options.onFailure(); + return; + } fail(view, id); - return; } - view.dispatch(tr); - release(id); - clearMetadata(view, id); }, settle, results: (id: string) => operations.get(id)?.slots.slice(), diff --git a/test/webview/paste/cm-clipboard-paste.test.ts b/test/webview/paste/cm-clipboard-paste.test.ts index ce7f0589..472b9f02 100644 --- a/test/webview/paste/cm-clipboard-paste.test.ts +++ b/test/webview/paste/cm-clipboard-paste.test.ts @@ -47,6 +47,7 @@ function mount(doc = "", anchor = 0, head = anchor) { const onFailure = vi.fn(); const paste = createClipboardPaste({ canWrite: () => true, post, onFailure }); let edits = 0; + let transactions = 0; const view = new EditorView({ state: EditorState.create({ doc, @@ -55,6 +56,7 @@ function mount(doc = "", anchor = 0, head = anchor) { quollMarkdownLanguage(), paste.extension, EditorView.updateListener.of((update) => { + transactions += update.transactions.length; if (update.docChanged) { edits++; } @@ -63,7 +65,7 @@ function mount(doc = "", anchor = 0, head = anchor) { }), }); views.push(view); - return { view, paste, post, onFailure, edits: () => edits }; + return { view, paste, post, onFailure, edits: () => edits, transactions: () => transactions }; } it("ordinary text-only paste delegates once to CM", () => { const { view, post, edits } = mount("hello", 5); @@ -255,3 +257,66 @@ it("rich prose-to-code fallback retains accepted image", async () => { expect(edits()).toBe(2); expect(view.state.field(pendingPasteOperations)).toEqual([]); }); + +it("rich prose-to-code without fallback and rejected image preserves target", async () => { + const { view, paste, requestId, onFailure, edits } = await pendingRich(); + const selection = view.state.selection; + paste.resolve(requestId, null); + expect(view.state.doc.toString()).toBe("```\ntarget"); + expect(view.state.selection.eq(selection)).toBe(true); + expect(view.state.field(pendingPasteOperations)).toEqual([]); + expect(onFailure).toHaveBeenCalledTimes(1); + expect(edits()).toBe(1); +}); + +it("rich prose-to-code without fallback retains accepted image only", async () => { + const { view, paste, requestId, onFailure, edits } = await pendingRich(); + paste.resolve(requestId, "./assets/a.png"); + expect(view.state.doc.toString()).toBe("```\n![](./assets/a.png)\n"); + expect(edits()).toBe(2); + expect(view.state.field(pendingPasteOperations)).toEqual([]); + expect(onFailure).not.toHaveBeenCalled(); +}); + +it("rich prose-to-code plain fallback survives image rejection", async () => { + const { view, paste, requestId, edits } = await pendingRich("captured"); + paste.resolve(requestId, null); + expect(view.state.doc.toString()).toBe("```\ncaptured"); + expect(edits()).toBe(2); + expect(view.state.field(pendingPasteOperations)).toEqual([]); +}); + +it.each([ + undefined, + "plain caption", + "", +])("HTML-only caption survives non-image File rejection (%s)", async (text) => { + const { view, paste, post, edits } = mount("target", 0, 6); + firePasteAt(view.contentDOM, { + html: "

HTML caption

", + text, + files: [{ type: "application/pdf" }], + }); + expect(edits()).toBe(0); + await vi.waitFor(() => expect(post).toHaveBeenCalledTimes(1)); + paste.resolve(post.mock.calls[0][0].requestId, null); + expect(view.state.doc.toString()).toBe(text || "HTML caption\n"); + expect(edits()).toBe(1); + expect(view.state.field(pendingPasteOperations)).toEqual([]); +}); + +it.each([ + {}, + { text: "", html: "" }, +])("unusable clipboard does not delete selected text (%j)", (clipboard) => { + const { view, post, onFailure, edits, transactions } = mount("target", 0, 6); + const selection = view.state.selection; + firePasteAt(view.contentDOM, clipboard); + expect(view.state.doc.toString()).toBe("target"); + expect(view.state.selection.eq(selection)).toBe(true); + expect(view.state.field(pendingPasteOperations)).toEqual([]); + expect(post).not.toHaveBeenCalled(); + expect(edits()).toBe(0); + expect(transactions()).toBe(0); + expect(onFailure).toHaveBeenCalledOnce(); +}); diff --git a/test/webview/paste/cm-paste-operation.test.ts b/test/webview/paste/cm-paste-operation.test.ts index bd431229..11dff164 100644 --- a/test/webview/paste/cm-paste-operation.test.ts +++ b/test/webview/paste/cm-paste-operation.test.ts @@ -1,6 +1,6 @@ // @vitest-environment happy-dom import { history, redo, redoDepth, undo } from "@codemirror/commands"; -import { EditorSelection, EditorState, StateEffect } from "@codemirror/state"; +import { EditorSelection, EditorState, StateEffect, type TransactionSpec } from "@codemirror/state"; import { EditorView } from "@codemirror/view"; import { expect, it, vi } from "vitest"; @@ -709,3 +709,154 @@ it("dispose aborts readers and clears timers", () => { vi.useRealTimers(); } }); + +it.each([ + "construction", + "dispatch", + "normalization", +])("precommit failure preserves document and terminates operation (%s)", (stage) => { + vi.useFakeTimers(); + const onFailure = vi.fn(); + const onUnlinkedPath = vi.fn(); + const release = vi.fn(); + const coordinator = createPasteOperationCoordinator({ + onFailure, + onUnlinkedPath, + startResources: () => release, + }); + const view = new EditorView({ + doc: "target", + selection: { anchor: 0, head: 6 }, + extensions: [coordinator.extension], + }); + try { + const id = coordinator.capture(view, { from: 0, to: 6, render: () => "caption" }, 1)!; + const before = view.state; + coordinator.settle(id, 0, "./unlinked.png"); + if (stage === "construction") { + const update = before.update.bind(before); + vi.spyOn(before, "update").mockImplementation((...specs: TransactionSpec[]) => { + if (specs.some((spec) => spec.changes)) { + throw new Error("construction failed"); + } + return update(...specs); + }); + } else if (stage === "dispatch") { + const dispatch = view.dispatch.bind(view); + vi.spyOn(view, "dispatch").mockImplementation((...args: Parameters) => { + if (args[0] && "docChanged" in args[0] && args[0].docChanged) { + throw new Error("dispatch failed"); + } + dispatch(...args); + }); + } else { + vi.spyOn(before, "toText").mockImplementationOnce(() => { + throw new Error("normalization failed"); + }); + } + expect(() => coordinator.complete(id)).not.toThrow(); + expect(view.state.doc.eq(before.doc)).toBe(true); + expect(view.state.selection.eq(before.selection)).toBe(true); + expect(view.state.field(pendingPasteOperations)).toEqual([]); + expect(coordinator.results(id)).toBeUndefined(); + expect(release).toHaveBeenCalledOnce(); + expect(onFailure).toHaveBeenCalledOnce(); + expect(onUnlinkedPath).toHaveBeenCalledExactlyOnceWith("./unlinked.png"); + coordinator.settle(id, 0, "./late.png"); + coordinator.complete(id); + vi.advanceTimersByTime(30_000); + expect(view.state.doc.eq(before.doc)).toBe(true); + expect(onFailure).toHaveBeenCalledOnce(); + } finally { + vi.restoreAllMocks(); + view.destroy(); + vi.useRealTimers(); + } +}); + +it("throwing live render terminates once and reports unlinked path", () => { + vi.useFakeTimers(); + const onFailure = vi.fn(); + const onUnlinkedPath = vi.fn(); + const release = vi.fn(); + const render = vi.fn(() => { + throw new Error("render failed"); + }); + const coordinator = createPasteOperationCoordinator({ + onFailure, + onUnlinkedPath, + startResources: () => release, + }); + const view = new EditorView({ doc: "target", extensions: [coordinator.extension] }); + try { + const id = coordinator.capture(view, { from: 0, to: 6, render }, 1)!; + const before = view.state; + coordinator.settle(id, 0, "./unlinked.png"); + expect(() => coordinator.complete(id)).not.toThrow(); + expect(view.state.doc.eq(before.doc)).toBe(true); + expect(view.state.selection.eq(before.selection)).toBe(true); + expect(view.state.field(pendingPasteOperations)).toEqual([]); + expect(coordinator.results(id)).toBeUndefined(); + expect(release).toHaveBeenCalledOnce(); + expect(onFailure).toHaveBeenCalledOnce(); + expect(onUnlinkedPath).toHaveBeenCalledExactlyOnceWith("./unlinked.png"); + coordinator.settle(id, 0, "./late.png"); + coordinator.complete(id); + vi.advanceTimersByTime(30_000); + expect(render).toHaveBeenCalledOnce(); + expect(onFailure).toHaveBeenCalledOnce(); + expect(view.state.doc.eq(before.doc)).toBe(true); + } finally { + view.destroy(); + vi.useRealTimers(); + } +}); + +it("postcommit failure never reinserts", () => { + vi.useFakeTimers(); + const onFailure = vi.fn(); + const release = vi.fn(); + const coordinator = createPasteOperationCoordinator({ onFailure, startResources: () => release }); + let edits = 0; + const view = new EditorView({ + doc: "target", + extensions: [ + coordinator.extension, + EditorView.updateListener.of((update) => { + if (update.docChanged) { + edits++; + } + }), + ], + }); + try { + const id = coordinator.capture(view, { from: 0, to: 6, render: () => "caption" }, 1)!; + coordinator.settle(id, 0, "./accepted.png"); + const dispatch = view.dispatch.bind(view); + const spy = vi + .spyOn(view, "dispatch") + .mockImplementation((...args: Parameters) => { + dispatch(...args); + if (args[0] && "docChanged" in args[0] && args[0].docChanged) { + throw new Error("postcommit failed"); + } + }); + expect(() => coordinator.complete(id)).not.toThrow(); + expect(view.state.doc.toString()).toBe("caption\n![](./accepted.png)\n"); + expect(view.state.field(pendingPasteOperations)).toEqual([]); + expect(coordinator.results(id)).toBeUndefined(); + expect(release).toHaveBeenCalledOnce(); + expect(onFailure).toHaveBeenCalledOnce(); + coordinator.settle(id, 0, "./late.png"); + coordinator.complete(id); + vi.advanceTimersByTime(30_000); + expect(edits).toBe(1); + expect(spy).toHaveBeenCalledOnce(); + expect(onFailure).toHaveBeenCalledOnce(); + expect(view.state.doc.toString()).toBe("caption\n![](./accepted.png)\n"); + } finally { + vi.restoreAllMocks(); + view.destroy(); + vi.useRealTimers(); + } +}); From ea794ed41cab1e96d7cb53746489524a3b6d041d Mon Sep 17 00:00:00 2001 From: Mitsuki Fukunaga Date: Tue, 10 Nov 2026 08:48:38 +0900 Subject: [PATCH 5/9] feat(paste): switch production paste to the single clipboard coordinator Remove the legacy pending-anchor paths, guard settlement against a read-only flip, and surface insertion failures in the notice slot. --- src/webview/cm/image/image-paste.ts | 416 +----- src/webview/cm/image/index.ts | 1 - src/webview/cm/paste/clipboard-paste.ts | 44 +- src/webview/cm/paste/html-table-paste.ts | 55 +- src/webview/cm/paste/index.ts | 6 +- src/webview/cm/paste/list-reindent.ts | 51 +- src/webview/cm/paste/paste-operation.ts | 19 +- src/webview/cm/paste/rich-html-paste.ts | 278 +--- src/webview/cm/paste/url-link-paste.ts | 66 +- src/webview/editor.ts | 59 +- src/webview/shell.ts | 6 + test/webview/cm-crlf-line-model.test.ts | 16 +- test/webview/image/cm-image-paste.test.ts | 1145 ++--------------- test/webview/paste/cm-clipboard-paste.test.ts | 37 +- .../webview/paste/cm-html-table-paste.test.ts | 12 +- .../paste/cm-list-reindent-paste.test.ts | 7 +- test/webview/paste/cm-paste-url-link.test.ts | 8 +- .../paste/cm-rich-html-image-paste.test.ts | 214 +-- test/webview/paste/cm-rich-html-paste.test.ts | 196 +-- test/webview/shell.test.ts | 194 ++- 20 files changed, 545 insertions(+), 2285 deletions(-) diff --git a/src/webview/cm/image/image-paste.ts b/src/webview/cm/image/image-paste.ts index ae49217c..16f86f0b 100644 --- a/src/webview/cm/image/image-paste.ts +++ b/src/webview/cm/image/image-paste.ts @@ -1,7 +1,5 @@ -// Callback-based File transport and the temporary legacy paste/drop adapter. -// Only the adapter inserts links; transport never changes the document. +// Callback-based File transport and drop adapter. Neither inserts document content. -import { type Extension, StateEffect, StateField } from "@codemirror/state"; import { EditorView } from "@codemirror/view"; import { @@ -11,7 +9,6 @@ import { type WebviewToHost, } from "../../../shared/protocol.js"; import { safePostMessage } from "../../safe-post-message.js"; -import { hostDocumentReseed } from "../host-reseed.js"; const MAX_IMAGES_PER_EVENT = 16; // Aggregate byte ceiling per paste/drop so a multi-file drop cannot queue an @@ -20,102 +17,6 @@ const MAX_IMAGES_PER_EVENT = 16; // dropped (console.warn). const MAX_TOTAL_IMAGE_BYTES_PER_EVENT = 4 * MAX_IMAGE_BYTES; // 40 MiB -type PendingAnchor = { requestId: string; anchor: number }; - -// `addPendingAnchor` is exported so unit tests can seed pending anchors without driving -// real DOM paste/drop events — test/webview/image/cm-image-paste.test.ts and -// test/webview/shell.test.ts both do. Its paired half is deliberately NOT exported: the -// only dispatchers are clearPending() and resolve()'s insert, both in this file, and the -// field below matches on the effect object itself, not on its visibility. Tests observe a -// removal through `pendingImageAnchors` (the outcome) rather than by dispatching the -// effect, so widening this back to `export` needs a real out-of-module dispatcher first. -export const addPendingAnchor = StateEffect.define(); -const removePendingAnchor = StateEffect.define(); // requestId - -/** Tracks in-flight image-write anchors and maps each through doc changes so the - * link lands at the right place after the async host round-trip. Exported for - * unit tests. */ -export const pendingImageAnchors = StateField.define({ - create: () => [], - update(value, tr) { - // A host reseed that ACTUALLY replaces the doc (wholesale 0..len) collapses - // mapped anchors to the replace boundary (≈EOF). Drop all pending anchors — - // the file is still written but the link is not inserted (orphan, idempotent - // on re-paste), which is correct vs. inserting at a wrong spot. That orphan is - // NOT silent: the reply still arrives, finds no anchor, and resolve() warns - // with the path — which is why this clear does no logging of its own (it runs - // inside a StateField update, has no relativePath to report, and would fire per - // anchor rather than per outcome). BUT a same-content reseed (applyDocument - // with needsReseed=false: a version/canWrite-only ack) carries the annotation - // WITHOUT a doc change — positions are unchanged, so anchors must be KEPT (else - // a paste + keep-typing whose edit acks mid-round-trip would silently lose its - // image link). Gate the clear on tr.docChanged. - // Note: a reseed transaction never also carries addPendingAnchor — paste and - // reseed always dispatch separately — so early-returning here (skipping the - // effects loop below) cannot drop a freshly-added anchor. - if (tr.annotation(hostDocumentReseed)) { - if (tr.docChanged) { - return value.length === 0 ? value : []; - } - return value; // no-op reseed: positions intact, keep pending anchors - } - let next = value; - if (tr.docChanged) { - next = next.map((p) => ({ ...p, anchor: tr.changes.mapPos(p.anchor, 1) })); - } - for (const effect of tr.effects) { - if (effect.is(addPendingAnchor)) { - next = [...next, effect.value]; - } else if (effect.is(removePendingAnchor)) { - next = next.filter((p) => p.requestId !== effect.value); - } - } - return next; - }, -}); - -/** The single definition of "this clipboard item is an image this module will - * ingest". EXPORTED because richHtmlPaste (paste/rich-html-paste.ts) must defer - * to this handler on exactly this set and no wider: it runs FIRST, so deferring - * on a SUPERSET means this handler then declines, CM core falls through to - * `doPaste("")`, and the user's selection is replaced with nothing. - * - * That handler used to restate the conditions instead of borrowing them, and TWO - * separate over-matches shipped into review before this became one definition: - * `files.length > 0` (which also matches a copied PDF), then - * `getAsFile() !== null` (which lets an `undefined` return through, where the - * `if (file)` below rejects it). A comment saying "MUST stay a SUBSET" is not a - * mechanism; one shared function is. Do NOT re-inline these conditions on either - * side — a caller that needs a NARROWER set should intersect this predicate with - * its own extra test, so the shared floor stays shared. - * - * `kind === "file"` is first as a short-circuit matching the DOM contract; per - * spec a non-file item's `getAsFile()` already returns null, so it is - * belt-and-braces rather than the load-bearing condition. */ -export function isIngestibleImageItem(item: DataTransferItem): boolean { - return item.kind === "file" && item.type.startsWith("image/") && !!item.getAsFile(); -} - -function imageFilesFrom(dt: DataTransfer | null): File[] { - if (!dt) { - return []; - } - const files: File[] = []; - for (const item of Array.from(dt.items)) { - // Built FROM the shared predicate, not beside it: an acceptance change made - // here without going through `isIngestibleImageItem` is exactly the drift that - // deletes a selection over in richHtmlPaste. - if (!isIngestibleImageItem(item)) { - continue; - } - const file = item.getAsFile(); // non-null by the predicate; the check narrows the type - if (file) { - files.push(file); - } - } - return files; -} - /** Estimated base64 length of `size` bytes — gross-oversized files are dropped * before the (expensive) FileReader encode. */ function estimatedBase64Length(size: number): number { @@ -167,22 +68,12 @@ export function createImageFileTransport(opts: { post: (message: WebviewToHost) callback(path); return true; } - function submit( - file: File, - onResult: (path: string | null) => void, - onRequest?: (requestId: string) => void, - onFailure?: () => void - ): () => void { + function submit(file: File, onResult: (path: string | null) => void): () => void { const requestId = `${sessionNonce}-${++seq}`; pending.set(requestId, onResult); - onRequest?.(requestId); const failed = () => { if (pending.delete(requestId)) { - if (onFailure) { - onFailure(); - } else { - onResult(null); - } + onResult(null); } }; const reader = new FileReader(); @@ -244,274 +135,49 @@ export function createImageFileTransport(opts: { post: (message: WebviewToHost) release(); }); } - return { submit, start, resolve }; + return { start, resolve }; } -export function createImagePasteDrop(opts: { - canWrite: () => boolean; - post: (message: WebviewToHost) => void; -}): { - extension: Extension; - resolve: (view: EditorView, requestId: string, relativePath: string | null) => void; -} { - // Dispatch guarded against a view torn down mid-round-trip (tab closed between - // FileReader start and callback). CM 6.43 does NOT actually throw there — - // `EditorView.update` early-returns on `this.destroyed` (measured; see the - // "logs — but does not rethrow — a clearPending dispatch that fails after - // teardown" test) — so this catch pins the contract against a future CM - // regression rather than handling a hazard that reproduces today. - // - // It no longer stays silent. The catch was justified by "a destroyed view has - // taken the pending state and the editor surface down with it, so there is no - // outcome left for a reader to act on" — true of a destroyed view, but that is - // NOT the only thing that can throw here. `view.dispatch` runs every StateField - // in the editor, so an unrelated field throwing during an image-resolve dispatch - // lands in this catch on a perfectly live view, where the pending entry then - // survives and makes a re-paste look like a duplicate. Under today's CM the - // destroyed-view case cannot even reach the catch (the measurement above), so - // in practice a line printed from here means the OTHER cause — exactly the one - // that was invisible. Whether the paste itself deserved a log is still each - // caller's call, made before it gets here; this line reports only the failure of - // the clear. - // Every StateField still runs on this transaction — CM re-evaluates them all, not - // just the ones an effect names. What makes it the safer dispatch is the absence of - // `changes`: the widget/fold/lint fields see an unchanged doc and stay dormant, - // whereas resolve()'s insert is what drives them through real work. - const clearPending = (view: EditorView, requestId: string): void => { - try { - view.dispatch({ effects: removePendingAnchor.of(requestId) }); - } catch (err) { - console.error("[quoll] failed to clear a pending image anchor", { err, requestId }); - } - }; - - const transport = createImageFileTransport({ post: opts.post }); - const submit = (view: EditorView, file: File, anchor: number): void => { - let requestId = ""; - transport.submit( - file, - (path) => resolveLegacy(view, requestId, path), - (id) => { - requestId = id; - view.dispatch({ effects: addPendingAnchor.of({ requestId, anchor }) }); - }, - () => clearPending(view, requestId) - ); - }; - - // Caller contract: invoked only with files.length > 0 AND canWrite() === true, after the - // caller has already preventDefault'd the event. Submits each in-cap image. - // - // The unconditional `true` states intent; it is not the mechanism. What actually stops - // the browser navigating to a dropped file is the caller's preventDefault. Under CM 6.43 - // the return value is unobservable from here: `InputState.runHandlers` reacts to a truthy - // handler by calling `event.preventDefault()` (already called) and breaking the handler - // loop — which its own `if (event.defaultPrevented) break` would do on the next iteration - // regardless. Kept as belt-and-braces, and because it is the honest answer to "did this - // handler own the event?" on the branch where every file was refused. - const handle = (view: EditorView, files: File[], anchor: number): boolean => { - visitTransferFiles( - files, - (file) => { - if (file.size === 0) { - console.warn("[quoll] dropped empty image file (zero bytes)"); - return; - } - submit(view, file, anchor); - }, - () => {} - ); - return true; - }; - - const extension: Extension = [ - pendingImageAnchors, - EditorView.domEventHandlers({ - paste: (event, view) => { - const files = imageFilesFrom(event.clipboardData); - if (files.length === 0) { - return false; // no image — let CM handle normal text paste - } - event.preventDefault(); // image files present — we own this event - if (!opts.canWrite()) { - return true; // read-only: swallow without writing - } - return handle(view, files, view.state.selection.main.head); - }, - dragover: (event) => { - if (event.dataTransfer && Array.from(event.dataTransfer.types).includes("Files")) { - event.preventDefault(); // allow the drop event to fire for file drags - } - return false; - }, - drop: (event, view) => { - const files = imageFilesFrom(event.dataTransfer); - if (files.length === 0) { - return false; - } - event.preventDefault(); // image file drop — never let the browser open it - if (!opts.canWrite()) { - return true; // read-only: swallow without writing - } - const pos = view.posAtCoords({ x: event.clientX, y: event.clientY }); - return handle(view, files, pos ?? view.state.selection.main.head); - }, - }), - ]; - - const resolveLegacy = ( - view: EditorView, - requestId: string, - relativePath: string | null - ): void => { - const pending = view.state.field(pendingImageAnchors).find((p) => p.requestId === requestId); - if (!pending) { - // unknown / duplicate / already-resolved / cleared by a reseed. The last of - // those is the common one and the one with a cost: a wholesale reseed drops - // every anchor (it cannot map them through a full-document replace), and by - // the time the reply lands the host has ALREADY written the image. Not - // inserting is right — the position could not be verified — but the file then - // sits in the workspace with nothing pointing at it, so the outcome is named - // here. `relativePath` is what makes the line actionable: a path IS the orphan. - // A `null` path is logged too rather than skipped — see the arm below for why - // "the host refused, so it reported it" does not hold in general. - // The message states the outcome rather than the cause: a late reply from a - // previous webview session (the sessionNonce case) reaches this same branch - // and is indistinguishable from here. - console.warn("[quoll] image-write result had no pending anchor; no link inserted", { - requestId, - relativePath, - }); - return; - } - if (relativePath === null) { - // The host sent no path. This arm was silent on the theory that it had - // therefore refused, already told the user, and left nothing behind — and ALL - // THREE are false in cases the wire cannot distinguish, because - // `image-write-result` carries `ok:false` with no reason: - // · not always a refusal — the write-failure arm of - // `image-write-service.ts` ACCEPTED the write and lost to the filesystem, - // which is why the message below says "did not complete" and names no - // cause: the wire never sent one. - // · not always reported — `image-write-budget.ts` latches `warned` on the - // first session-volume rejection (`onExceeded` fires exactly once), so a - // REPEAT cap rejection posts null having shown the user nothing at all. - // · not always empty-handed — that same arm rejects AFTER attempting the - // write, and `workspace.fs.writeFile` offers no atomicity, so a partial - // content-addressed file can already be on disk (exactly why the budget - // never refunds a failed write). - // Most reject reasons DO showError, so this line is usually a duplicate of a - // toast the user has seen. That is the cheap direction to be wrong in: a - // duplicated line in a console nobody reads costs nothing, while trusting a - // toast we cannot verify was shown loses the outcome entirely. No - // `relativePath` to carry — there is no path on this arm. - console.warn("[quoll] image write did not complete on the host; no link inserted", { - requestId, - }); - clearPending(view, requestId); - return; - } - if (!opts.canWrite()) { - // Split out from the arm above because the two are opposites, not variants: - // here the write SUCCEEDED and the host said so, then the document flipped - // read-only before the link could be inserted. The host has no reason to have - // said anything — from its side this write went fine — so unlike the arm above - // there is not even a toast that MIGHT have been shown. The image sits in the - // workspace with nothing pointing at it, and `relativePath` is the only handle - // on that orphan, so it is logged (a host-chosen workspace-relative path, never - // image content). The insert itself must still not happen: edit-sync would drop it. - console.warn( - "[quoll] pasted image written but not linked: document went read-only mid round-trip", - { requestId, relativePath } - ); - clearPending(view, requestId); - return; - } - const anchor = pending.anchor; - // Both failure paths below share one recovery, because by the time resolve() runs - // the host has ALREADY written the image into the workspace: swallowing either one - // costs the user a stray asset file with no link and no trace, and — because - // `removePendingAnchor` rides the very dispatch that can fail — a leaked entry - // pinning a now-meaningless anchor, which makes a re-paste look like a duplicate. - // So each catch logs and then clears the anchor on its OWN dispatch, which cannot - // carry the same hazard (effects only, no positions; clearPending absorbs the - // torn-down-view case). The resulting orphaned file is exactly what a wholesale - // reseed already produces, and beats inserting at a position we could not verify. - // - // They are kept as two try blocks rather than one because they fail for unrelated - // reasons and only a precise log can tell them apart afterwards. Each logs the - // context the sibling handler in `list/list-indent-keymap.ts` established as the - // house style for a dispatch failure — a bare `err` does not say WHICH pending - // image, at what anchor, or how far that anchor sat from the end of the doc. The - // context is identical between the two, so it is built in one place; only the - // label (which of the two failures this is) varies per call site. - // - // `docLength` is snapshotted HERE rather than read at log time, so that both call - // sites report the same thing: the length the anchor was measured against. The - // dispatch catch must not re-read it, because a throw can land on either side of - // the state commit — `ViewState.update` assigns `this.state` (what `view.state` - // returns) as its FIRST statement, before `docView.update` runs — so a re-read - // would mean pre-insert on one path and post-insert on the other, under one name. - const docLengthBeforeInsert = view.state.doc.length; - const logInsertFailure = (label: string, err: unknown): void => { - console.error(`[quoll] pasted image link insert failed: ${label}`, { - err, - requestId, - anchor, - relativePath, - docLength: docLengthBeforeInsert, - }); - }; - let line: ReturnType; - try { - line = view.state.doc.lineAt(anchor); - } catch (err) { - // Position lookup, not the edit: `lineAt` throws a RangeError on an anchor left - // stale past the doc end, and it runs BEFORE the dispatch is even built — which - // is why it needs its own guard. Wrapped only around the dispatch, this escaped - // into the shell's message handler. - logInsertFailure("stale anchor", err); - clearPending(view, requestId); - return; +/** Acquire each obtainable File once; the host decides acceptance from its bytes. */ +export function filesFromTransfer(data: DataTransfer | null): File[] { + const files: File[] = []; + for (const item of Array.from(data?.items ?? [])) { + if (item.kind !== "file") { + continue; } - // Standalone block: break onto its own line when mid-line; always close with - // a newline so the read-path renders it as a block image. Multiple images - // from one event insert in completion order (see Design notes). - const prefix = anchor === line.from ? "" : "\n"; - const insert = `${prefix}![](${relativePath})\n`; - try { - // resolve runs synchronously from the shell's `editor?.` null-guarded handler - // (view alive), but guard the dispatch for symmetry with the async paths. - // History-recorded on purpose, so the image link stays undoable — at the cost - // of the redo branch of anything undone during the round-trip. Change only - // with a decision record. - view.dispatch({ - changes: { from: anchor, insert }, - selection: { anchor: anchor + insert.length }, - effects: removePendingAnchor.of(requestId), - scrollIntoView: true, - }); - } catch (err) { - // Deliberately NOT labelled "stale anchor": this catch is not only about the - // anchor. Building the transaction runs every StateField, and `EditorView.update` - // then runs `docView.update` unguarded — so an unrelated field throwing on this - // insert (block widgets are StateFields here by design, as are fold and lint) - // surfaces at exactly this line. What never reaches us is anything CM catches on - // our behalf and hands to `logException`: ViewPlugin updates and updateListener - // callbacks both — so a failure in edit-sync, which rides a listener, shows up in - // the console under CM's own label and not this one. Naming the dispatch rather - // than the anchor keeps the next reader off the wrong scent. - logInsertFailure("dispatch threw", err); - clearPending(view, requestId); + const file = item.getAsFile(); + if (file) { + files.push(file); } - }; + } + return files; +} - return { - extension, - resolve: (view: EditorView, requestId: string, path: string | null) => { - if (!transport.resolve(requestId, path)) { - resolveLegacy(view, requestId, path); +/** File drops submit a collapsed target to the same coordinator as clipboard paste. */ +export function imageFileDrop(options: { + canWrite: () => boolean; + capture: (view: EditorView, files: readonly File[], from: number) => void; +}) { + return EditorView.domEventHandlers({ + dragover(event) { + if (Array.from(event.dataTransfer?.types ?? []).includes("Files")) { + event.preventDefault(); } + return false; }, - }; + drop(event, view) { + const files = filesFromTransfer(event.dataTransfer); + if (!files.length) { + return false; + } + event.preventDefault(); + if (!options.canWrite() || view.state.readOnly) { + return true; + } + const from = + view.posAtCoords({ x: event.clientX, y: event.clientY }) ?? view.state.selection.main.head; + options.capture(view, files, from); + return true; + }, + }); } diff --git a/src/webview/cm/image/index.ts b/src/webview/cm/image/index.ts index 8a978872..9c8720db 100644 --- a/src/webview/cm/image/index.ts +++ b/src/webview/cm/image/index.ts @@ -1,4 +1,3 @@ export { imageBlockField } from "./image-field.js"; -export { createImagePasteDrop } from "./image-paste.js"; export { ImageBlockWidget } from "./image-widget.js"; export { quollResourceBaseUri } from "./resource-base.js"; diff --git a/src/webview/cm/paste/clipboard-paste.ts b/src/webview/cm/paste/clipboard-paste.ts index 456be430..47735640 100644 --- a/src/webview/cm/paste/clipboard-paste.ts +++ b/src/webview/cm/paste/clipboard-paste.ts @@ -2,7 +2,11 @@ import { type EditorState, Prec } from "@codemirror/state"; import { EditorView } from "@codemirror/view"; import type { WebviewToHost } from "../../../shared/protocol.js"; -import { createImageFileTransport } from "../image/image-paste.js"; +import { + createImageFileTransport, + filesFromTransfer, + imageFileDrop, +} from "../image/image-paste.js"; import { decideTablePaste } from "./html-table-paste.js"; import { decideListPaste } from "./list-reindent.js"; import { createPasteOperationCoordinator, type PasteDecision } from "./paste-operation.js"; @@ -16,21 +20,11 @@ export function snapshotClipboard(data: DataTransfer | null) { present: types.includes(type), value: data?.getData(type) ?? "", }); - const files: File[] = []; - for (const item of Array.from(data?.items ?? [])) { - if (item.kind !== "file") { - continue; - } - const file = item.getAsFile(); - if (file) { - files.push(file); - } - } return { plain: flavour("text/plain"), html: flavour("text/html"), uri: flavour("text/uri-list"), - files, + files: filesFromTransfer(data), }; } @@ -67,18 +61,24 @@ export function decideClipboardPaste( return null; } -/** Independent clipboard entry; production remains on the temporary legacy adapter. */ +/** Single clipboard owner; File drops share its coordinator and transport. */ export function createClipboardPaste(options: { canWrite: () => boolean; post: (message: WebviewToHost) => void; onFailure: () => void; onUnlinkedPath?: (path: string) => void; }) { + const reportUnlinked = + options.onUnlinkedPath ?? + ((relativePath: string) => { + console.warn("[quoll] pasted image written but not linked", { relativePath }); + }); const transport = createImageFileTransport({ post: options.post }); let capturedFiles: readonly File[] = []; const coordinator = createPasteOperationCoordinator({ + canWrite: options.canWrite, onFailure: options.onFailure, - onUnlinkedPath: options.onUnlinkedPath, + onUnlinkedPath: reportUnlinked, startResources: (id) => transport.start(capturedFiles, (slot, path) => coordinator.settle(id, slot, path)), onReady: (id): void => { @@ -88,6 +88,14 @@ export function createClipboardPaste(options: { return { extension: [ coordinator.extension, + imageFileDrop({ + canWrite: options.canWrite, + capture(view, files, from) { + capturedFiles = files; + coordinator.capture(view, { from, to: from, render: () => "" }, files.length); + capturedFiles = []; + }, + }), Prec.high( EditorView.domEventHandlers({ paste(event, view) { @@ -98,7 +106,7 @@ export function createClipboardPaste(options: { return false; } event.preventDefault(); - if (!options.canWrite()) { + if (!options.canWrite() || view.state.readOnly) { return true; } if (!snapshot.files.length && !text && !decided) { @@ -115,7 +123,11 @@ export function createClipboardPaste(options: { }) ), ], - resolve: transport.resolve, + resolve(requestId: string, path: string | null) { + if (!transport.resolve(requestId, path) && path !== null) { + reportUnlinked(path); + } + }, dispose: coordinator.dispose, }; } diff --git a/src/webview/cm/paste/html-table-paste.ts b/src/webview/cm/paste/html-table-paste.ts index ca1b0ef5..08fd9b0f 100644 --- a/src/webview/cm/paste/html-table-paste.ts +++ b/src/webview/cm/paste/html-table-paste.ts @@ -1,17 +1,4 @@ -// CodeMirror paste handler: when the clipboard carries a `text/html` flavour -// containing a ``, insert an equivalent GFM Markdown table instead of the -// raw HTML/plain-text. Every other paste is left untouched — the handler returns -// `false` (without preventDefault) so `pasteUrlOverSelection` (url-link-paste.ts), -// the image paste pipeline (image-paste.ts), and CM's default plain-text paste -// still run. -// -// Mounted at Prec.high so it arbitrates before those handlers: on a convertible -// table it consumes the event; otherwise it defers. The insert rides the normal -// dispatch → updateListener → edit-sync → host write-lock → applyEdit settlement -// pipeline (no raw write path). - -import { type EditorState, type Extension, Prec } from "@codemirror/state"; -import { EditorView } from "@codemirror/view"; +import type { EditorState } from "@codemirror/state"; import { htmlTableToGfm } from "./html-table-to-gfm.js"; @@ -50,43 +37,3 @@ export function decideTablePaste(state: EditorState, html: string) { blockPrefix(live.doc.sliceString(0, start)) + gfm + blockSuffix(live.doc.sliceString(end)), }; } - -export function htmlTablePaste(opts: { canWrite: () => boolean }): Extension { - return Prec.high( - EditorView.domEventHandlers({ - paste: (event, view) => { - const html = event.clipboardData?.getData("text/html"); - if (!html) { - return false; // no HTML flavour → let normal paste handlers run - } - const decision = decideTablePaste(view.state, html); - if (decision === null) { - return false; // not a convertible table → defer to the other handlers - } - // preventDefault ONLY here, AFTER the null-check. This is intentionally the - // OPPOSITE order from imagePaste (which preventDefaults before its - // capability check): moving it earlier would swallow non-table HTML pastes. - event.preventDefault(); - // Read-only: swallow silently with NO fallback insert (mirrors imagePaste). - // canWrite() is the same source that drives EditorState.readOnly, so they - // cannot diverge — no redundant view.state.readOnly check. - if (!opts.canWrite()) { - return true; - } - // Insert at the MAIN selection range; multi-cursor collapses to it (a block - // table under multi-cursor is degenerate — acceptable). The handler runs - // synchronously inside the live DOM event, so the view is always alive at - // dispatch time (unlike imagePaste's async FileReader path). - const { from, to } = decision; - const insert = decision.render(view.state, from, to); - view.dispatch({ - changes: { from, to, insert }, - selection: { anchor: from + insert.length }, - scrollIntoView: true, - userEvent: "input.paste", - }); - return true; - }, - }) - ); -} diff --git a/src/webview/cm/paste/index.ts b/src/webview/cm/paste/index.ts index 78cfccbb..76cf56f6 100644 --- a/src/webview/cm/paste/index.ts +++ b/src/webview/cm/paste/index.ts @@ -1,7 +1,5 @@ export { createClipboardPaste } from "./clipboard-paste.js"; -export { htmlTablePaste } from "./html-table-paste.js"; export { htmlTableToGfm, tableElementToGfm } from "./html-table-to-gfm.js"; export { type HtmlToMarkdownResult, htmlToMarkdown } from "./html-to-markdown.js"; -export { listReindentPaste, reindentPastedList } from "./list-reindent.js"; -export { richHtmlPaste } from "./rich-html-paste.js"; -export { detectPasteLinkUrl, pasteUrlOverSelection } from "./url-link-paste.js"; +export { reindentPastedList } from "./list-reindent.js"; +export { detectPasteLinkUrl } from "./url-link-paste.js"; diff --git a/src/webview/cm/paste/list-reindent.ts b/src/webview/cm/paste/list-reindent.ts index 8a7128e2..62582154 100644 --- a/src/webview/cm/paste/list-reindent.ts +++ b/src/webview/cm/paste/list-reindent.ts @@ -1,20 +1,4 @@ -// Paste re-indent: when the clipboard carries a MULTI-LINE plain-text Markdown -// LIST fragment and the caret sits on an otherwise-blank (whitespace-only) line -// inside an existing list context, re-base the fragment's leading indentation so -// its top level aligns with the caret's column, preserving the fragment's inner -// relative structure (Obsidian analogue). Pure text transform on the paste content; the -// handler (below) inserts it through the normal edit pipeline. Every -// non-qualifying paste defers (return null / false) so the other paste handlers -// + CM's default plain-text paste still run. -// -// v1 is deliberately conservative — "never corrupt" beats "re-base everything": -// a fragment containing a fenced code block, tabs in its indentation, a -// non-list first line, or a non-list line shallower than the list markers is -// DEFERRED (inserted unchanged), because re-basing those cases could corrupt -// the Markdown. Re-basing around a preserved fence is future work. - -import { type EditorState, type Extension, Prec } from "@codemirror/state"; -import { EditorView } from "@codemirror/view"; +import type { EditorState } from "@codemirror/state"; import { columnAt } from "../list/list-geometry.js"; import { parseListMark } from "../list/list-transform.js"; @@ -215,36 +199,3 @@ export function decideListPaste(state: EditorState, text: string) { reindentPastedList(text, columnAt(live, end), live.tabSize) ?? rebased, }; } - -export function listReindentPaste(opts: { canWrite: () => boolean }): Extension { - return Prec.high( - EditorView.domEventHandlers({ - paste: (event, view) => { - const text = event.clipboardData?.getData("text/plain"); - const decision = text ? decideListPaste(view.state, text) : null; - if (decision === null) { - return false; - } - // preventDefault ONLY here, AFTER committing to the re-base (mirrors - // htmlTablePaste): moving it earlier would swallow non-qualifying pastes. - event.preventDefault(); - // Read-only: swallow silently with NO fallback insert. canWrite() is the - // same source that drives EditorState.readOnly, so they cannot diverge. - if (!opts.canWrite()) { - return true; - } - // Replace from the LINE START (swallowing the spaces prefix) so the - // transform's absolute indentation is not double-counted with the prefix. - const { from, to } = decision; - const insert = decision.render(view.state, from, to); - view.dispatch({ - changes: { from, to, insert }, - selection: { anchor: from + insert.length }, - scrollIntoView: true, - userEvent: "input.paste", - }); - return true; - }, - }) - ); -} diff --git a/src/webview/cm/paste/paste-operation.ts b/src/webview/cm/paste/paste-operation.ts index 0fb95b9d..195f09df 100644 --- a/src/webview/cm/paste/paste-operation.ts +++ b/src/webview/cm/paste/paste-operation.ts @@ -95,6 +95,7 @@ export interface PasteDecision { export function createPasteOperationCoordinator(options: { onFailure: () => void; + canWrite?: () => boolean; startResources?: (id: string) => (() => void) | undefined; onReady?: (id: string) => void; onUnlinkedPath?: (path: string) => void; @@ -205,7 +206,7 @@ export function createPasteOperationCoordinator(options: { ], dispose: () => dispose(), capture(view: EditorView, decision: PasteDecision, slotCount = 0): string | null { - if (disposed) { + if (disposed || options.canWrite?.() === false || view.state.readOnly) { return null; } const id = `paste-${++nextId}`; @@ -264,6 +265,19 @@ export function createPasteOperationCoordinator(options: { return; } const state = view.state; + if (options.canWrite?.() === false || state.readOnly) { + for (const path of slots) { + if (typeof path === "string") { + console.warn( + "[quoll] pasted image written but not linked: document went read-only mid round-trip", + { requestId: id, relativePath: path } + ); + } + } + release(id); + clearMetadata(view, id); + return; + } try { const insert = composePasteReplacement(state, decision, target, slots); if (!insert.length) { @@ -287,6 +301,9 @@ export function createPasteOperationCoordinator(options: { view.dispatch(tr); release(id); clearMetadata(view, id); + if (slots.some((path) => path === null)) { + options.onFailure(); + } } catch { if (view.state !== state) { // Adoption is terminal even when dispatch did not finish successfully. diff --git a/src/webview/cm/paste/rich-html-paste.ts b/src/webview/cm/paste/rich-html-paste.ts index dc565fcb..cae2f3ee 100644 --- a/src/webview/cm/paste/rich-html-paste.ts +++ b/src/webview/cm/paste/rich-html-paste.ts @@ -1,50 +1,7 @@ -// CodeMirror paste handler: when the clipboard carries a `text/html` fragment -// that converts to Markdown AND that conversion actually emitted Markdown syntax, -// insert the Markdown instead of the raw HTML/plain text. A fragment can convert -// perfectly and still not be inserted — see the no-syntax defer in the handler -// (and its image-item exception, which inserts it after all). -// -// Follows html-table-paste.ts — Prec.high, defer (return false WITHOUT -// preventDefault so the handlers after this one still run), preventDefault only -// AFTER committing to insert, read-only swallow, and one dispatch through the -// normal edit-sync → host write-lock → applyEdit settlement pipeline. One -// deviation on the insert itself: when an image file item rides along, this handler -// inserts and STILL returns false un-prevented, so imagePaste gets the same event. -// -// What a defer actually reaches: CM runs handlers in precedence order — within one -// precedence, in extension order — stopping at the first that returns true. This -// handler is Prec.high and registered AFTER htmlTablePaste / pasteUrlOverSelection / -// listReindentPaste (same precedence → extension order decides); imagePaste is at -// DEFAULT precedence, so it runs after every Prec.high handler regardless of where -// its line sits in editor.ts. So by the time control arrives here those three have -// already declined, and deferring hands the event to imagePaste and then to CM's -// default plain-text paste — nobody else. -// -// Two of its four defer sites are shared with that sibling — `!html` and a null -// conversion (`gfm === null` there). What deviates: the caret/selection-in-code -// guard, the no-syntax defer, and the data-loss guard wrapped around the -// null-conversion defer — none of which html-table-paste.ts has. TWO of those can -// instead CONSUME the event (in-code paste, null conversion), and each first -// requires that a defer be harmless — see canDeferWithoutDataLoss, which they -// share: a plain / uri-list fallback for CM core, an image file item for -// imagePaste, OR an empty main selection, since `doPaste("")` at a bare caret -// deletes nothing. Both ask "can anything downstream insert this clipboard" -// through pasteWouldBeDropped. The no-syntax site is NOT a consume branch and asks -// a narrower question — will CM's PLAIN paste actually run — because not deferring -// there INSERTS this conversion; see the comment at the site. -// The `!html` defer is unconditional: with no HTML flavour this handler has nothing -// to insert either, so CM's own fallback behaviour is the whole story there. - import { ensureSyntaxTree, language } from "@codemirror/language"; -import { type EditorState, type Extension, Prec } from "@codemirror/state"; -import { EditorView } from "@codemirror/view"; +import type { EditorState } from "@codemirror/state"; import type { SyntaxNode, Tree } from "@lezer/common"; -// Cross-directory into cm/image/ deliberately: the "is this an image item" -// question belongs to the handler that ingests them, and borrowing its definition -// is what keeps this handler's defer set from drifting wider (see -// hasImageFileItem). No cycle — nothing under cm/image/ imports cm/paste/. -import { isIngestibleImageItem } from "../image/image-paste.js"; import { caretInCode } from "../list/list-tree.js"; import { htmlToMarkdown } from "./html-to-markdown.js"; @@ -182,87 +139,6 @@ function insertsInline(state: EditorState, from: number, to: number, md: string) return want !== null && got !== null && got.join() === want.join(); } -/** Would CM's own paste handler insert anything if this handler deferred? - * - * CM core runs `doPaste(view, getData("text/plain") || getData("text/uri-list"))`, - * and `doPaste("")` dispatches an empty insert over the selection — i.e. deleting - * it. EVERY defer path that can fire over a non-empty selection must therefore - * check this first and consume the event itself instead of deferring into that - * deletion. Written once so a change in CM's fallback order is found once. */ -function hasPlainFallback(event: ClipboardEvent): boolean { - const data = event.clipboardData; - return !!data && (!!data.getData("text/plain") || !!data.getData("text/uri-list")); -} - -/** Does the clipboard carry an image file item — one imagePaste will actually act - * on? imagePaste is registered AFTER this handler (editor.ts), so consuming such - * an event here would starve the only handler that can perform the paste and the - * document would simply be left untouched. - * - * The membership test is IMPORTED, not restated: `isIngestibleImageItem` is - * imagePaste's own definition of what it ingests, so this handler cannot defer on - * a wider set than that handler accepts. It used to restate the conditions, and a - * restatement that over-matches is not a conservative approximation — it is data - * loss: this handler defers, imagePaste declines, and CM's core `doPaste("")` - * replaces the selection with nothing. Two restatements shipped into review - * (`files.length`, which also matches a copied PDF; then `getAsFile() !== null`, - * which lets an `undefined` return through) before the definition was shared. - * "Cannot miss a file item" is the wrong property to optimise for; not - * over-matching is the one that protects the document — and sharing the - * definition is what makes over-matching unconstructible rather than merely - * forbidden by a comment. - * - * What stays local here is only the CLIPBOARD-shaped part: with no `items` at all - * the answer is false. That is the safe side: at the two consume branches false - * falls back to swallowing the event, which leaves the document intact (on the - * insert path it falls back to consuming after the insert — the image is not - * pasted, nothing is destroyed), whereas a wrong true routes to the deletion - * above. */ -function hasImageFileItem(event: ClipboardEvent): boolean { - const items = event.clipboardData?.items; - if (!items) { - return false; - } - return Array.from(items).some(isIngestibleImageItem); -} - -/** Can NOTHING downstream insert this clipboard? No plain / uri-list bytes for CM - * core, no image file item for imagePaste — so whatever this handler decides at a - * branch that will not insert the conversion, the paste produces nothing: consume - * and it is swallowed here; defer and CM's `doPaste("")` is a no-op at a caret or - * a deletion over a selection. THIS is the condition a diagnostic belongs on. The - * warnings used to sit on the consume decision instead, which tracked "did we - * protect the selection" rather than "did the paste vanish" — and so said nothing - * at all for the bare-caret case, the one where the user sees a paste do nothing - * and has no trace to look at. */ -function pasteWouldBeDropped(event: ClipboardEvent): boolean { - return !hasPlainFallback(event) && !hasImageFileItem(event); -} - -/** May this handler defer without risking the user's text? - * - * A defer hands the event to imagePaste and then to CM core, and CM core runs - * `doPaste("")` when the clipboard has no plain/uri bytes — which over a - * non-empty selection replaces it with nothing. Three independent things make a - * defer harmless: - * - a plain / uri-list fallback — CM core inserts those bytes instead of nothing; - * - an image file item — imagePaste consumes the event first, so CM core never - * runs; and consuming HERE would starve the only handler that can perform that - * paste, leaving the document untouched; - * - an empty MAIN selection — `doPaste("")` at a bare caret is a no-op, so - * consuming would swallow the event to protect nothing. Only the main range is - * tested: under multi-cursor a non-empty SECONDARY range would still be cleared - * by CM's replaceSelection(""). Accepted, mirroring html-table-paste.ts's - * collapse-to-main — a rich paste under multi-cursor is degenerate anyway. - * - * Both consume branches below (in-code paste, null conversion) need exactly this - * test, so they share it rather than restating it: the two must not drift, since - * each one's `false` answer is a decision to swallow a paste. The no-syntax defer - * site deliberately does NOT use it — see the comment there. */ -function canDeferWithoutDataLoss(event: ClipboardEvent, from: number, to: number): boolean { - return !pasteWouldBeDropped(event) || from === to; -} - /** Freeze conversion bytes, but decide inline placement against live neighbours. */ export function decideRichPaste( state: EditorState, @@ -290,155 +166,3 @@ export function decideRichPaste( }, }; } - -export function richHtmlPaste(opts: { canWrite: () => boolean }): Extension { - return Prec.high( - EditorView.domEventHandlers({ - paste: (event, view) => { - const html = event.clipboardData?.getData("text/html"); - if (!html) { - return false; // no HTML flavour → defer - } - const { from, to } = view.state.selection.main; - // Caret / selection touching a fenced or indented code block: a converted - // fragment can corrupt either kind — a
 becomes a ``` fenced snippet whose
-        // delimiters would prematurely close a surrounding *fence*, while inside an
-        // *indented* (4-space) block the blockPrefix/blockSuffix blank-line separators
-        // (and any non-indented inserted line) would terminate the block early. Check
-        // BOTH endpoints so a selection that starts in prose and extends into code (or
-        // vice versa) also defers, not just an empty caret. Mirrors listReindentPaste's
-        // caretInCode guard. Checked BEFORE htmlToMarkdown so the in-code decision does
-        // not depend on convertibility: whether a fragment happens to convert says
-        // nothing about whether inserting Markdown here is safe, and running the
-        // conversion first would make an unconvertible clipboard take a different
-        // path through this handler than a convertible one over the same code.
-        if (caretInCode(view.state, from) || caretInCode(view.state, to)) {
-          // Defer to plain-text paste so the raw text lands verbatim, structure
-          // intact — but only when deferring cannot cost the user anything. With no
-          // plain fallback, no image item and a real selection, deferring hands CM a
-          // doPaste("") that would delete the selected code, so consume instead.
-          // canDeferWithoutDataLoss holds the reasoning for all three exemptions;
-          // the `converted === null` branch below needs the same test and shares it.
-          // Warn on the DROP, not on the consume: at a bare caret this branch
-          // defers and CM's doPaste("") does nothing, which is just as silent for
-          // the user and just as puzzling. See pasteWouldBeDropped.
-          if (pasteWouldBeDropped(event)) {
-            console.warn("[quoll] rich paste: HTML-only clipboard dropped over code");
-          }
-          if (canDeferWithoutDataLoss(event, from, to)) {
-            return false;
-          }
-          event.preventDefault();
-          return true;
-        }
-        const imageRidesAlong = hasImageFileItem(event);
-        const converted = decideRichPaste(
-          view.state,
-          html,
-          event.clipboardData?.getData("text/plain") ||
-            event.clipboardData?.getData("text/uri-list") ||
-            "",
-          imageRidesAlong
-        );
-        if (converted === null) {
-          // Nothing convertible (whitespace-only, cap breached, parse error) → the
-          // clipboard's own bytes are the best available paste. Same requirement as
-          // the caretInCode branch above, hence the same test: with nothing to defer
-          // into and a real selection, deferring hands CM a doPaste("") that replaces
-          // the selection with nothing — an HTML-only clipboard (a remote , say)
-          // would silently delete the user's text. Consume the event instead.
-          // Same split as the branch above: the warning follows the dropped paste,
-          // not the consume decision.
-          if (pasteWouldBeDropped(event)) {
-            console.warn("[quoll] rich paste: unconvertible HTML-only clipboard dropped");
-          }
-          if (!canDeferWithoutDataLoss(event, from, to)) {
-            event.preventDefault();
-            return true;
-          }
-          return false; // defer to plain paste / imagePaste
-        }
-        // The conversion emitted escaped text and line structure only — no
-        // emphasis / link / code / heading / list / quote / table / rule. The
-        // conversion's deliberate escaping (which exists so that text inside
-        // GENUINELY rich content cannot activate a construct the user did not
-        // author) would mangle Markdown the user wrote by hand:
-        // `- [ ]` becomes `\- \[ \]`. Defer so CM's default paste inserts the
-        // clipboard's own bytes — byte-identical to typing them, so the "pasted
-        // text never activates a construct hand-typed text would not" invariant
-        // still holds. Requires a plain fallback to defer into, for the reason
-        // hasPlainFallback documents.
-        //
-        // ...and requires that NO image file item rides along, because then the
-        // plain flavour can never land: imagePaste calls preventDefault()
-        // unconditionally once it sees an ingestible image item (image-paste.ts),
-        // and CM's dispatcher breaks the handler chain on defaultPrevented, so CM's
-        // builtin plain-text paste never runs — with or without a text/plain
-        // flavour. Deferring there used to lose the caption outright, and the whole
-        // paste when imagePaste then refused the file. So with an image item the
-        // escaped conversion is inserted below even though text/plain exists.
-        // Hence the two predicates spelled out rather than `!pasteWouldBeDropped`:
-        // that funnel asks "can the EVENT be absorbed", this site needs "will the
-        // TEXT land". Nor canDeferWithoutDataLoss: an empty selection is no reason
-        // to defer here, since not deferring inserts rather than swallows.
-        //
-        // What this deliberately gives up: the `text/html` flavour is NOT
-        // strictly redundant here. LINE STRUCTURE is real information only it
-        // carries — a `
` hard break arrives as whatever the plain flavour - // spells (typically a soft `\n`, which renders as a space) instead of the - // `\`-escaped hard break the conversion would have written, and - // `
`-per-line blocking survives only as far as the plain flavour - // mirrors it. That is an empirical bet on clipboard producers — in - // practice they emit a `text/plain` that mirrors their own block - // structure — not a logical identity, and it is taken because re-escaping - // Markdown the user typed by hand is the worse failure. Pinned by "loses - // a lone
hard break to the plain fallback" in - // cm-rich-html-paste.test.ts; change either only with a decision record. - // - // Read-only needs no check HERE: this returns before the canWrite() gate - // below, and CM's builtin paste handler early-returns on - // `view.state.readOnly`. That is sound because EditorState.readOnly / - // EditorView.editable (editor.ts's `editableComp`) are reconfigured from - // the same `canWrite` wire value that drives opts.canWrite(), so the two - // cannot diverge — the same invariant html-table-paste.ts relies on. - if (!converted.emittedMarkdownSyntax && hasPlainFallback(event) && !imageRidesAlong) { - return false; - } - // An image file item does not stop the insert, and the insert does not stop - // the image: after dispatching, this handler returns false WITHOUT - // preventDefault, so imagePaste runs next on the same event and anchors its - // pending image at the selection head this dispatch just moved — after the - // prose. On success BOTH remain, prose then `![](…)` (deliberate: it used - // to be one or the other). Nothing here predicts whether imagePaste or the - // host will accept the file; if they refuse, the prose is simply all that - // lands. Known residual: undoing the inserted prose does not cancel the - // pending anchor — it maps back to the insert position and a later - // successful host reply still inserts the image there. That late insert is - // a history event, so it also discards the redo branch of the undone prose. - // The cause is not here: resolve() (image-paste.ts) records its insert as - // ordinary history, a property of ANY pending image — this path is a new - // way to reach it. Pinned in cm-rich-html-image-paste.test.ts; closing it - // is left to the PASTE-03 clipboard-arbitration work. - if (!opts.canWrite()) { - event.preventDefault(); - return true; // read-only: swallow, no fallback insert (mirrors siblings) - } - if (!imageRidesAlong) { - event.preventDefault(); - } - // A lone paragraph that reads the same in place goes in at the caret, like a - // plain paste — bold text pasted into a sentence must not split it. Anything - // else gets blank-line separators so it stands as its own block, and so does - // prose with an image riding along: the image lands after it, on its own line. - const insert = converted.render(view.state, from, to); - view.dispatch({ - changes: { from, to, insert }, - selection: { anchor: from + insert.length }, - scrollIntoView: true, - userEvent: "input.paste", - }); - return !imageRidesAlong; - }, - }) - ); -} diff --git a/src/webview/cm/paste/url-link-paste.ts b/src/webview/cm/paste/url-link-paste.ts index bb91e1c0..9b70c1fb 100644 --- a/src/webview/cm/paste/url-link-paste.ts +++ b/src/webview/cm/paste/url-link-paste.ts @@ -1,26 +1,6 @@ -// CodeMirror paste handler: pasting a single plain http(s) URL while a non-empty -// single-line selection exists wraps the selection as a Markdown link -// `[selection](url)` (Notion / Obsidian behaviour). Every other paste defers — -// the handler returns `false` (without preventDefault) so htmlTablePaste, the -// image paste pipeline (image-paste.ts), and CM's default plain-text paste run. -// -// This REPLACES @codemirror/lang-markdown's built-in `pasteURLAsLink` (dropped -// from quollMarkdownLanguage in markdown.ts). The built-in matched URLs by -// PREFIX (`/^(https?:\/\/|mailto:|xmpp:|www\.)/`), so `https://x.com trailing` -// over a selection was wrongly wrapped, and `xmpp:` / `www.` / `mailto:` schemes -// outside Quoll's url-allowlist were accepted. Ours requires the clipboard text -// to be EXACTLY one http(s) URL token and reuses the shared `isAllowedUrl` -// hardening (C0/DEL, protocol-relative) so paste stays in lockstep with the -// render/open sinks. It KEEPS the built-in's syntax-context guard -// (`selectionIsPlainText`) so a URL is never wrapped into code / an existing -// link / emphasis / raw HTML. The insert rides the normal dispatch → -// updateListener → edit-sync → host write-lock → applyEdit settlement -// pipeline (no raw write). - import { markdownLanguage } from "@codemirror/lang-markdown"; import { ensureSyntaxTree } from "@codemirror/language"; -import { type EditorState, type Extension, Prec } from "@codemirror/state"; -import { EditorView } from "@codemirror/view"; +import type { EditorState } from "@codemirror/state"; import { type AllowlistedUrl, isAllowedUrl } from "../../../markdown/url-allowlist.js"; @@ -120,26 +100,6 @@ function linkDestination(url: string): string { return /[()]/.test(url) && !/[<>]/.test(url) ? `<${url}>` : url; } -// TODO(dedupe): unify with the Cmd+K link-wrap helper from -// feat/inline-formatting-shortcuts once merged. -/** - * Wrap the main selection as `[selection](url)` in ONE dispatch (a single undo - * step). Two point-insertions bracket the selected text so the label is - * preserved verbatim and CM maps the selection through the changes. The caller - * guarantees a non-empty single-line main range in plain Markdown text. - */ -function insertLinkOverSelection(view: EditorView, url: AllowlistedUrl): void { - const { from, to } = view.state.selection.main; - view.dispatch({ - changes: [ - { from, insert: "[" }, - { from: to, insert: `](${linkDestination(url)})` }, - ], - userEvent: "input.paste", - scrollIntoView: true, - }); -} - /** Capture the URL and label range; rendering does not retain the view. */ export function decideUrlPaste(state: EditorState, text: string) { const { from, to, empty } = state.selection.main; @@ -161,27 +121,3 @@ export function decideUrlPaste(state: EditorState, text: string) { : text, }; } - -export function pasteUrlOverSelection(opts: { canWrite: () => boolean }): Extension { - return Prec.high( - EditorView.domEventHandlers({ - paste: (event, view) => { - const text = event.clipboardData?.getData("text/plain"); - const decision = text ? decideUrlPaste(view.state, text) : null; - if (decision === null) { - return false; - } - // preventDefault ONLY here, AFTER we commit to wrapping (mirrors - // htmlTablePaste): moving it earlier would swallow non-URL pastes. - event.preventDefault(); - // Read-only: swallow silently with NO fallback insert. canWrite() is the - // same source that drives EditorState.readOnly, so they cannot diverge. - if (!opts.canWrite()) { - return true; - } - insertLinkOverSelection(view, decision.url); - return true; - }, - }) - ); -} diff --git a/src/webview/editor.ts b/src/webview/editor.ts index 31aeb563..17853e16 100644 --- a/src/webview/editor.ts +++ b/src/webview/editor.ts @@ -53,7 +53,7 @@ import { } from "./cm/format/format-document-command.js"; import { frontmatterBlockField, frontmatterRevealKeymap } from "./cm/frontmatter/index.js"; import { hostDocumentReseed } from "./cm/host-reseed.js"; -import { createImagePasteDrop, imageBlockField, quollResourceBaseUri } from "./cm/image/index.js"; +import { imageBlockField, quollResourceBaseUri } from "./cm/image/index.js"; import { runFormatCommand as runInlineFormat } from "./cm/inline/inline-formatting-commands.js"; import { quollLinkClickHandler } from "./cm/link-handlers.js"; import { quollLintFixKeymap } from "./cm/lint/apply-fix.js"; @@ -66,12 +66,7 @@ import { quollMarkdownLanguage } from "./cm/markdown.js"; import { openExternalSinkFor, quollOpenExternalSink } from "./cm/open-external.js"; import { quollOutline } from "./cm/outline/index.js"; import { quollUpdateConfigSink, updateConfigSinkFor } from "./cm/outline/update-config-sink.js"; -import { - htmlTablePaste, - listReindentPaste, - pasteUrlOverSelection, - richHtmlPaste, -} from "./cm/paste/index.js"; +import { createClipboardPaste } from "./cm/paste/index.js"; import { computeReseedChange, quollDocumentEol, @@ -148,6 +143,8 @@ export type EditorOptions = { * The shell wires it to a low-alarm user-visible notice. Passed straight * through to edit-sync's `onResyncStorm`. */ onResyncStorm?: () => void; + /** Offers a failure or partial-result notice through the shell's shared slot. */ + onPasteFailure?: () => void; /** Fired from edit-sync's drain when un-acked local bytes were actually LOST — * from EITHER holder it carries them in (a held pre-ack replay buffer, or an * Edit posted and not yet acked). NOT once per discard: a superseded holder @@ -403,9 +400,10 @@ export function mountEditor(opts: EditorOptions): EditorHandle { showHeld: (content) => replaceViewText(splitToCmText(content), []), }); - const imagePaste = createImagePasteDrop({ + const clipboardPaste = createClipboardPaste({ canWrite: () => opts.getState().canWrite, post: (message) => getHost().postMessage(message), + onFailure: () => opts.onPasteFailure?.(), }); // Mirror the editor's advisory lint set to the host so it appears in the @@ -882,47 +880,8 @@ export function mountEditor(opts: EditorOptions): EditorHandle { // write-lock). Companion PAINT-side fix: the !important flex-basis in // cm/theme.ts (PR #199). quollVisibleEdgeRecovery(), - // Smart paste: a clipboard `text/html` fragment containing a
is - // converted to a GFM Markdown table and inserted through the normal edit - // pipeline. Prec.high so it arbitrates before imagePaste / default paste; - // on a non-table paste it returns false and defers. Grouped with the - // other paste handlers below. - htmlTablePaste({ canWrite: () => opts.getState().canWrite }), - // Paste-URL-over-selection: a clipboard text that is exactly one http(s) - // URL, pasted while a non-empty single-line selection exists, wraps the - // selection as `[selection](url)`. Prec.high, deferring on every other - // paste. Supersedes @codemirror/lang-markdown's built-in pasteURLAsLink - // (dropped from quollMarkdownLanguage) with allowlist-aligned detection. - pasteUrlOverSelection({ canWrite: () => opts.getState().canWrite }), - // Paste re-indent: a multi-line plain-text Markdown LIST fragment pasted - // at the start of a line inside an existing list context is re-based so - // its top level aligns with the caret's column (Obsidian analogue), inner - // structure preserved. Prec.high, deferring on every non-qualifying paste - // (single-line, non-list fragment, fence-bearing, mid-line caret, caret - // outside a list, tab-ambiguous) so htmlTablePaste / pasteUrlOverSelection - // / imagePaste / CM's default plain-text paste still run. - listReindentPaste({ canWrite: () => opts.getState().canWrite }), - // Rich paste: any clipboard `text/html` fragment that converts to Markdown - // (bold/italic, headings, nested lists, links, code, blockquotes, tables via - // the shared table core) is inserted through the normal edit pipeline. - // Prec.high, registered AFTER the table / URL / list handlers (they keep - // their fast paths) and BEFORE imagePaste. That ordering is safe for the two - // paths where this handler SWALLOWS a paste (in-code, null conversion): both - // exempt a clipboard carrying an image file item (hasImageFileItem) — NOT - // because an image copy lacks a text/html flavour; copying an image out of a - // web page carries both. The insert path hands the event on instead: with an - // image item riding along it inserts the conversion and returns false - // un-prevented, so imagePaste runs next and anchors after the inserted text. - // (A "Copy image" clipboard's HTML is a bare → null → the exempted path.) - // Non-convertible → normally return false so imagePaste / plain paste run; - // swallowed only when nothing downstream could insert and a selection is at - // stake (canDeferWithoutDataLoss in rich-html-paste.ts). - richHtmlPaste({ canWrite: () => opts.getState().canWrite }), - // Paste/drop image ingestion: capture image files, post image-write, and - // insert the relative link at a position-mapped anchor on the host's - // reply. canWrite mirrors edit-sync's readonly drop; the host is the - // authoritative gate (sniff + size cap + read-only). - imagePaste.extension, + // One owner arbitrates clipboard text, HTML and Files through settlement. + clipboardPaste.extension, EditorView.lineWrapping, // The document's EOL — NOT EditorState.lineSeparator. Providing that facet // replaces CodeMirror's default insert splitter (/\r\n?|\n/) with a literal @@ -1252,7 +1211,7 @@ export function mountEditor(opts: EditorOptions): EditorHandle { return sync.isIdentityTransition(externalEpoch, epochGeneration); }, resolveImageWrite(requestId, relativePath) { - imagePaste.resolve(view, requestId, relativePath); + clipboardPaste.resolve(requestId, relativePath); }, setLintGutter(enabled) { // Same-value no-op guard. The host posts editor-config at BOTH eager-seed diff --git a/src/webview/shell.ts b/src/webview/shell.ts index 7cd747f2..19ee3296 100644 --- a/src/webview/shell.ts +++ b/src/webview/shell.ts @@ -185,6 +185,8 @@ export function mountShell(root: HTMLElement, opts: ShellOptions): ShellHandle { // threw, which CodeMirror does not promise to roll back. The two refusals // above are decided before any mutation, so their claim is proven. "format-failed": "Format Document failed. Review the document before continuing.", + "paste-failed": + "Paste could not be completed in full. Review the document before trying again.", "handoff-unsynced": "Quoll could not confirm that your latest edits were saved, so nothing was sent to the assistant. Try again — after your next edit if this repeats.", } as const; @@ -207,6 +209,7 @@ export function mountShell(root: HTMLElement, opts: ShellOptions): ShellHandle { "format-readonly": 0, "format-too-large": 0, "format-failed": 0, + "paste-failed": 0, "handoff-unsynced": 0, }; let noticeKind: NoticeKind | null = null; @@ -467,6 +470,9 @@ export function mountShell(root: HTMLElement, opts: ShellOptions): ShellHandle { resourceBaseUri, getState: () => state, dispatch, + onPasteFailure: () => { + showNotice("paste-failed"); + }, onResyncStorm: showStormNotice, onLocalEditDiscarded: showDiscardNotice, onReadonlyHold: showHoldNotice, diff --git a/test/webview/cm-crlf-line-model.test.ts b/test/webview/cm-crlf-line-model.test.ts index 6186606f..bc30ace2 100644 --- a/test/webview/cm-crlf-line-model.test.ts +++ b/test/webview/cm-crlf-line-model.test.ts @@ -27,7 +27,6 @@ import { EditorSelection, EditorState, Text } from "@codemirror/state"; import { EditorView } from "@codemirror/view"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { autoCloseFenceOnEnter } from "../../src/webview/cm/fenced-code/fenced-code-enter-keymap.js"; -import { addPendingAnchor } from "../../src/webview/cm/image/image-paste.js"; import { continueListOnEnter } from "../../src/webview/cm/list/list-continuation-keymap.js"; import { quollDocumentEol } from "../../src/webview/cm/seed.js"; import { type EditorHandle, mountEditor } from "../../src/webview/editor.js"; @@ -265,7 +264,7 @@ describe("Quoll's own multi-line insert paths, in a CRLF document", () => { expectCleanLineModel(view.state.doc); }); - it("imagePaste (resolveImageWrite) keeps a clean line model", () => { + it("imagePaste (resolveImageWrite) keeps a clean line model", async () => { const { handle, view } = mount(); handle.applyDocument({ content: "ab\r\ncd", @@ -274,10 +273,15 @@ describe("Quoll's own multi-line insert paths, in a CRLF document", () => { docVersion: 1, ...PAIR, }); - // Anchor mid-line so the insert carries the leading "\n" (image-paste.ts's - // standalone-block prefix), exercising the multi-line insert path. - view.dispatch({ effects: addPendingAnchor.of({ requestId: "1", anchor: 1 }) }); - handle.resolveImageWrite("1", "./assets/x.png"); + view.dispatch({ selection: { anchor: 1 } }); + firePasteAt(view.contentDOM, { files: [{ type: "image/png" }] }); + await vi.waitFor(() => + expect(postMessage.mock.calls.some(([message]) => message.type === "image-write")).toBe(true) + ); + const request = postMessage.mock.calls + .map(([message]) => message) + .find((message) => message.type === "image-write"); + handle.resolveImageWrite(request.requestId, "./assets/x.png"); expectLineModel(view.state.doc, ["a", "![](./assets/x.png)", "b", "cd"]); expectCleanLineModel(view.state.doc); }); diff --git a/test/webview/image/cm-image-paste.test.ts b/test/webview/image/cm-image-paste.test.ts index be4c5e74..8dcc4f6c 100644 --- a/test/webview/image/cm-image-paste.test.ts +++ b/test/webview/image/cm-image-paste.test.ts @@ -1,60 +1,34 @@ // @vitest-environment happy-dom -import { EditorState, type Extension, StateField } from "@codemirror/state"; +import { EditorState, type Extension } from "@codemirror/state"; import { EditorView } from "@codemirror/view"; -import { afterEach, describe, expect, it, vi } from "vitest"; - +import { afterEach, expect, it, vi } from "vitest"; import { - isWebviewToHost, MAX_IMAGE_BYTES, PROTOCOL_VERSION, type WebviewToHost, } from "../../../src/shared/protocol.js"; import { hostDocumentReseed } from "../../../src/webview/cm/host-reseed.js"; -import { - addPendingAnchor, - createImageFileTransport, - createImagePasteDrop, - isIngestibleImageItem, - pendingImageAnchors, -} from "../../../src/webview/cm/image/image-paste.js"; +import { createImageFileTransport } from "../../../src/webview/cm/image/image-paste.js"; +import { createClipboardPaste } from "../../../src/webview/cm/paste/clipboard-paste.js"; +import { pendingPasteOperations } from "../../../src/webview/cm/paste/paste-operation.js"; import { fireDragOverAt, fireDropAt, firePasteAt, IMAGE_FILE, - makeClipboardData, } from "../helpers/clipboard-double.js"; import { imageWrites, sizedImageFile } from "../helpers/image-paste-doubles.js"; -function mount(doc: string, canWrite = true, extras: Extension = []) { +const views: EditorView[] = []; +function mount(doc = "", canWrite = true, extras: Extension = []) { const post = vi.fn<(message: WebviewToHost) => void>(); - const paste = createImagePasteDrop({ canWrite: () => canWrite, post }); + const paste = createClipboardPaste({ canWrite: () => canWrite, post, onFailure: vi.fn() }); const view = new EditorView({ state: EditorState.create({ doc, extensions: [paste.extension, extras] }), }); + views.push(view); return { view, paste, post }; } - -/** A StateField that throws when it sees the insert — the unrelated-extension failure - * resolve()'s dispatch catch is really for. A StateField and not a ViewPlugin because - * CM treats the two oppositely: `PluginInstance.update` catches plugin exceptions and - * routes them to `logException`, so a plugin can never reach that catch, while a field - * throws out of transaction construction and does. (Quoll's block widgets are fields - * for unrelated reasons — see the block-widget StateField rule — so this is the shape - * its own extensions would fail in.) */ -const throwingField = StateField.define({ - create: () => null, - update(value, tr) { - if (tr.docChanged) { - throw new Error("unrelated widget field blew up"); - } - return value; - }, -}); - -const anchorIds = (view: EditorView): string[] => - view.state.field(pendingImageAnchors).map((p) => p.requestId); - // The 8-byte PNG signature plus the head of its IHDR chunk. Real bytes rather than // text so a decode of the posted payload can PROVE the file survived the data-URL // round trip: a split that kept the `data:image/png;base64,` prefix, or that sliced @@ -101,7 +75,6 @@ function stubFileReader(drive: (reader: StubFileReader) => void): void { * when its whole observable is the SYNCHRONOUS pending anchor. Deliberate rather * than incidental: a read that never finishes cannot outlive `view.destroy()` and * post into a torn-down view. */ -const stubReadThatNeverCompletes = () => stubFileReader(() => {}); /** happy-dom has no layout engine, so `view.posAtCoords` reaches through an * undefined client rect and THROWS at SOME coordinates rather than returning a @@ -117,1003 +90,143 @@ function stubDropPos(view: EditorView, pos: number | null) { afterEach(() => { vi.unstubAllGlobals(); + views.forEach((view) => { + view.destroy(); + }); + views.length = 0; vi.restoreAllMocks(); driveRead = () => {}; }); -describe("pendingImageAnchors", () => { - it("maps an anchor through insertions, staying AFTER text inserted at it", () => { - const { view } = mount("hello"); - const anchorOne = () => - view.state.field(pendingImageAnchors).find((p) => p.requestId === "1")?.anchor; - view.dispatch({ effects: addPendingAnchor.of({ requestId: "1", anchor: 2 }) }); - view.dispatch({ changes: { from: 0, insert: "XX" } }); - expect(anchorOne()).toBe(4); - // Association 1, and the second assertion is what pins it: an insertion landing - // EXACTLY on the anchor must leave the anchor after it. With -1 the anchor stays - // put, so a keystroke at the caret while the host round-trip is in flight would - // land the image link above the character the user just typed. - view.dispatch({ changes: { from: 4, insert: "YY" } }); - expect(anchorOne()).toBe(6); - }); - - it("keeps anchors on a same-content reseed but clears them on a wholesale reseed", () => { - const { view } = mount("hello"); - view.dispatch({ effects: addPendingAnchor.of({ requestId: "1", anchor: 3 }) }); - view.dispatch({ annotations: hostDocumentReseed.of(true) }); // no changes → keep - expect(view.state.field(pendingImageAnchors).length).toBe(1); - view.dispatch({ - changes: { from: 0, to: view.state.doc.length, insert: "world" }, - annotations: hostDocumentReseed.of(true), - }); - expect(view.state.field(pendingImageAnchors).length).toBe(0); - }); +it("transport settles callbacks without inserting", async () => { + const { view, post } = mount("hello"); + const transport = createImageFileTransport({ post }); + const settled = vi.fn(); + transport.start([pngFile()], settled); + await vi.waitFor(() => expect(imageWrites(post)).toHaveLength(1)); + expect(decodeBase64(imageWrites(post)[0].data)).toEqual(PNG_BYTES); + transport.resolve(imageWrites(post)[0].requestId, "assets/a.png"); + expect(settled).toHaveBeenCalledWith(0, "assets/a.png"); + expect(view.state.doc.toString()).toBe("hello"); }); - -describe("resolve", () => { - it("inserts a standalone image link at the mapped anchor and clears the entry", () => { - const { view, paste } = mount("ab"); - view.dispatch({ effects: addPendingAnchor.of({ requestId: "1", anchor: 1 }) }); - paste.resolve(view, "1", "./assets/x.png"); - expect(view.state.doc.toString()).toBe("a\n![](./assets/x.png)\nb"); - // The caret ends up PAST the inserted block, so typing continues below the - // image rather than in front of it. - expect(view.state.selection.main.head).toBe(22); - expect(view.state.field(pendingImageAnchors).length).toBe(0); - }); - - it("inserts at a line start without prepending a blank line", () => { - // The other branch of the mid-line ternary, and the only one every other test - // here misses (they all anchor at 1). A regression to an unconditional "\n" - // silently prepends a blank line to every image pasted at a line start — byte - // noise invisible in the WYSIWYG surface, found only in `git diff`. - const { view, paste } = mount("ab"); - view.dispatch({ effects: addPendingAnchor.of({ requestId: "1", anchor: 0 }) }); - paste.resolve(view, "1", "./assets/x.png"); - expect(view.state.doc.toString()).toBe("![](./assets/x.png)\nab"); - expect(view.state.selection.main.head).toBe(20); - }); - - it("warns — the file is already on disk — when the doc goes read-only mid round-trip", () => { - // The costly half of the branch this test's sibling below shares with it: the - // host has ALREADY written the image by the time resolve runs, so a read-only - // flip here leaves a file in the workspace with no link and (before this warn) - // nothing anywhere naming it. `relativePath` is carried because it is the only - // way to find that orphan afterwards; it is a workspace-relative path the host - // itself chose, not clipboard content. - const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); - const { view, paste } = mount("ab", false); - view.dispatch({ effects: addPendingAnchor.of({ requestId: "1", anchor: 1 }) }); - paste.resolve(view, "1", "./assets/x.png"); - expect(view.state.doc.toString()).toBe("ab"); - expect(view.state.field(pendingImageAnchors).length).toBe(0); - expect(warn.mock.calls).toEqual([ - [ - "[quoll] pasted image written but not linked: document went read-only mid round-trip", - { requestId: "1", relativePath: "./assets/x.png" }, - ], - ]); - view.destroy(); - }); - - it("reports a non-completing host write WITHOUT the orphan wording (relativePath null)", () => { - // The other arm, and the reason the branch is split rather than sharing one - // warn: the two are opposites. Here nothing was linked because the host said - // no; there is no path to report, so the message must not borrow the sibling's - // "written but not linked" wording — a reader chasing an orphan file would find - // nothing. Asserted as the COMPLETE list precisely to pin that separation: - // collapsing the two arms back into one shared warn goes red here. - // - // This arm was briefly silent, on the theory that the host had already toasted - // and left nothing behind. Both halves are false in cases the wire cannot tell - // apart (a repeat session-cap rejection shows the user nothing — `warned` - // latches in image-write-budget.ts; a write-failure rejection can leave a - // partial file — `workspace.fs.writeFile` is not atomic), and `ok:false` on the - // wire carries no reason to distinguish them by. - const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); - const { view, paste } = mount("ab"); - view.dispatch({ effects: addPendingAnchor.of({ requestId: "1", anchor: 1 }) }); - paste.resolve(view, "1", null); - expect(view.state.doc.toString()).toBe("ab"); - expect(view.state.field(pendingImageAnchors).length).toBe(0); - // "did not complete", not "refused": `ok:false` also carries the write-FAILURE - // case, where the host accepted the write and the filesystem lost. Pinning the - // exact string keeps the message reason-neutral — naming a cause the wire never - // sent is the overclaim this whole arm was rewritten to stop making. - expect(warn.mock.calls).toEqual([ - ["[quoll] image write did not complete on the host; no link inserted", { requestId: "1" }], - ]); - view.destroy(); - }); - - it("warns when a wholesale reseed cleared the anchor before the host replied", () => { - // The commonest way this fires, and the expensive one: the host reseeds the - // document mid round-trip, `pendingImageAnchors` drops every anchor (it cannot - // map them through a wholesale replace), and the reply then arrives with the - // image ALREADY WRITTEN into the workspace. Nothing is inserted — correct, vs. - // guessing a position — but before this warn the file was left in the workspace - // with no link and no line anywhere naming it. - const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); - const { view, paste } = mount("ab"); - view.dispatch({ effects: addPendingAnchor.of({ requestId: "1", anchor: 1 }) }); - // A real wholesale reseed: annotated AND doc-changing, which is the exact pair - // the field's update() requires before it clears (a no-op reseed keeps anchors). - view.dispatch({ - changes: { from: 0, to: view.state.doc.length, insert: "fresh" }, - annotations: hostDocumentReseed.of(true), - }); - expect(view.state.field(pendingImageAnchors)).toEqual([]); - - paste.resolve(view, "1", "./assets/x.png"); - - expect(view.state.doc.toString()).toBe("fresh"); // nothing inserted - expect(warn.mock.calls).toEqual([ - [ - "[quoll] image-write result had no pending anchor; no link inserted", - { requestId: "1", relativePath: "./assets/x.png" }, - ], - ]); - view.destroy(); - }); - - it("still warns when the anchor is gone AND the host sent no path", () => { - // The corner where the two vanished-trace cases meet, and the only combination - // the other tests leave open: a reseed cleared the anchor AND the reply carries - // `relativePath: null`. It is worth its own test because the obvious "tidy-up" - // here — skip the log when there is no path to report, since nothing was written - // — is wrong twice over: a null reply can follow a failed write that already left - // a partial file, and the host's own message is not guaranteed (a repeat - // session-cap rejection shows the user nothing). With no anchor and no path, this - // line is the entire remaining trace of the paste. An early return added above - // the warn passes every other test in this file; it goes red here. - const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); - const { view, paste } = mount("ab"); - paste.resolve(view, "gone", null); - expect(view.state.doc.toString()).toBe("ab"); - expect(warn.mock.calls).toEqual([ - [ - "[quoll] image-write result had no pending anchor; no link inserted", - { requestId: "gone", relativePath: null }, - ], - ]); - view.destroy(); - }); - - it("ignores an unknown requestId while another anchor is still pending", () => { - // The anchor seeded here is what makes this a test of the ID MATCH rather than - // of the empty-queue case: on an empty field `find(...)` and `[0]` are both - // undefined, so the guard reads as "correct" while ignoring the requestId - // entirely. This is the terminus of the sessionNonce design — a late reply from - // a previous webview session must be a no-op, not a resolve of whatever anchor - // happens to be first in the queue, which would write one image's path onto - // another image's position. - const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); - const { view, paste } = mount("ab"); - view.dispatch({ effects: addPendingAnchor.of({ requestId: "1", anchor: 1 }) }); - paste.resolve(view, "nope", "./assets/x.png"); - // The document is the hazard itself; the surviving entry is the other half — - // the real anchor must still be waiting for its own reply. - expect(view.state.doc.toString()).toBe("ab"); - expect(view.state.field(pendingImageAnchors)).toEqual([{ requestId: "1", anchor: 1 }]); - // A stale reply from a previous webview session lands here too, so the same - // warn covers it: the message names the OUTCOME (no anchor, no link) rather - // than guessing which of the two causes produced it. - expect(warn.mock.calls).toEqual([ - [ - "[quoll] image-write result had no pending anchor; no link inserted", - { requestId: "nope", relativePath: "./assets/x.png" }, - ], - ]); - }); - - it("logs and clears the anchor when the insert throws on a stale position", () => { - // The failure the whole guard exists for, raised WITHOUT a mock: an anchor past - // the doc end makes `doc.lineAt` throw a real RangeError. It throws BEFORE the - // dispatch, which is why the guard cannot sit around the dispatch alone — there - // it escapes into the shell's message handler instead. - // - // Everything asserted here is downstream of one fact: the host has ALREADY - // written the image by the time resolve runs. So a failure must leave a LOUD - // orphan (logged, no pending entry) rather than a silent one — a leaked entry - // pins a dead anchor and makes the user's re-paste read as a duplicate. - const error = vi.spyOn(console, "error").mockImplementation(() => {}); - const { view, paste } = mount("ab"); - view.dispatch({ effects: addPendingAnchor.of({ requestId: "1", anchor: 99 }) }); - - expect(() => paste.resolve(view, "1", "./assets/x.png")).not.toThrow(); - // The label is asserted, not just the fact of a log: the sibling dispatch failure - // logs from the same function with the same recovery, and only the message tells - // a maintainer which of the two they are looking at. - expect(error.mock.calls).toEqual([ - [ - "[quoll] pasted image link insert failed: stale anchor", - { - err: expect.any(RangeError), - requestId: "1", - anchor: 99, - relativePath: "./assets/x.png", - docLength: 2, - }, - ], - ]); - expect(view.state.doc.toString()).toBe("ab"); - expect(view.state.field(pendingImageAnchors)).toEqual([]); - view.destroy(); - }); - - it("clears only the failing anchor, leaving a second pending image intact", () => { - // With one seeded anchor, "cleared the failing requestId" and "cleared everything" - // are the same observable — and one paste event routinely mints several requestIds - // (see the pair test above). Two anchors are what make the scoping visible: a - // regression to a wholesale clear on any insert failure would silently drop the - // OTHER image's still-valid pending link, turning one bad paste into two. - const error = vi.spyOn(console, "error").mockImplementation(() => {}); - const { view, paste } = mount("ab"); - view.dispatch({ effects: addPendingAnchor.of({ requestId: "1", anchor: 99 }) }); - view.dispatch({ effects: addPendingAnchor.of({ requestId: "2", anchor: 1 }) }); - - expect(() => paste.resolve(view, "1", "./assets/x.png")).not.toThrow(); - expect(error).toHaveBeenCalledTimes(1); - expect(view.state.field(pendingImageAnchors)).toEqual([{ requestId: "2", anchor: 1 }]); - // The survivor is still resolvable — the failed sibling neither consumed nor - // corrupted it. - paste.resolve(view, "2", "./assets/y.png"); - expect(view.state.doc.toString()).toBe("a\n![](./assets/y.png)\nb"); - expect(view.state.field(pendingImageAnchors)).toEqual([]); - view.destroy(); - }); - - it("blames the dispatch, not the anchor, when an unrelated field throws on the insert", () => { - // The case the "dispatch threw" label exists for, with a real extension rather than - // a stubbed dispatch: a StateField unrelated to image paste throws while CM builds - // the insert transaction. The anchor is perfectly valid here, so labelling this - // "stale anchor" would send whoever reads the log after a position bug that does - // not exist. - const error = vi.spyOn(console, "error").mockImplementation(() => {}); - const { view, paste } = mount("ab", true, throwingField); - view.dispatch({ effects: addPendingAnchor.of({ requestId: "1", anchor: 1 }) }); - - expect(() => paste.resolve(view, "1", "./assets/x.png")).not.toThrow(); - const [[label]] = error.mock.calls as [[string]]; - expect(label).toBe("[quoll] pasted image link insert failed: dispatch threw"); - // Still retired, so the orphaned file stays idempotent on a re-paste. - expect(view.state.field(pendingImageAnchors)).toEqual([]); - view.destroy(); - }); - - it("reports the PRE-insert doc length even when the throw lands after CM commits", () => { - // `ViewState.update` assigns `view.state` as its first statement, before the DOM - // work that can throw — so a failure can arrive with the insert already applied. - // Reproduced at that exact seam by letting the real dispatch run and then throwing. - // Read at log time, `docLength` would be 23 here and 2 on the stale-anchor path: - // the same field name meaning pre-insert or post-insert depending on which failure - // occurred, which is worse than useless to someone reading it cold. - const error = vi.spyOn(console, "error").mockImplementation(() => {}); - const { view, paste } = mount("ab"); - view.dispatch({ effects: addPendingAnchor.of({ requestId: "1", anchor: 1 }) }); - const realDispatch = view.dispatch.bind(view); - vi.spyOn(view, "dispatch").mockImplementation((...specs: Parameters) => { - realDispatch(...specs); - throw new Error("field blew up after the state was committed"); - }); - - expect(() => paste.resolve(view, "1", "./assets/x.png")).not.toThrow(); - const [[, context]] = error.mock.calls as [[string, { docLength: number }]]; - expect(context.docLength).toBe(2); // "ab" — what the anchor was measured against - // This mock throws on EVERY dispatch, so the follow-up clear throws too and - // reports itself. Pinned here — not because this test is about the clear, but - // because destructuring only the first call would let that second line change - // or vanish unnoticed from the one test that silently emits it. - expect(error.mock.calls.map(([label]) => label)).toEqual([ - "[quoll] pasted image link insert failed: dispatch threw", - "[quoll] failed to clear a pending image anchor", - ]); - // The insert really did land, which is precisely why the length must be captured - // beforehand: `view.state.doc` is 23 chars by the time the catch runs. - expect(view.state.doc.toString()).toBe("a\n![](./assets/x.png)\nb"); - expect(view.state.doc.length).toBe(23); - view.destroy(); - }); - - it("still logs when even the clearing dispatch fails after a teardown", () => { - // The other end of the same guard: the view dies between the shell's null-check - // and the dispatch, so BOTH the insert and the follow-up clear throw. The anchor - // then dies with the view, and clearPending no longer swallows that second - // failure — it logs it too. So BOTH console.error entries are pinned below: the - // logs are the only observable left, and a recovery attempt that failed must not - // read like one that succeeded. - const error = vi.spyOn(console, "error").mockImplementation(() => {}); - const { view, paste } = mount("ab"); - view.dispatch({ effects: addPendingAnchor.of({ requestId: "1", anchor: 1 }) }); - // Raised deliberately: CM 6.43 does not throw on dispatch to a destroyed view - // (see the clearPending test below for the measurement). The contract is pinned - // for the future, not for today's CM. - const dispatch = vi.spyOn(view, "dispatch").mockImplementation(() => { - throw new Error("dispatch on a destroyed view"); - }); - - expect(() => paste.resolve(view, "1", "./assets/x.png")).not.toThrow(); - // "dispatch threw", NOT "stale anchor": the anchor here is perfectly valid, and - // this same catch is where an unrelated field/plugin throwing on the insert lands. - // Mislabelling it would send whoever reads the log after the wrong subsystem. - expect(error.mock.calls).toEqual([ - [ - "[quoll] pasted image link insert failed: dispatch threw", - { - err: expect.any(Error), - requestId: "1", - anchor: 1, - relativePath: "./assets/x.png", - docLength: 2, - }, - ], - // The follow-up clear throws too — same dead view — and says so. Before this - // second line the recovery attempt failed in silence, so the log claimed a - // cleared anchor that was never cleared. - [ - "[quoll] failed to clear a pending image anchor", - { err: expect.any(Error), requestId: "1" }, - ], - ]); - // Twice: the insert, then the clear it attempts anyway. One call would mean the - // catch returned without trying to clear. - expect(dispatch).toHaveBeenCalledTimes(2); - view.destroy(); - }); +it("reverse replies preserve clipboard order in one replacement", async () => { + const { view, paste, post } = mount("old"); + view.dispatch({ selection: { anchor: 0, head: 3 } }); + firePasteAt(view.contentDOM, { files: [...IMAGE_FILE, ...IMAGE_FILE] }); + await vi.waitFor(() => expect(imageWrites(post)).toHaveLength(2)); + const [a, b] = imageWrites(post); + expect(a.requestId).not.toBe(b.requestId); + paste.resolve(b.requestId, "assets/b.png"); + expect(view.state.doc.toString()).toBe("old"); + paste.resolve(a.requestId, "assets/a.png"); + expect(view.state.doc.toString()).toBe("![](assets/a.png)\n![](assets/b.png)\n"); + expect(view.state.field(pendingPasteOperations)).toEqual([]); }); - -describe("isIngestibleImageItem", () => { - // This predicate is the whole of the coupling between two handlers: richHtmlPaste - // runs FIRST and defers to imagePaste on exactly the set this returns true for. - // A `true` imagePaste will not honour means richHtmlPaste defers, imagePaste - // declines, and CM core's doPaste("") replaces the user's selection with nothing. - // Both directions are pinned because both are load-bearing: over-match deletes - // text, under-match swallows a legitimate image paste. - const itemsOf = (data: Parameters[0]): DataTransferItem[] => - makeClipboardData(data).items; - - it("accepts a file item carrying a real image File", () => { - const items = itemsOf({ files: IMAGE_FILE }); - expect(items.filter(isIngestibleImageItem).length).toBe(1); - }); - - it.each([ - ["a non-image file (a copied PDF)", [{ type: "application/pdf" }]], - ["an image item whose getAsFile() is null", [{ type: "image/png", file: null }]], - ["an image item whose getAsFile() is undefined", [{ type: "image/png", file: undefined }]], - ])("rejects %s", (_label, files) => { - expect(itemsOf({ files }).some(isIngestibleImageItem)).toBe(false); - }); - - it("rejects the kind:'string' text flavours a real clipboard always carries", () => { - // Not a hypothetical: every clipboard with a text/html or text/plain flavour - // lists these, so a scan over `items` meets them before any file entry. - expect(itemsOf({ html: "

x

", text: "x" }).some(isIngestibleImageItem)).toBe(false); - }); - - it("finds the image among the text flavours of a mixed clipboard", () => { - // The shape that actually reaches richHtmlPaste's defer decision: rich HTML, - // plain text AND a bitmap, all on one clipboard. - const items = itemsOf({ html: "

x

", text: "x", files: IMAGE_FILE }); - expect(items.length).toBe(3); - expect(items.filter(isIngestibleImageItem).length).toBe(1); - }); +it("requestIds cannot collide across webview sessions", async () => { + const a = mount(); + const b = mount(); + firePasteAt(a.view.contentDOM, { files: IMAGE_FILE }); + firePasteAt(b.view.contentDOM, { files: IMAGE_FILE }); + await vi.waitFor(() => { + expect(imageWrites(a.post)).toHaveLength(1); + expect(imageWrites(b.post)).toHaveLength(1); + }); + expect(imageWrites(a.post)[0].requestId).not.toBe(imageWrites(b.post)[0].requestId); }); - -describe("imagePaste — clipboard ingestion", () => { - // The predicate above decides membership; these pin that imagePaste ACTS on - // exactly that set — the half that makes richHtmlPaste's defer safe. Without - // them the shared predicate could be correct while imageFilesFrom stopped using - // it, and richHtmlPaste would be deferring into a handler that no longer accepts - // what it was promised. - // - // Two observables, and each covers what the other cannot. An anchor is queued - // SYNCHRONOUSLY when a file is submitted, so it pins ingestion (the async post - // rides a FileReader and is pinned in the next describe). But an anchor count of - // 0 is equally true of a decline and of a consume-without-ingesting, so every - // decline test also asserts the DOCUMENT: on a decline CM core's builtin paste - // handler runs next and inserts the text flavour; on a consume it never runs. - // `defaultPrevented` separates neither — CM's builtin returns true for any truthy - // clipboardData and `runHandlers` preventDefaults on a true return, so it is true - // with or without this extension. - it("ingests a paste carrying an image file beside text flavours, at the selection head", () => { - stubReadThatNeverCompletes(); - const { view } = mount("abcd"); - view.dispatch({ selection: { anchor: 4, head: 1 } }); - firePasteAt(view.contentDOM, { files: IMAGE_FILE, html: "

x

", text: "x" }); - // The anchor's VALUE, not just its existence: the paste path derives it from - // the selection HEAD, and the selection is backwards and non-empty so neither a - // hard-coded 0 nor `.anchor` (4) can pass. (The drop path's twin is in the drop - // describe.) - expect(view.state.field(pendingImageAnchors)).toEqual([ - { requestId: expect.any(String), anchor: 1 }, - ]); - view.destroy(); - }); - - it("derives the paste anchor from the head of a FORWARD selection too", () => { - // A second FIXTURE rather than a second assertion on the one above, because no - // single selection shape can separate `head` from both of its neighbours: CM - // derives `from = min(anchor, head)`, so on the backwards selection above - // `from === head === 1` and a regression to `.from` reads identically there. - // Forward is the shape where all three diverge — anchor/from 1, head/to 4 — so - // only here does `.from` produce the wrong anchor. (Drop's twin is below.) - stubReadThatNeverCompletes(); - const { view } = mount("abcd"); - view.dispatch({ selection: { anchor: 1, head: 4 } }); - firePasteAt(view.contentDOM, { files: IMAGE_FILE, text: "x" }); - expect(view.state.field(pendingImageAnchors)).toEqual([ - { requestId: expect.any(String), anchor: 4 }, - ]); - view.destroy(); - }); - - it.each([ - ["a non-image file", [{ type: "application/pdf" }]], - ["an image item that yields no File", [{ type: "image/png", file: null }]], - ["an image item that yields undefined", [{ type: "image/png", file: undefined }]], - ["no file at all", []], - ])("declines a paste carrying only %s", (_label, files) => { - // Declining is what makes richHtmlPaste's `hasImageFileItem` a data-loss test - // rather than a preference: on these clipboards nothing downstream performs the - // paste, so richHtmlPaste must NOT defer into them. - const { view } = mount("ab"); - firePasteAt(view.contentDOM, { files, text: "ZZ" }); - expect(view.state.field(pendingImageAnchors).length).toBe(0); - // Declined, not consumed. The caret sits at 0, so CM core's insert of the text - // flavour is the difference: a handler that preventDefaults and returns true - // here leaves the doc "ab" — the "every text paste silently vanishes" - // regression this module exists to avoid. - expect(view.state.doc.toString()).toBe("ZZab"); - view.destroy(); - }); - - it("declines a text-only paste (its kind:'string' items are not files)", () => { - const { view } = mount("ab"); - firePasteAt(view.contentDOM, { html: "

x

", text: "ZZ" }); - expect(view.state.field(pendingImageAnchors).length).toBe(0); - expect(view.state.doc.toString()).toBe("ZZab"); - view.destroy(); - }); - - it("leaves a trace when it drops a zero-byte image", () => { - // The one clipboard shape where declining is invisible from the document: the - // shared predicate never looks at size, so richHtmlPaste has already deferred; - // this handler preventDefaults, skips the file and returns true. Nothing is - // inserted and CM's plain-text fallback is suppressed, so the console line is - // the only evidence the paste ever happened. All three refusals in the loop are - // asserted by TEXT, because a warn COUNT makes them interchangeable and a - // swapped refusal would go unnoticed. - const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); - const { view } = mount("ab"); - const empty = new File([], "f", { type: "image/png" }); - firePasteAt(view.contentDOM, { files: [{ type: "image/png", file: empty }], text: "ZZ" }); - expect(view.state.field(pendingImageAnchors).length).toBe(0); - expect(warn).toHaveBeenCalledWith(expect.stringContaining("zero bytes")); - // The suppression itself: the text flavour does NOT reach the document. Recorded - // rather than pinned — the handler's unconditional `preventDefault` runs before - // every refusal, so no mutation of the refusals can flip this line. - expect(view.state.doc.toString()).toBe("ab"); - view.destroy(); - }); - - it("swallows an image paste on a read-only doc without queueing an anchor", () => { - const { view } = mount("ab", false); - firePasteAt(view.contentDOM, { files: IMAGE_FILE, text: "ZZ" }); - // The anchor count is the pinning assertion: delete the `canWrite()` gate and - // the file is submitted, queueing one. The doc records the rest of the contract - // — the event is consumed, so CM's plain-text fallback never runs. (`mount(doc, - // false)` gates createImagePasteDrop only; it does not set EditorState.readOnly, - // so CM's own read-only short-circuit is not what produces this.) - expect(view.state.field(pendingImageAnchors).length).toBe(0); - expect(view.state.doc.toString()).toBe("ab"); - view.destroy(); - }); +it.each([false, true])("reseed retains ack and cancels changed content (%s)", async (changed) => { + const { view, paste, post } = mount("old"); + firePasteAt(view.contentDOM, { files: IMAGE_FILE }); + await vi.waitFor(() => expect(imageWrites(post)).toHaveLength(1)); + view.dispatch({ + ...(changed ? { changes: { from: 0, to: 3, insert: "new" } } : {}), + annotations: hostDocumentReseed.of(true), + }); + vi.spyOn(console, "warn").mockImplementation(() => {}); + paste.resolve(imageWrites(post)[0].requestId, "assets/a.png"); + expect(view.state.doc.toString()).toBe(changed ? "new" : "![](assets/a.png)\nold"); + expect(view.state.field(pendingPasteOperations)).toEqual([]); }); - -describe("imagePaste — the image-write post", () => { - // The ingestion tests above stop at the pending anchor, which is queued - // SYNCHRONOUSLY. Everything past it — the FileReader, the data-URL split, the - // post itself — is the async half, and nothing asserted it: a `post` spy existed - // but was never read. A split that shipped the `data:` prefix through, or that - // came back empty, would leave every one of those tests green while every real - // paste failed host-side validation. - it("posts the file's own bytes as prefix-stripped base64", async () => { - const { view, post } = mount("ab"); - firePasteAt(view.contentDOM, { files: [{ type: "image/png", file: pngFile() }] }); - - await vi.waitFor(() => expect(post).toHaveBeenCalledTimes(1)); - const [message] = imageWrites(post); - expect(message.protocol).toBe(PROTOCOL_VERSION); - // The prefix must be gone, and the payload must decode back to the exact file - // bytes — magic bytes included. Asserted as two facts because they fail - // differently: a kept prefix is a wire-format bug, a shifted slice is a decode - // bug, and only the second survives a `not.toMatch(/^data:/)` on its own. - expect(message.data).not.toMatch(/^data:/); - expect(Array.from(decodeBase64(message.data))).toEqual(Array.from(PNG_BYTES)); - // The host's own boundary validator is the real acceptance test for this - // message — pin it here rather than restating its bounds. - expect(isWebviewToHost(message)).toBe(true); - // The post carries the requestId of the anchor still waiting on the reply; - // without this the two halves could drift and every image would resolve to the - // unknown-requestId no-op. - expect(anchorIds(view)).toEqual([message.requestId]); - view.destroy(); - }); - - it("mints a distinct requestId per file and resolves the pair in completion order", async () => { - const { view, paste, post } = mount("ab"); - view.dispatch({ selection: { anchor: 1 } }); - firePasteAt(view.contentDOM, { - files: [ - { type: "image/png", file: pngFile() }, - { type: "image/png", file: pngFile() }, - ], - }); - - await vi.waitFor(() => expect(post).toHaveBeenCalledTimes(2)); - const ids = imageWrites(post).map((m) => m.requestId); - expect(new Set(ids).size).toBe(2); - expect(anchorIds(view).sort()).toEqual([...ids].sort()); - // Both anchors start on the same position, so the ORDER is decided by the - // mapping: resolving the first maps the second anchor past the text just - // inserted (association 1), landing the pair in completion order instead of - // stacking in reverse. The second lands on a line start, so it also exercises - // resolve()'s no-leading-newline branch from the far side. - paste.resolve(view, ids[0], "./assets/one.png"); - paste.resolve(view, ids[1], "./assets/two.png"); - expect(view.state.doc.toString()).toBe("a\n![](./assets/one.png)\n![](./assets/two.png)\nb"); - view.destroy(); - }); - - it("mints requestIds that cannot collide across webview sessions", () => { - // Each createImagePasteDrop is one webview session, and its counter restarts - // from zero. Without the per-session nonce both sessions mint the same first - // id, so a late image-write-result from the PREVIOUS session resolves this - // session's anchor and writes the wrong image path into the document. Two live - // instances is the only way to observe the nonce at all. - stubReadThatNeverCompletes(); - const first = mount("ab"); - const second = mount("ab"); - firePasteAt(first.view.contentDOM, { files: IMAGE_FILE }); - firePasteAt(second.view.contentDOM, { files: IMAGE_FILE }); - - expect(anchorIds(first.view)).toHaveLength(1); - expect(anchorIds(second.view)).not.toEqual(anchorIds(first.view)); - first.view.destroy(); - second.view.destroy(); - }); - - it.each([ - [ - "a non-string result", - new ArrayBuffer(8), - "[quoll] dropped pasted image (FileReader returned a non-string result)", - ], - [ - "a data URL whose base64 payload is empty", - "data:image/png;base64,", - "[quoll] dropped pasted image (data URL carried no base64 payload)", - ], - [ - "a result with no comma to split on", - "not-a-data-url", - "[quoll] dropped pasted image (data URL carried no base64 payload)", - ], - ])("clears the pending anchor without posting on %s", (_label, result, expectedWarn) => { - const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); - stubFileReader((reader) => { - reader.result = result; - reader.onload?.(); - }); - const { view, post } = mount("ab"); - firePasteAt(view.contentDOM, { files: IMAGE_FILE }); - - expect(post).not.toHaveBeenCalled(); - // Cleared, not merely never-added: the anchor is queued before the read starts, - // so a missing clear leaks it and the next reseed-free resolve could land an - // unrelated image link on it. - expect(view.state.field(pendingImageAnchors).length).toBe(0); - // Every one of these three refuses a paste on a WRITABLE doc: no post, no file, - // nothing inserted, and (before these warns) no trace — the same vanishing act - // the zero-byte case was fixed for. Per-case rather than a shared assertion, so - // that collapsing the two messages into one generic string goes red here. - // - // `requestId` is matched by shape, not value: it embeds a per-session - // `crypto.randomUUID()` nonce, so pinning the literal would pin the nonce. What - // must not regress is that the field is THERE — one paste event can start - // several concurrent reads, and a bare message could not say which one died. - expect(warn.mock.calls).toEqual([[expectedWarn, { requestId: expect.any(String) }]]); - view.destroy(); - }); - - it("clears the pending anchor and logs the browser's error when the read fails", () => { - const error = vi.spyOn(console, "error").mockImplementation(() => {}); - const readError = new DOMException("boom", "NotReadableError"); - stubFileReader((reader) => { - reader.error = readError; +it.each([0, 2, null])("drop uses its coordinate, falling back to selection (%s)", async (pos) => { + const { view, paste, post } = mount("abcd"); + view.dispatch({ selection: { anchor: 1, head: 3 } }); + stubDropPos(view, pos); + const event = fireDropAt(view.contentDOM, { files: IMAGE_FILE }); + expect(event.defaultPrevented).toBe(true); + const from = pos ?? 3; + expect(view.state.field(pendingPasteOperations)[0]).toMatchObject({ from, to: from }); + await vi.waitFor(() => expect(imageWrites(post)).toHaveLength(1)); + paste.resolve(imageWrites(post)[0].requestId, "assets/a.png"); + expect(view.state.doc.toString()).toBe( + `${"abcd".slice(0, from)}${from ? "\n" : ""}![](assets/a.png)\n${"abcd".slice(from)}` + ); +}); +it("file drag permits drop; ordinary drag and drop delegate", () => { + const { view, post } = mount("old"); + expect(fireDragOverAt(view.contentDOM, { files: IMAGE_FILE }).defaultPrevented).toBe(true); + fireDropAt(view.contentDOM, { text: "" }); + expect(imageWrites(post)).toEqual([]); +}); +it("read-only paste and drop start no reads or posts", () => { + const { view, post } = mount("old", false); + stubDropPos(view, 0); + expect(firePasteAt(view.contentDOM, { files: IMAGE_FILE }).defaultPrevented).toBe(true); + expect(fireDropAt(view.contentDOM, { files: IMAGE_FILE }).defaultPrevented).toBe(true); + expect(view.state.field(pendingPasteOperations)).toEqual([]); + expect(imageWrites(post)).toEqual([]); +}); +it.each([ + "read", + "abort", + "post", + "malformed", +])("local transfer failure settles slot (%s)", (failure) => { + stubFileReader((reader) => { + if (failure === "read") { reader.onerror?.(); - }); - const { view, post } = mount("ab"); - firePasteAt(view.contentDOM, { files: IMAGE_FILE }); - - expect(post).not.toHaveBeenCalled(); - expect(view.state.field(pendingImageAnchors).length).toBe(0); - // There is no webview toast channel for this path, so the console line is the - // only trace a failed read leaves anywhere — and `reader.error` is the only - // description of WHY the browser failed. Discarding it (the shape this test - // replaced) left "failed to read" with nothing to act on. Pinned as the - // COMPLETE call list, by text AND by exhaustion: CodeMirror swallows an - // exception thrown inside a domEventHandler (`bindHandler` → `logException`) - // and reports it as a further console.error, which a text-only - // `toHaveBeenCalledWith` would not notice. - expect(error.mock.calls).toEqual([ - ["[quoll] failed to read pasted image", { err: readError, requestId: expect.any(String) }], - ]); - view.destroy(); - }); - - it("clears the pending anchor when the post is refused by the transport", async () => { - const error = vi.spyOn(console, "error").mockImplementation(() => {}); - // mount()'s own `post` spy, not a hand-rolled EditorView: createImagePasteDrop - // captures it via opts.post, so mockImplementation reaches the same closure — - // and a future change to mount() cannot silently skip this test. - const { view, post } = mount("ab"); - post.mockImplementation(() => { - throw new Error("panel disposed mid-post"); - }); - firePasteAt(view.contentDOM, { files: [{ type: "image/png", file: pngFile() }] }); - - // safePostMessage swallows the throw and reports false; the anchor must not - // outlive a message the host never received. Observed through the post call AND - // the complete console.error list — the combination that separates this path - // from a read failure and from a crash logged after it. - await vi.waitFor(() => expect(post).toHaveBeenCalledTimes(1)); - expect(error.mock.calls).toEqual([ - ["[quoll] postMessage(image-write) failed", expect.any(Error)], - ]); - expect(view.state.field(pendingImageAnchors).length).toBe(0); - view.destroy(); + return; + } + if (failure === "abort") { + (reader as unknown as { onabort: () => void }).onabort(); + return; + } + reader.result = failure === "malformed" ? new ArrayBuffer(0) : "data:image/png;base64,eA=="; + reader.onload?.(); }); - - it("logs — but does not rethrow — a clearPending dispatch that fails after teardown", () => { - const error = vi.spyOn(console, "error").mockImplementation(() => {}); - // Muted, not asserted: getting to clearPending goes through the empty-base64 - // arm, whose warn is pinned by the `it.each` above. Nothing to add here — this - // spy exists only to keep that line out of the suite output. - vi.spyOn(console, "warn").mockImplementation(() => {}); - const readers: StubFileReader[] = []; - stubFileReader((reader) => { - readers.push(reader); - }); - const { view, post } = mount("ab"); - firePasteAt(view.contentDOM, { files: IMAGE_FILE }); - expect(view.state.field(pendingImageAnchors).length).toBe(1); - - view.destroy(); - // CM 6.43 does NOT throw on dispatch to a destroyed view — EditorView.update - // early-returns on `this.destroyed` (measured: no throw, and the state update is - // even applied). So destroying alone cannot exercise clearPending's catch; the - // throw is raised DELIBERATELY here to pin the contract itself. Do not read this - // as "CM throws": the point is the future, where a real browser or a CM bump - // that restores the throw must not turn a tab closed mid-read into an - // unhandled rejection escaping the FileReader callback. - const dispatch = vi.spyOn(view, "dispatch").mockImplementation(() => { - throw new Error("dispatch on a destroyed view"); - }); - const reader = readers[0]; - reader.result = "not-a-data-url"; // no comma to split on → clearPending - expect(() => reader.onload?.()).not.toThrow(); - expect(dispatch).toHaveBeenCalledTimes(1); - expect(post).not.toHaveBeenCalled(); - // Swallowed for the CALLER (nothing escapes into the FileReader callback), but - // no longer swallowed for the READER. Pinned as the complete list so the line - // cannot be quietly dropped back to a bare `catch {}`. - expect(error.mock.calls).toEqual([ - [ - "[quoll] failed to clear a pending image anchor", - { err: expect.any(Error), requestId: expect.any(String) }, - ], - ]); + const post = vi.fn(() => { + throw new Error("post failed"); }); + const settled = vi.fn(); + createImageFileTransport({ post }).start([pngFile()], settled); + expect(settled).toHaveBeenCalledExactlyOnceWith(0, null); }); - -describe("imagePaste — per-event caps", () => { - // The two SIZE caps are decided from `file.size` BEFORE the read; the COUNT cap - // is decided from `files.length` before the loop runs at all, so its warn names a - // number of discarded files rather than one file. Either way nothing is read, so - // the pending anchor count is the complete observable and these tests can stay - // synchronous — hence `stubReadThatNeverCompletes` throughout. - - it("drops a grossly oversized image before reading it", () => { - const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); - stubReadThatNeverCompletes(); - const { view } = mount("ab"); - // 5 MiB past the reject threshold puts it 1 MiB past the transfer ceiling - // (reject + 4 MiB of headroom), i.e. into the band the webview drops itself - // rather than forwarding for a precise host-side toast. - const huge = sizedImageFile(MAX_IMAGE_BYTES + 5 * MIB); - firePasteAt(view.contentDOM, { files: [{ type: "image/png", file: huge }] }); - - expect(view.state.field(pendingImageAnchors).length).toBe(0); - expect(warn).toHaveBeenCalledWith(expect.stringContaining("exceeds transfer ceiling")); - view.destroy(); - }); - - it("still forwards an image inside the transfer-headroom band", () => { - // The band ABOVE the reject threshold but below the transfer ceiling has to - // reach the host, which answers with a precise too-large toast — protocol.ts - // sizes MAX_IMAGE_DATA_LENGTH from the ceiling for exactly this reason. Gating - // on MAX_IMAGE_BYTES instead would silently drop the whole band, degrading that - // toast into a console.warn nobody sees. - stubReadThatNeverCompletes(); - const { view } = mount("ab"); - const slightlyOver = sizedImageFile(MAX_IMAGE_BYTES + MIB); - firePasteAt(view.contentDOM, { files: [{ type: "image/png", file: slightlyOver }] }); - - expect(view.state.field(pendingImageAnchors).length).toBe(1); - view.destroy(); - }); - - it("skips — rather than stops at — a per-file refusal, so later images still land", () => { - const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); - stubReadThatNeverCompletes(); - const { view } = mount("ab"); - // The twin of the aggregate-cap test below, and the reason it takes its own - // fixture: `handle()` has THREE refusals, and only the aggregate one is meant to - // `break`. The two per-file ones must `continue` so one bad file does not - // discard the rest of the paste. Every other refusal test here is a single-file - // fixture, where `continue` and `break` are indistinguishable — a valid file - // has to sit BEHIND a refused one for the difference to become observable. - const empty = new File([], "f", { type: "image/png" }); - firePasteAt(view.contentDOM, { - files: [ - { type: "image/png", file: empty }, // refused: zero bytes - { type: "image/png", file: sizedImageFile(MAX_IMAGE_BYTES + 5 * MIB) }, // refused: over the ceiling - { type: "image/png" }, // 1 byte — must still be ingested - ], - }); - - expect(view.state.field(pendingImageAnchors).length).toBe(1); - // Both refusals are named, so the two branches stay distinguishable from each - // other as well as from the aggregate cap. - expect(warn).toHaveBeenCalledWith(expect.stringContaining("zero bytes")); - expect(warn).toHaveBeenCalledWith(expect.stringContaining("exceeds transfer ceiling")); - view.destroy(); - }); - - it("stops — rather than skips — at the per-event aggregate byte cap", () => { - const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); - stubReadThatNeverCompletes(); - const { view } = mount("ab"); - // Every file clears the per-file ceiling on its own, so only the aggregate can - // refuse any of them — the cap that exists so a multi-file drop cannot queue - // 16 × the transfer ceiling of base64 at once. - // - // Sizes are deliberately MIXED, and that is this test's whole job. 10+10+10+9 - // reaches 39 MiB, the fifth 10 MiB file would exceed the 40 MiB cap, and the - // trailing 1 MiB file would still FIT. Production documents `break`, so that - // trailing file must NOT be queued: with `continue` the count is 5. The - // uniform-size test below owns the other half — the exact boundary — because no - // running total here lands ON the cap. Neither fixture can do both. - const files = [10, 10, 10, 9, 10, 1].map((size) => ({ - type: "image/png", - file: sizedImageFile(size * MIB), - })); - firePasteAt(view.contentDOM, { files }); - - expect(view.state.field(pendingImageAnchors).length).toBe(4); - expect(warn).toHaveBeenCalledWith(expect.stringContaining("aggregate byte cap")); - view.destroy(); - }); - - it("accepts the file that lands exactly ON the aggregate byte cap (pins > over >=)", () => { - const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); - stubReadThatNeverCompletes(); - const { view } = mount("ab"); - // Five files of exactly MAX_IMAGE_BYTES: the fourth brings the running total to - // 30 + 10 = 40 MiB, exactly the cap. `>` accepts it (4 queued), `>=` refuses it - // (3) — the comparison the mixed fixture above cannot see. - const files = Array.from({ length: 5 }, () => ({ - type: "image/png", - file: sizedImageFile(MAX_IMAGE_BYTES), - })); - firePasteAt(view.contentDOM, { files }); - - expect(view.state.field(pendingImageAnchors).length).toBe(4); - expect(warn).toHaveBeenCalledWith(expect.stringContaining("aggregate byte cap")); - view.destroy(); - }); - - it("ingests at most 16 files from one event, and says how many it dropped", () => { - const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); - stubReadThatNeverCompletes(); - const { view } = mount("ab"); - // One byte each, so the count cap is the only thing that can bite. Twenty - // rather than the minimal 17 so the reported COUNT (4) is distinguishable from - // both the overflow-of-one case and from any hard-coded number. - const files = Array.from({ length: 20 }, () => ({ type: "image/png" })); - firePasteAt(view.contentDOM, { files }); - - expect(view.state.field(pendingImageAnchors).length).toBe(16); - // The refusal this cap used to make silently: the overflow is discarded whole, - // so without a line here dropping 20 images inserts 16 and tells the user - // nothing, anywhere. Pinned by TEXT like the other three refusals (a warn COUNT - // would make them interchangeable), and the dropped count is pinned too because - // it is the only part of the message a reader can act on. - expect(warn).toHaveBeenCalledWith(expect.stringContaining("count cap")); - expect(warn).toHaveBeenCalledWith(expect.stringContaining("dropped 4 image")); - view.destroy(); - }); - - it("stays silent about the count cap on an event that fits exactly", () => { - // The boundary `>` vs `>=`: at exactly 16 files nothing is discarded, so a warn - // here would cry wolf on every full-but-legal paste. No other test in this file - // sits on the cap, so without this one `>=` ships green. - const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); - stubReadThatNeverCompletes(); - const { view } = mount("ab"); - const files = Array.from({ length: 16 }, () => ({ type: "image/png" })); - firePasteAt(view.contentDOM, { files }); - - expect(view.state.field(pendingImageAnchors).length).toBe(16); - expect(warn).not.toHaveBeenCalled(); - view.destroy(); +it("per-file refusal skips oversized file and preserves later slot", () => { + stubFileReader((reader) => { + reader.result = "data:image/png;base64,eA=="; + reader.onload?.(); }); + const post = vi.fn<(message: WebviewToHost) => void>(); + const settled = vi.fn(); + createImageFileTransport({ post }).start([sizedImageFile(20 * MIB), pngFile()], settled); + expect(settled).toHaveBeenCalledWith(0, null); + expect(imageWrites(post)).toHaveLength(1); }); - -describe("imagePaste — drop and dragover", () => { - // Untested end to end until now: the drop handler shares `handle` with paste but - // owns its own read-only gate, its own preventDefault (the one that stops the - // browser navigating away from the document to the dropped file) and its own - // anchor derivation. - // - // `defaultPrevented` discriminates here, but only for events CM's builtin does - // NOT already claim. Measured on a bare EditorView carrying no imagePaste - // extension: a FILE-carrying drop is already `true` (CM's `handlers.drop` returns - // true for any dataTransfer with files, and `runHandlers` preventDefaults on a - // true return), so the file-drop tests below pin anchors instead. A TEXT-ONLY - // drop is `false`, and that is the entire observable of the decline path — the - // drop handler returns before its own preventDefault on that branch. `dragover` - // is bare-false the same way. - it("preventDefaults a file drag so the drop event can fire, and leaves other drags alone", () => { - const { view } = mount("ab"); - // Without the preventDefault the browser never fires `drop` at all, so this is - // load-bearing rather than cosmetic. - expect(fireDragOverAt(view.contentDOM, { files: IMAGE_FILE }).defaultPrevented).toBe(true); - expect(fireDragOverAt(view.contentDOM, { text: "x" }).defaultPrevented).toBe(false); - view.destroy(); - }); - - it("declines a drop carrying no image, leaving the event to CM", () => { - // The drop-side mirror of the paste decline tests, and it needs a different - // observable: nothing CM does with a text drop reaches this document, so only - // `defaultPrevented` can tell decline from consume. - // - // ⚠️ The `false` baseline depends on the double: CM's builtin drop handler reads - // the LEGACY alias `dataTransfer.getData("Text")`, which `makeClipboardData` - // deliberately does not answer (canonical note: on its `getData`), so CM declines - // too. That much is a property of the double, not of imagePaste — but the mutant - // this test exists to kill (consuming every non-image drop) flips it to `true` - // either way. - const { view } = mount("ab"); - expect(fireDropAt(view.contentDOM, { text: "x" }).defaultPrevented).toBe(false); - view.destroy(); - }); - - it("ingests an image drop at the dropped-at position", () => { - stubReadThatNeverCompletes(); - const { view } = mount("ab"); - const posAtCoords = stubDropPos(view, 2); - view.dispatch({ selection: { anchor: 0 } }); - - fireDropAt(view.contentDOM, { files: IMAGE_FILE }, { x: 12, y: 34 }); - - // The drop point, NOT the selection head (0) — pinned by driving them apart. - expect(posAtCoords).toHaveBeenCalledWith({ x: 12, y: 34 }); - expect(view.state.field(pendingImageAnchors)).toEqual([ - { requestId: expect.any(String), anchor: 2 }, - ]); - view.destroy(); - }); - - it("falls back to the selection head when the drop point maps to no position", () => { - stubReadThatNeverCompletes(); - const { view } = mount("abcd"); - stubDropPos(view, null); - view.dispatch({ selection: { anchor: 4, head: 1 } }); - - fireDropAt(view.contentDOM, { files: IMAGE_FILE }); - - // The HEAD of a backwards non-empty selection, so neither a hard-coded 0 nor - // `.anchor` (4) can pass. - expect(view.state.field(pendingImageAnchors)).toEqual([ - { requestId: expect.any(String), anchor: 1 }, - ]); - view.destroy(); - }); - - it("falls back to the head of a FORWARD selection too", () => { - // The drop-side twin of the forward-selection paste test: this fallback reads - // `selection.main.head` at its own call site, so it needs its own forward - // fixture to separate `head` (4) from `from` (1) — see that test for why one - // selection shape cannot cover both aliases. - stubReadThatNeverCompletes(); - const { view } = mount("abcd"); - stubDropPos(view, null); - view.dispatch({ selection: { anchor: 1, head: 4 } }); - - fireDropAt(view.contentDOM, { files: IMAGE_FILE }); - - expect(view.state.field(pendingImageAnchors)).toEqual([ - { requestId: expect.any(String), anchor: 4 }, - ]); - view.destroy(); - }); - - it("ingests a drop at doc position 0 rather than falling back to the selection head", () => { - // Position 0 is the ONLY drop point at which `pos ?? head` and `pos || head` - // disagree: `||` reads a valid position 0 as absent and falls back to the - // selection head, silently moving an image dropped at the very start of the - // document down to wherever the caret was. Every other stubbed position in this - // file is truthy or null, where the two operators agree. - stubReadThatNeverCompletes(); - const { view } = mount("abcd"); - stubDropPos(view, 0); - view.dispatch({ selection: { anchor: 3 } }); - - fireDropAt(view.contentDOM, { files: IMAGE_FILE }); - - expect(view.state.field(pendingImageAnchors)).toEqual([ - { requestId: expect.any(String), anchor: 0 }, - ]); - view.destroy(); - }); - - it("swallows an image drop on a read-only doc without queueing an anchor", () => { - // The read is driven to COMPLETION, unlike the neighbouring drop tests, and - // that is what gives the `post` assertion teeth: with the `canWrite()` gate - // deleted the file is submitted, this reader runs to onload and posts. Against - // a never-completing read, `post` is unreachable and the assertion cannot fail. - stubFileReader((reader) => { - reader.result = "data:image/png;base64,AAAA"; - reader.onload?.(); - }); - const { view, post } = mount("ab", false); - // Stubbed so that deleting the `canWrite()` gate makes this test FAIL rather - // than pass for the wrong reason — see stubDropPos. - stubDropPos(view, 1); - - fireDropAt(view.contentDOM, { files: IMAGE_FILE }); - - // A read-only document must still not hand the file to the browser to open - // (that is the drop handler's preventDefault, not observable here — see the - // note above). What IS observable is that no write traffic leaves the webview. - expect(view.state.field(pendingImageAnchors).length).toBe(0); - expect(post).not.toHaveBeenCalled(); - view.destroy(); +it("aggregate ceiling accepts exact boundary then stops", () => { + stubFileReader((reader) => { + reader.result = "data:image/png;base64,eA=="; + reader.onload?.(); }); -}); - -it("extracted transport never inserts and legacy adapter preserves paste behavior", async () => { - const { view, paste, post } = mount("hello"); + const post = vi.fn<(message: WebviewToHost) => void>(); const settled = vi.fn(); - const transport = createImageFileTransport({ post }); - transport.submit(pngFile(), settled); - await vi.waitFor(() => expect(imageWrites(post)).toHaveLength(1)); - const first = imageWrites(post)[0]; - transport.resolve(first.requestId, "./assets/transport.png"); - expect(settled).toHaveBeenCalledWith("./assets/transport.png"); - expect(view.state.doc.toString()).toBe("hello"); - firePasteAt(view.contentDOM, { files: IMAGE_FILE }); - await vi.waitFor(() => expect(imageWrites(post)).toHaveLength(2)); - const second = imageWrites(post)[1]; - paste.resolve(view, second.requestId, "./assets/legacy.png"); - expect(view.state.doc.toString()).toBe("![](./assets/legacy.png)\nhello"); - expect(view.state.field(pendingImageAnchors)).toEqual([]); - view.destroy(); + createImageFileTransport({ post }).start( + [...Array.from({ length: 4 }, () => sizedImageFile(MAX_IMAGE_BYTES)), pngFile(), pngFile()], + settled + ); + expect(imageWrites(post)).toHaveLength(4); + expect(settled.mock.calls).toEqual([ + [4, null], + [5, null], + ]); }); - it.each([ "encoded", "count", diff --git a/test/webview/paste/cm-clipboard-paste.test.ts b/test/webview/paste/cm-clipboard-paste.test.ts index 472b9f02..20542a43 100644 --- a/test/webview/paste/cm-clipboard-paste.test.ts +++ b/test/webview/paste/cm-clipboard-paste.test.ts @@ -1,5 +1,5 @@ // @vitest-environment happy-dom -import { EditorState } from "@codemirror/state"; +import { Compartment, EditorState } from "@codemirror/state"; import { EditorView } from "@codemirror/view"; import { afterEach, expect, it, vi } from "vitest"; @@ -320,3 +320,38 @@ it.each([ expect(transactions()).toBe(0); expect(onFailure).toHaveBeenCalledOnce(); }); + +it.each([ + "capability", + "state", +])("pending clipboard settlement preserves document after read-only flip (%s)", async (source) => { + let canWrite = true; + const readOnly = new Compartment(); + const post = vi.fn(); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const paste = createClipboardPaste({ canWrite: () => canWrite, post, onFailure: vi.fn() }); + const view = new EditorView({ + state: EditorState.create({ + doc: "keep", + selection: { anchor: 0, head: 4 }, + extensions: [paste.extension, readOnly.of(EditorState.readOnly.of(false))], + }), + }); + views.push(view); + firePasteAt(view.contentDOM, { text: "caption", files: [{ type: "image/png" }] }); + await vi.waitFor(() => expect(post).toHaveBeenCalledTimes(1)); + if (source === "capability") { + canWrite = false; + } else { + view.dispatch({ effects: readOnly.reconfigure(EditorState.readOnly.of(true)) }); + } + const selection = view.state.selection; + paste.resolve(post.mock.calls[0][0].requestId, "assets/unlinked.png"); + expect(view.state.doc.toString()).toBe("keep"); + expect(view.state.selection.eq(selection)).toBe(true); + expect(view.state.field(pendingPasteOperations)).toEqual([]); + expect(warn).toHaveBeenCalledWith( + "[quoll] pasted image written but not linked: document went read-only mid round-trip", + { requestId: expect.any(String), relativePath: "assets/unlinked.png" } + ); +}); diff --git a/test/webview/paste/cm-html-table-paste.test.ts b/test/webview/paste/cm-html-table-paste.test.ts index efc383e7..5a4a466d 100644 --- a/test/webview/paste/cm-html-table-paste.test.ts +++ b/test/webview/paste/cm-html-table-paste.test.ts @@ -1,10 +1,12 @@ // @vitest-environment happy-dom + import { EditorState } from "@codemirror/state"; import { EditorView } from "@codemirror/view"; import { describe, expect, it } from "vitest"; +import { createClipboardPaste } from "../../../src/webview/cm/paste/clipboard-paste.js"; import { decideTablePaste } from "../../../src/webview/cm/paste/html-table-paste.js"; -import { htmlTablePaste, htmlTableToGfm } from "../../../src/webview/cm/paste/index.js"; +import { htmlTableToGfm } from "../../../src/webview/cm/paste/index.js"; describe("htmlTableToGfm — structure", () => { it("converts a thead + tbody table to GFM", () => { @@ -362,7 +364,8 @@ function mount(doc: string, canWrite = true) { doc, extensions: [ EditorState.readOnly.of(!canWrite), - htmlTablePaste({ canWrite: () => canWrite }), + createClipboardPaste({ canWrite: () => canWrite, post: () => {}, onFailure: () => {} }) + .extension, ], }), }); @@ -424,14 +427,13 @@ describe("htmlTablePaste — handler", () => { view.destroy(); }); - it("defers a fragment with prose alongside a table (preserves the prose)", () => { + it("preserves prose alongside a converted table", () => { const view = mount(""); firePaste(view, { html: "

intro

AB
", text: "intro\nA\tB", }); - // Not converted: no delimiter row is inserted; the plain-text path keeps prose. - expect(view.state.doc.toString()).not.toContain("| --- |"); + expect(view.state.doc.toString()).toBe("intro\n\n| A | B |\n| --- | --- |\n"); view.destroy(); }); diff --git a/test/webview/paste/cm-list-reindent-paste.test.ts b/test/webview/paste/cm-list-reindent-paste.test.ts index 5d146f4f..80f07f3b 100644 --- a/test/webview/paste/cm-list-reindent-paste.test.ts +++ b/test/webview/paste/cm-list-reindent-paste.test.ts @@ -1,9 +1,11 @@ // @vitest-environment happy-dom + import { markdown } from "@codemirror/lang-markdown"; import { EditorState } from "@codemirror/state"; import { EditorView } from "@codemirror/view"; import { describe, expect, it } from "vitest"; -import { listReindentPaste, reindentPastedList } from "../../../src/webview/cm/paste/index.js"; +import { createClipboardPaste } from "../../../src/webview/cm/paste/clipboard-paste.js"; +import { reindentPastedList } from "../../../src/webview/cm/paste/index.js"; import { decideListPaste } from "../../../src/webview/cm/paste/list-reindent.js"; describe("reindentPastedList — pure transform", () => { @@ -110,7 +112,8 @@ function mount(doc: string, canWrite = true) { extensions: [ markdown(), EditorState.readOnly.of(!canWrite), - listReindentPaste({ canWrite: () => canWrite }), + createClipboardPaste({ canWrite: () => canWrite, post: () => {}, onFailure: () => {} }) + .extension, ], }), }); diff --git a/test/webview/paste/cm-paste-url-link.test.ts b/test/webview/paste/cm-paste-url-link.test.ts index 08c804d0..fcf5b142 100644 --- a/test/webview/paste/cm-paste-url-link.test.ts +++ b/test/webview/paste/cm-paste-url-link.test.ts @@ -1,11 +1,12 @@ // @vitest-environment happy-dom + import { history, undo } from "@codemirror/commands"; import { EditorSelection, EditorState } from "@codemirror/state"; import type { EditorView } from "@codemirror/view"; import { describe, expect, it } from "vitest"; - import { quollMarkdownLanguage } from "../../../src/webview/cm/markdown.js"; -import { detectPasteLinkUrl, pasteUrlOverSelection } from "../../../src/webview/cm/paste/index.js"; +import { createClipboardPaste } from "../../../src/webview/cm/paste/clipboard-paste.js"; +import { detectPasteLinkUrl } from "../../../src/webview/cm/paste/index.js"; import { decideUrlPaste, selectionIsPlainText, @@ -84,7 +85,8 @@ function mount(doc: string, anchor: number, head: number, canWrite = true): Edit quollMarkdownLanguage(), EditorState.readOnly.of(!canWrite), history(), - pasteUrlOverSelection({ canWrite: () => canWrite }), + createClipboardPaste({ canWrite: () => canWrite, post: () => {}, onFailure: () => {} }) + .extension, ], }), }); diff --git a/test/webview/paste/cm-rich-html-image-paste.test.ts b/test/webview/paste/cm-rich-html-image-paste.test.ts index 960dd262..0df4ce05 100644 --- a/test/webview/paste/cm-rich-html-image-paste.test.ts +++ b/test/webview/paste/cm-rich-html-image-paste.test.ts @@ -1,180 +1,98 @@ // @vitest-environment happy-dom -// richHtmlPaste and imagePaste as the REAL pair, in editor.ts's order (richHtmlPaste -// first; it is Prec.high regardless). cm-rich-html-paste.test.ts pins the hand-off -// against a sentinel; this file pins what the two handlers produce together when -// one clipboard carries a convertible `text/html` flavour AND an image file item. import { history, redo, redoDepth, undo } from "@codemirror/commands"; import { EditorState } from "@codemirror/state"; import { EditorView } from "@codemirror/view"; -import { afterEach, describe, expect, it, vi } from "vitest"; - -import { MAX_IMAGE_BYTES, type WebviewToHost } from "../../../src/shared/protocol.js"; -import { - createImagePasteDrop, - pendingImageAnchors, -} from "../../../src/webview/cm/image/image-paste.js"; -import { richHtmlPaste } from "../../../src/webview/cm/paste/rich-html-paste.js"; +import { afterEach, expect, it, vi } from "vitest"; +import type { WebviewToHost } from "../../../src/shared/protocol.js"; +import { createClipboardPaste } from "../../../src/webview/cm/paste/clipboard-paste.js"; +import { pendingPasteOperations } from "../../../src/webview/cm/paste/paste-operation.js"; import { firePasteAt, IMAGE_FILE, makeClipboardData } from "../helpers/clipboard-double.js"; -import { imageWrites, sizedImageFile } from "../helpers/image-paste-doubles.js"; - -const CAPTION_HTML = "
caption
"; +import { imageWrites } from "../helpers/image-paste-doubles.js"; -function mount(doc: string, canWrite = true, withRichHtml = true) { +const views: EditorView[] = []; +afterEach(() => { + views.forEach((view) => { + view.destroy(); + }); + views.length = 0; + vi.restoreAllMocks(); +}); +function mount(doc = "") { const post = vi.fn<(message: WebviewToHost) => void>(); - const paste = createImagePasteDrop({ canWrite: () => canWrite, post }); + const paste = createClipboardPaste({ canWrite: () => true, post, onFailure: vi.fn() }); const view = new EditorView({ state: EditorState.create({ doc, selection: { anchor: doc.length }, - extensions: [ - history(), - EditorState.readOnly.of(!canWrite), - withRichHtml ? richHtmlPaste({ canWrite: () => canWrite }) : [], - paste.extension, - ], + extensions: [history(), paste.extension], }), }); + views.push(view); return { view, paste, post }; } - -const anchors = (view: EditorView) => view.state.field(pendingImageAnchors); - -afterEach(() => { - vi.restoreAllMocks(); -}); - -describe("richHtmlPaste + imagePaste — one clipboard carrying HTML and an image file", () => { - it("keeps the caption when the image file is zero bytes", () => { - vi.spyOn(console, "warn").mockImplementation(() => {}); - const { view, post } = mount(""); - firePasteAt(view.contentDOM, { - html: CAPTION_HTML, - files: [{ type: "image/png", file: new File([], "empty.png", { type: "image/png" }) }], - }); - expect(view.state.doc.toString()).toBe("caption\n"); - expect(anchors(view)).toEqual([]); - expect(imageWrites(post)).toEqual([]); - view.destroy(); - }); - - it("keeps the caption when the host refuses the image (webview accepts, host replies null)", async () => { - vi.spyOn(console, "warn").mockImplementation(() => {}); - const { view, paste, post } = mount(""); - firePasteAt(view.contentDOM, { html: CAPTION_HTML, files: IMAGE_FILE }); - await vi.waitFor(() => expect(imageWrites(post)).toHaveLength(1)); - const [{ requestId }] = imageWrites(post); - paste.resolve(view, requestId, null); - expect(view.state.doc.toString()).toBe("caption\n"); - expect(anchors(view)).toEqual([]); - view.destroy(); - }); - - it("keeps the caption and the accepted images when the aggregate cap drops a later file", async () => { - vi.spyOn(console, "warn").mockImplementation(() => {}); - const { view, post } = mount(""); - const files = Array.from({ length: 5 }, () => ({ - type: "image/png", - file: sizedImageFile(MAX_IMAGE_BYTES), - })); - firePasteAt(view.contentDOM, { html: CAPTION_HTML, files }); - expect(view.state.doc.toString()).toBe("caption\n"); - const head = view.state.selection.main.head; - expect(head).toBe(8); - expect(anchors(view).map((p) => p.anchor)).toEqual([head, head, head, head]); - await vi.waitFor(() => expect(imageWrites(post)).toHaveLength(4)); - view.destroy(); +it.each([false, true])("caption and accepted image settle together (plain=%s)", async (plain) => { + const { view, paste, post } = mount(); + firePasteAt(view.contentDOM, { + html: "
caption
", + ...(plain ? { text: "caption" } : {}), + files: IMAGE_FILE, }); - - it("inserts the caption AND the image link on success, the image after the caption", async () => { - const { view, paste, post } = mount("intro\n"); - firePasteAt(view.contentDOM, { html: CAPTION_HTML, files: IMAGE_FILE }); - expect(view.state.doc.toString()).toBe("intro\n\ncaption\n"); - const head = view.state.selection.main.head; - expect(head).toBe(15); - expect(anchors(view)).toEqual([{ requestId: expect.any(String), anchor: head }]); - await vi.waitFor(() => expect(imageWrites(post)).toHaveLength(1)); - paste.resolve(view, imageWrites(post)[0].requestId, "assets/a.png"); - expect(view.state.doc.toString()).toBe("intro\n\ncaption\n![](assets/a.png)\n"); - expect(anchors(view)).toEqual([]); - view.destroy(); + expect(view.state.doc.toString()).toBe(""); + await vi.waitFor(() => expect(imageWrites(post)).toHaveLength(1)); + paste.resolve(imageWrites(post)[0].requestId, "assets/a.png"); + expect(view.state.doc.toString()).toBe("caption\n![](assets/a.png)\n"); + expect(view.state.field(pendingPasteOperations)).toEqual([]); + expect(undo(view)).toBe(true); + expect(view.state.doc.toString()).toBe(""); + expect(redo(view)).toBe(true); + expect(imageWrites(post)).toHaveLength(1); +}); +it.each([false, true])("caption survives host rejection (zero-byte=%s)", async (empty) => { + const { view, paste, post } = mount(); + firePasteAt(view.contentDOM, { + html: "
caption
", + files: empty ? [{ type: "image/png", file: new File([], "empty.png") }] : IMAGE_FILE, }); - - it("KNOWN RESIDUAL: undoing the caption does not cancel the pending image — it still lands, and the caption can no longer be redone", async () => { - // Pinned, not endorsed. Two halves: the anchor is a mapped position, not part - // of the caption's history event, so the image still lands; and resolve()'s - // insert is an ordinary history event, so it discards the redo branch the undo - // just created. Closing this belongs to the PASTE-03 clipboard-arbitration - // work; update this test with that change. + expect(view.state.doc.toString()).toBe(""); + await vi.waitFor(() => expect(imageWrites(post)).toHaveLength(1)); + paste.resolve(imageWrites(post)[0].requestId, null); + expect(view.state.doc.toString()).toBe("caption\n"); + expect(view.state.field(pendingPasteOperations)).toEqual([]); +}); +for (const [name, html] of [ + ["rich pending paste preserves redo", "

caption

"], + ["image-only pending paste preserves redo", undefined], +] as const) { + it(name, async () => { const { view, paste, post } = mount("intro\n"); - firePasteAt(view.contentDOM, { html: CAPTION_HTML, files: IMAGE_FILE }); - expect(view.state.doc.toString()).toBe("intro\n\ncaption\n"); - expect(undo(view)).toBe(true); - expect(view.state.doc.toString()).toBe("intro\n"); - expect(redoDepth(view.state)).toBe(1); - expect(anchors(view)).toEqual([{ requestId: expect.any(String), anchor: 6 }]); - await vi.waitFor(() => expect(imageWrites(post)).toHaveLength(1)); - paste.resolve(view, imageWrites(post)[0].requestId, "assets/a.png"); - expect(view.state.doc.toString()).toBe("intro\n![](assets/a.png)\n"); - expect(anchors(view)).toEqual([]); - expect(redoDepth(view.state)).toBe(0); - expect(redo(view)).toBe(false); - expect(view.state.doc.toString()).toBe("intro\n![](assets/a.png)\n"); - view.destroy(); - }); - - it("KNOWN RESIDUAL baseline: an image-only paste loses the redo of unrelated typing the same way, without richHtmlPaste", async () => { - // The mechanism above is not the caption path's: any edit undone while an - // image is pending loses its redo when the reply lands. richHtmlPaste is not - // even mounted here. - const { view, paste, post } = mount("intro\n", true, false); view.dispatch({ changes: { from: 6, insert: "typed\n" }, selection: { anchor: 12 }, userEvent: "input.type", }); - firePasteAt(view.contentDOM, { files: IMAGE_FILE }); - expect(anchors(view)).toEqual([{ requestId: expect.any(String), anchor: 12 }]); + firePasteAt(view.contentDOM, { html, files: IMAGE_FILE }); + await vi.waitFor(() => expect(imageWrites(post)).toHaveLength(1)); expect(undo(view)).toBe(true); expect(view.state.doc.toString()).toBe("intro\n"); expect(redoDepth(view.state)).toBe(1); - expect(anchors(view)).toEqual([{ requestId: expect.any(String), anchor: 6 }]); - await vi.waitFor(() => expect(imageWrites(post)).toHaveLength(1)); - paste.resolve(view, imageWrites(post)[0].requestId, "assets/a.png"); - expect(view.state.doc.toString()).toBe("intro\n![](assets/a.png)\n"); - expect(redoDepth(view.state)).toBe(0); - expect(redo(view)).toBe(false); - view.destroy(); - }); - - it("inserts exactly one copy of the caption when text/plain rides along too", async () => { - const { view, post } = mount(""); - firePasteAt(view.contentDOM, { html: CAPTION_HTML, text: "caption", files: IMAGE_FILE }); - expect(view.state.doc.toString()).toBe("caption\n"); - expect(anchors(view)).toHaveLength(1); - await vi.waitFor(() => expect(imageWrites(post)).toHaveLength(1)); - view.destroy(); - }); - - it("inserts a syntax-bearing conversion and still queues the image", async () => { - const { view, post } = mount(""); - firePasteAt(view.contentDOM, { html: "

bold

", files: IMAGE_FILE }); - expect(view.state.doc.toString()).toBe("**bold**\n"); - expect(anchors(view)).toEqual([{ requestId: expect.any(String), anchor: 9 }]); - await vi.waitFor(() => expect(imageWrites(post)).toHaveLength(1)); - view.destroy(); - }); - - it("inserts nothing and queues no image in a read-only editor", () => { - const { view, post } = mount("intro\n", false); - const event = firePasteAt(view.contentDOM, { html: CAPTION_HTML, files: IMAGE_FILE }); - expect(event.defaultPrevented).toBe(true); + expect(view.state.field(pendingPasteOperations)).toEqual([]); + vi.spyOn(console, "warn").mockImplementation(() => {}); + paste.resolve(imageWrites(post)[0].requestId, "assets/late.png"); expect(view.state.doc.toString()).toBe("intro\n"); - expect(anchors(view)).toEqual([]); - expect(imageWrites(post)).toEqual([]); - view.destroy(); + expect(redoDepth(view.state)).toBe(1); + expect(redo(view)).toBe(true); + expect(view.state.doc.toString()).toBe("intro\ntyped\n"); }); +} +it("no-op undo leaves clipboard pending", async () => { + const { view, paste, post } = mount(); + firePasteAt(view.contentDOM, { files: IMAGE_FILE }); + expect(undo(view)).toBe(false); + expect(view.state.field(pendingPasteOperations)).toHaveLength(1); + await vi.waitFor(() => expect(imageWrites(post)).toHaveLength(1)); + paste.resolve(imageWrites(post)[0].requestId, "assets/a.png"); + expect(view.state.doc.toString()).toBe("![](assets/a.png)\n"); }); - it("clipboard snapshot acquires stable File references", () => { const supplied = new File(["given"], "given.png", { type: "image/png" }); const data = makeClipboardData({ diff --git a/test/webview/paste/cm-rich-html-paste.test.ts b/test/webview/paste/cm-rich-html-paste.test.ts index 3c3a7c90..43303013 100644 --- a/test/webview/paste/cm-rich-html-paste.test.ts +++ b/test/webview/paste/cm-rich-html-paste.test.ts @@ -6,15 +6,20 @@ import { EditorView } from "@codemirror/view"; import type { Tree } from "@lezer/common"; import { describe, expect, it } from "vitest"; import { quollMarkdownLanguage } from "../../../src/webview/cm/markdown.js"; +import { createClipboardPaste } from "../../../src/webview/cm/paste/clipboard-paste.js"; import { htmlToMarkdown } from "../../../src/webview/cm/paste/html-to-markdown.js"; -import { decideRichPaste, richHtmlPaste } from "../../../src/webview/cm/paste/rich-html-paste.js"; -import { type ClipboardFlavours, firePasteAt, IMAGE_FILE } from "../helpers/clipboard-double.js"; +import { decideRichPaste } from "../../../src/webview/cm/paste/rich-html-paste.js"; +import { type ClipboardFlavours, firePasteAt } from "../helpers/clipboard-double.js"; function mount(doc: string, canWrite = true) { return new EditorView({ state: EditorState.create({ doc, - extensions: [EditorState.readOnly.of(!canWrite), richHtmlPaste({ canWrite: () => canWrite })], + extensions: [ + EditorState.readOnly.of(!canWrite), + createClipboardPaste({ canWrite: () => canWrite, post: () => {}, onFailure: () => {} }) + .extension, + ], }), }); } @@ -29,7 +34,8 @@ function mountMd(doc: string, canWrite = true) { extensions: [ markdown(), EditorState.readOnly.of(!canWrite), - richHtmlPaste({ canWrite: () => canWrite }), + createClipboardPaste({ canWrite: () => canWrite, post: () => {}, onFailure: () => {} }) + .extension, ], }), }); @@ -39,58 +45,23 @@ function mountMd(doc: string, canWrite = true) { // handler against a parse of the whole document. function mountQuoll(doc: string) { return new EditorView({ - state: EditorState.create({ - doc, - extensions: [quollMarkdownLanguage(), richHtmlPaste({ canWrite: () => true })], - }), - }); -} - -/** Mount a sentinel paste handler AFTER richHtmlPaste, exactly where imagePaste - * sits in the real editor (editor.ts), so a defer is observable directly: - * `defaultPrevented` cannot distinguish a defer from a consume, because CM core - * runs on a defer and preventDefaults on its own. `consume: true` models - * imagePaste ACCEPTING the event (preventDefault + return true); the default - * models it declining, leaving CM core to handle the paste. */ -function mountWithNextHandler(doc: string, opts: { consume?: boolean; canWrite?: boolean } = {}) { - const seen = { reachedNextHandler: false }; - const canWrite = opts.canWrite ?? true; - const view = new EditorView({ state: EditorState.create({ doc, extensions: [ - markdown(), - EditorState.readOnly.of(!canWrite), - richHtmlPaste({ canWrite: () => canWrite }), - EditorView.domEventHandlers({ - paste: (event) => { - seen.reachedNextHandler = true; - if (opts.consume) { - event.preventDefault(); - return true; - } - return false; - }, - }), + quollMarkdownLanguage(), + createClipboardPaste({ canWrite: () => true, post: () => {}, onFailure: () => {} }) + .extension, ], }), }); - return { view, seen }; } -// `items` is the source of truth — it is what BOTH imagePaste's imageFilesFrom and -// this handler's hasImageFileItem scan, through the one shared -// `isIngestibleImageItem`. `files` is derived from it, the way a real DataTransfer -// relates the two. Both default to an empty collection rather than being left -// undefined, so what a test exercises is the kind/type/getAsFile scan itself and -// never the predicate's optional chaining. The double is shared with the imagePaste -// suite so neither side's can drift from the other's — helpers/clipboard-double.ts. const firePaste = (view: EditorView, data: ClipboardFlavours): Event => firePasteAt(view.contentDOM, data); const REMOTE_IMG_HTML = ''; -describe("richHtmlPaste — handler", () => { +describe("clipboard rich conversion", () => { it("converts a rich HTML fragment and consumes the event", () => { const view = mount(""); const event = firePaste(view, { @@ -354,31 +325,6 @@ describe("richHtmlPaste — handler", () => { view.destroy(); }); - it("inserts a syntax-bearing conversion AND hands the event on when an image file rides along", () => { - // A clipboard carrying both real Markdown syntax and a bitmap (Word / Outlook - // copying a paragraph with an embedded picture) keeps both: the prose is - // inserted here, and the event is left un-prevented so imagePaste — the - // sentinel's slot — still gets it. Consuming here (the old behaviour) pasted the - // prose and silently dropped the image. The real handler pair is pinned in - // cm-rich-html-image-paste.test.ts. - const { view, seen } = mountWithNextHandler("", { consume: true }); - firePaste(view, { html: "

bold

", files: IMAGE_FILE }); - expect(seen.reachedNextHandler).toBe(true); - expect(view.state.doc.toString()).toBe("**bold**\n"); - view.destroy(); - }); - - it("swallows a rich paste carrying an image file in a read-only editor", () => { - // Read-only is decided before the image hand-off: nothing is inserted and the - // event is consumed here, so imagePaste is not reached either. - const { view, seen } = mountWithNextHandler("", { consume: true, canWrite: false }); - const event = firePaste(view, { html: "
caption
", files: IMAGE_FILE }); - expect(event.defaultPrevented).toBe(true); - expect(seen.reachedNextHandler).toBe(false); - expect(view.state.doc.toString()).toBe(""); - view.destroy(); - }); - it("still converts a rich fragment when the caret is outside any code block", () => { // Same markdown-aware mount, but the caret sits in prose: rich conversion is // unchanged (guard is a no-op outside code). @@ -465,42 +411,8 @@ describe("richHtmlPaste — handler", () => { view.destroy(); }); - it("still defers an HTML-only unconvertible paste at a bare caret", () => { - // With nothing selected there is nothing to destroy, so the handler must NOT - // over-consume — the handlers after it (imagePaste in the real editor) still - // need their turn at the same clipboard. defaultPrevented is useless as a defer - // signal here (CM core runs and preventDefaults on its own), so observe the - // deferral directly: a sentinel handler standing in for imagePaste runs only if - // this one returned false. Pins the `from !== to` half of the guard. - let reachedNextHandler = false; - const view = new EditorView({ - state: EditorState.create({ - doc: "keep-this", - extensions: [ - richHtmlPaste({ canWrite: () => true }), - EditorView.domEventHandlers({ - paste: () => { - reachedNextHandler = true; - return false; - }, - }), - ], - }), - }); - view.dispatch({ selection: { anchor: 4 } }); - firePaste(view, { html: '' }); - expect(reachedNextHandler).toBe(true); - expect(view.state.doc.toString()).toBe("keep-this"); - view.destroy(); - }); - it("consumes an unconvertible HTML-only paste carrying a NON-image file over a selection", () => { - // The file item belongs to no handler: imagePaste accepts only kind === "file" - // AND an image/ type, so it declines a PDF and hands the event back to CM core, - // whose doPaste("") — there is no text/plain — replaces the selection with - // nothing. The proxy that decides whether to defer must therefore be a SUBSET - // of imagePaste's own scan; when it was `files.length > 0` this exact clipboard - // emptied the document. + // A non-image File is pending host acceptance; it cannot delete the selection. const view = mount("keep-this"); view.dispatch({ selection: { anchor: 0, head: 9 } }); // whole doc selected const event = firePaste(view, { @@ -513,10 +425,7 @@ describe("richHtmlPaste — handler", () => { }); it("consumes an unconvertible HTML-only paste whose image item yields no File", () => { - // Matches on kind + type, but getAsFile() returns null, so imageFilesFrom skips - // it and imagePaste declines exactly as it does for the PDF above. Deferring on - // kind + type alone would leave this one clipboard shape still routing into - // doPaste("") — the same over-match, one step narrower. + // An unobtainable File leaves no renderable content or transfer candidate. const view = mount("keep-this"); view.dispatch({ selection: { anchor: 0, head: 9 } }); const event = firePaste(view, { @@ -529,13 +438,7 @@ describe("richHtmlPaste — handler", () => { }); it("consumes an unconvertible HTML-only paste whose image item returns undefined", () => { - // The same shape one step further out: imagePaste keeps a file with - // `if (file)`, so it declines an UNDEFINED getAsFile() exactly as it declines a - // null one. This handler shipped a `!== null` restatement that matched where - // imagePaste does not — it deferred, imagePaste declined, and CM's doPaste("") - // emptied the selection. Sharing isIngestibleImageItem is what makes that - // unconstructible; this is the document-level observation of it, and - // cm-image-paste.test.ts pins the same shape at the predicate. + // Undefined File results preserve the selection just like null results. const view = mount("keep-this"); view.dispatch({ selection: { anchor: 0, head: 9 } }); const event = firePaste(view, { @@ -547,45 +450,6 @@ describe("richHtmlPaste — handler", () => { view.destroy(); }); - it("defers an unconvertible HTML-only paste over a selection when an image file rides along", () => { - // The mirror image of the two above: imagePaste WILL act on this clipboard, so - // consuming here would starve the only handler that can perform the paste and - // the image would be silently dropped. Deferring is safe over a selection - // precisely because imagePaste consumes the event itself. - const { view, seen } = mountWithNextHandler("keep-this", { consume: true }); - view.dispatch({ selection: { anchor: 0, head: 9 } }); - firePaste(view, { html: REMOTE_IMG_HTML, files: IMAGE_FILE }); - expect(seen.reachedNextHandler).toBe(true); - expect(view.state.doc.toString()).toBe("keep-this"); - view.destroy(); - }); - - it("defers an image-file paste over a selection inside a code block", () => { - // The caretInCode consume branch needs the same image exemption as the - // null-conversion branch: without it, pasting a copied image while code was - // selected was preventDefault'd here and never reached imagePaste — the paste - // left no trace at all. - const { view, seen } = mountWithNextHandler("```\nfoo\n```", { consume: true }); - view.dispatch({ selection: { anchor: 4, head: 7 } }); // selects "foo" - firePaste(view, { html: REMOTE_IMG_HTML, files: IMAGE_FILE }); - expect(seen.reachedNextHandler).toBe(true); - expect(view.state.doc.toString()).toBe("```\nfoo\n```"); - view.destroy(); - }); - - it("still defers an HTML-only paste at a bare caret inside a code block", () => { - // The guard's whole justification is "deferring would delete the selection", - // which is vacuous at a caret: CM's doPaste("") with nothing selected is a - // no-op. Consuming there would swallow the event to protect nothing, and the - // handlers after this one would never get their turn at the same clipboard. - const { view, seen } = mountWithNextHandler("```\nfoo\n```"); - view.dispatch({ selection: { anchor: 5 } }); // bare caret inside "foo" - firePaste(view, { html: "

Title

" }); // no text/plain at all - expect(seen.reachedNextHandler).toBe(true); - expect(view.state.doc.toString()).toBe("```\nfoo\n```"); - view.destroy(); - }); - it("defers to CM's uri-list fallback for an in-code paste with no text/plain", () => { // hasPlainFallback is also true for text/uri-list (mirrors CM core's own // getData("text/plain") || getData("text/uri-list")). With no text/plain but a @@ -613,7 +477,7 @@ describe("richHtmlPaste — handler", () => { }); }); -describe("richHtmlPaste — plain-text-like fragments defer", () => { +describe("clipboard plain fallback", () => { // The reported bug: a plain Markdown checklist copied out of a text editor // arrives with a presentational `text/html` flavour. Converting it escaped the // user's own markers and merged the blank-line-separated items. @@ -640,30 +504,6 @@ describe("richHtmlPaste — plain-text-like fragments defer", () => { view.destroy(); }); - it("inserts a syntax-free fragment that arrives with an image file and no text/plain", () => { - // A caption
beside a copied image. Deferring here (the old behaviour) - // could not land the caption anywhere — imagePaste consumes the event, so CM's - // plain paste never runs — and when the image was then refused the whole paste - // vanished. Now the caption is inserted and the event still reaches imagePaste. - const { view, seen } = mountWithNextHandler("", { consume: true }); - firePaste(view, { html: "
caption
", files: IMAGE_FILE }); - expect(seen.reachedNextHandler).toBe(true); - expect(view.state.doc.toString()).toBe("caption\n"); - view.destroy(); - }); - - it("inserts a syntax-free fragment once when an image file AND text/plain ride along", () => { - // With an image item present the plain flavour can never land (imagePaste - // prevents CM's builtin paste), so the plain-fallback defer must not fire — - // deferring would lose the caption. The sentinel consumes like imagePaste does, - // so the exact doc string also pins that only ONE copy lands. - const { view, seen } = mountWithNextHandler("", { consume: true }); - firePaste(view, { html: "
caption
", text: "caption", files: IMAGE_FILE }); - expect(seen.reachedNextHandler).toBe(true); - expect(view.state.doc.toString()).toBe("caption\n"); - view.destroy(); - }); - it("still converts when the fragment carries real Markdown syntax", () => { const view = mount(""); const event = firePaste(view, { diff --git a/test/webview/shell.test.ts b/test/webview/shell.test.ts index e2ce9fba..e768fe1b 100644 --- a/test/webview/shell.test.ts +++ b/test/webview/shell.test.ts @@ -1,7 +1,8 @@ // @vitest-environment happy-dom + +import { EditorState, StateEffect } from "@codemirror/state"; import { EditorView, runScopeHandlers } from "@codemirror/view"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - import { createHostSessionCore, type HostSessionEvent, @@ -14,8 +15,9 @@ import { PROTOCOL_VERSION, } from "../../src/shared/protocol.js"; import { EDITOR_PREF_CSS_VARS, editorPrefToCssValue } from "../../src/webview/cm/editor-prefs.js"; -import { addPendingAnchor } from "../../src/webview/cm/image/image-paste.js"; import { proseLintEnabled } from "../../src/webview/cm/lint/index.js"; +import { pendingPasteOperations } from "../../src/webview/cm/paste/paste-operation.js"; +import { firePasteAt, IMAGE_FILE } from "./helpers/clipboard-double.js"; // shell.ts integration tests (post C3 React-free shell). Replaces the // React-era app.test.ts + persistence.test.ts. The shell mounts as a @@ -2382,53 +2384,179 @@ describe("shell — HostToWebview exhaustiveness guard", () => { }); describe("shell — image-write-result routing", () => { - // The anchors are seeded with addPendingAnchor rather than a synthesized - // paste: the paste path is already pinned by - // test/webview/image/cm-image-paste.test.ts, and driving it here would need a - // FileReader stub whose failure modes have nothing to do with the route under - // test. - const seedAnchor = (view: EditorView, requestId: string, anchor: number): void => { - view.dispatch({ effects: addPendingAnchor.of({ requestId, anchor }) }); - }; - - it("routes ok:true to an insert at the matching requestId's anchor", async () => { + async function requestAt(view: EditorView, anchor: number) { + view.dispatch({ selection: { anchor } }); + const before = postMessage.mock.calls.length; + firePasteAt(view.contentDOM, { files: IMAGE_FILE }); + await vi.waitFor(() => + expect( + postMessage.mock.calls + .slice(before) + .some(([message]) => (message as { type: string }).type === "image-write") + ).toBe(true) + ); + return ( + postMessage.mock.calls + .slice(before) + .map(([message]) => message) + .find((message) => (message as { type: string }).type === "image-write") as { + requestId: string; + } + ).requestId; + } + it("filter-vetoed paste shows failure notice with unchanged document", async () => { await mount(); - deliver(buildDocument({ docVersion: 1, content: "alpha\n" })); + deliver(buildDocument({ content: "keep" })); const view = mountedView(); - // Two pending anchors so the requestId is load-bearing: passing the wrong id - // (or ignoring it) resolves the wrong anchor and the insert lands at 0. - seedAnchor(view, "req-a", 0); - seedAnchor(view, "req-b", 6); - + view.dispatch({ selection: { anchor: 0, head: 4 } }); + firePasteAt(view.contentDOM, { text: "caption", files: IMAGE_FILE }); + await vi.waitFor(() => + expect( + postMessage.mock.calls.some( + ([message]) => (message as { type: string }).type === "image-write" + ) + ).toBe(true) + ); + const request = postMessage.mock.calls + .map(([message]) => message) + .find((message) => (message as { type: string }).type === "image-write") as { + requestId: string; + }; + expect(view.state.field(pendingPasteOperations)).toHaveLength(1); + view.dispatch({ + effects: StateEffect.appendConfig.of(EditorState.transactionFilter.of(() => [])), + }); + const selection = view.state.selection; + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + deliver({ + protocol: PROTOCOL_VERSION, + type: "image-write-result", + requestId: request.requestId, + ok: true, + relativePath: "./assets/unlinked.png", + }); + expect(view.state.doc.toString()).toBe("keep"); + expect(view.state.selection.eq(selection)).toBe(true); + expect(view.state.field(pendingPasteOperations)).toEqual([]); + expect(warn).toHaveBeenCalledWith("[quoll] pasted image written but not linked", { + relativePath: "./assets/unlinked.png", + }); + const notice = container?.querySelector(".quoll-notice-paste-failed"); + expect(notice).not.toBeNull(); + expect(notice?.textContent).toContain("Paste could not be completed"); + deliver({ + protocol: PROTOCOL_VERSION, + type: "image-write-result", + requestId: request.requestId, + ok: true, + relativePath: "./assets/unlinked.png", + }); + expect(view.state.doc.toString()).toBe("keep"); + expect(container?.querySelectorAll(".quoll-resync-notice")).toHaveLength(1); + }); + it("stronger notice survives declined paste notice", async () => { + await mount(); + vi.useFakeTimers(); + deliver(buildDocument({ content: "keep" })); + const view = mountedView(); + view.dispatch({ changes: { from: 4, insert: "x" } }); + vi.advanceTimersByTime(300); + deliver(buildDocument({ content: "keep", docVersion: 1 })); + const stronger = container?.querySelector(".quoll-notice-unsaved"); + expect(stronger).not.toBeNull(); + const text = stronger?.textContent; + const before = view.state.doc.toString(); + const requestId = await requestAt(view, 0); + view.dispatch({ + effects: StateEffect.appendConfig.of(EditorState.transactionFilter.of(() => [])), + }); + vi.spyOn(console, "warn").mockImplementation(() => {}); + deliver({ + protocol: PROTOCOL_VERSION, + type: "image-write-result", + requestId, + ok: true, + relativePath: "./assets/unlinked.png", + }); + expect(view.state.doc.toString()).toBe(before); + expect(view.state.field(pendingPasteOperations)).toEqual([]); + expect(container?.querySelector(".quoll-resync-notice")).toBe(stronger); + expect(stronger?.textContent).toBe(text); + expect(container?.querySelector(".quoll-notice-paste-failed")).toBeNull(); + (container?.querySelector(".quoll-resync-notice-dismiss") as HTMLButtonElement).click(); + vi.advanceTimersByTime(31_000); + expect(container?.querySelector(".quoll-resync-notice")).toBeNull(); + expect(view.state.doc.toString()).toBe(before); + }); + it("production editor uses one paste owner", async () => { + await mount(); + deliver(buildDocument({ content: "old" })); + const view = mountedView(); + view.dispatch({ selection: { anchor: 0, head: 3 } }); + let edits = 0; + view.dispatch({ + effects: StateEffect.appendConfig.of( + EditorView.updateListener.of((update) => { + if (update.docChanged) { + edits++; + } + }) + ), + }); + firePasteAt(view.contentDOM, { + html: "
A
B
", + text: "wrong", + files: IMAGE_FILE, + }); + expect(view.state.doc.toString()).toBe("old"); + expect(view.state.field(pendingPasteOperations)).toHaveLength(1); + await vi.waitFor(() => + expect( + postMessage.mock.calls.some( + ([message]) => (message as { type: string }).type === "image-write" + ) + ).toBe(true) + ); + const request = postMessage.mock.calls + .map(([message]) => message) + .find((message) => (message as { type: string }).type === "image-write") as { + requestId: string; + }; deliver({ protocol: PROTOCOL_VERSION, type: "image-write-result", - requestId: "req-b", + requestId: request.requestId, ok: true, relativePath: "./assets/shot.png", }); - - expect(view.state.doc.toString()).toBe("alpha\n![](./assets/shot.png)\n"); + expect(view.state.doc.toString()).toBe("| A |\n| --- |\n| B |\n![](./assets/shot.png)\n"); + expect(edits).toBe(1); + expect(view.state.field(pendingPasteOperations)).toEqual([]); }); - - it("routes ok:false to a null path — nothing is inserted", async () => { - // The ternary `message.ok ? message.relativePath : null` is the whole arm. - // Inverted, ok:true would pass null (previous test goes red) and ok:false - // would pass `undefined` — which resolve() does NOT treat as a rejection - // (`relativePath === null`), so it would insert `![](undefined)` here. + it("routes ok:true to the matching pending operation", async () => { await mount(); - deliver(buildDocument({ docVersion: 1, content: "alpha\n" })); + deliver(buildDocument({ content: "alpha\n" })); const view = mountedView(); - seedAnchor(view, "req-a", 6); - + await requestAt(view, 0); + const requestId = await requestAt(view, 6); deliver({ protocol: PROTOCOL_VERSION, type: "image-write-result", - requestId: "req-a", - ok: false, + requestId, + ok: true, + relativePath: "./assets/shot.png", }); - + expect(view.state.doc.toString()).toBe("alpha\n![](./assets/shot.png)\n"); + expect(view.state.field(pendingPasteOperations)).toHaveLength(1); + }); + it("routes ok:false to a null path without inserting", async () => { + await mount(); + deliver(buildDocument({ content: "alpha\n" })); + const view = mountedView(); + const requestId = await requestAt(view, 6); + deliver({ protocol: PROTOCOL_VERSION, type: "image-write-result", requestId, ok: false }); expect(view.state.doc.toString()).toBe("alpha\n"); + expect(view.state.field(pendingPasteOperations)).toEqual([]); }); }); From 618b964cdbbcd67ca56c8a2f7c5dfcb5e6060d20 Mon Sep 17 00:00:00 2001 From: Mitsuki Fukunaga Date: Tue, 10 Nov 2026 09:00:19 +0900 Subject: [PATCH 6/9] test(paste): pin production clipboard arbitration in a real browser Serve a fresh production webview bundle to the browser suite and pin table/File, reply order, captions, undo, selection, CRLF, read-only and plain paste against it. --- CHANGELOG.md | 7 + .../helpers/production-paste-fixture.ts | 197 +++++++++++++++ .../paste-arbitration.browser.test.ts | 230 ++++++++++++++++++ vitest.browser.config.ts | 43 ++++ 4 files changed, 477 insertions(+) create mode 100644 test/webview-browser/helpers/production-paste-fixture.ts create mode 100644 test/webview-browser/paste-arbitration.browser.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a86a71ab..8c71eb93 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ All notable changes to Quoll are documented here. +## Unreleased + +### Fixed + +- Pasting text, tables, or rich content together with files now inserts the text and accepted image links together in one undoable step. Captions survive rejected files, and image links keep their clipboard order even when replies arrive out of order. +- Undoing while a paste is waiting for images preserves Redo and prevents late image replies from changing the document. Moving the caret while waiting keeps the paste at its original target without moving you back. + ## 0.1.86 — 2026-11-03 ### Added diff --git a/test/webview-browser/helpers/production-paste-fixture.ts b/test/webview-browser/helpers/production-paste-fixture.ts new file mode 100644 index 00000000..d224866d --- /dev/null +++ b/test/webview-browser/helpers/production-paste-fixture.ts @@ -0,0 +1,197 @@ +import type { Transaction, TransactionSpec } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +import { expect } from "vitest"; + +import { + type DocumentEol, + type DocumentMessage, + type EditMessage, + type HostToWebview, + type ImageWriteMessage, + PROTOCOL_VERSION, + type WebviewToHost, +} from "../../../src/shared/protocol.js"; + +/** Real production entry in its own realm; only the VS Code host is simulated. */ +export async function productionPasteFixture(content = "", eol: DocumentEol = "\n") { + const frame = document.createElement("iframe"); + frame.style.cssText = "width:800px;height:360px;border:0"; + frame.srcdoc = ` + + +
`; + const loaded = new Promise((resolve) => { + frame.onload = () => resolve(); + }); + document.body.append(frame); + await loaded; + const win = frame.contentWindow as Window & typeof globalThis; + const doc = win.document; + const messages: WebviewToHost[] = []; + const warnings: unknown[][] = []; + const warn = win.console.warn.bind(win.console); + win.console.warn = (...args: unknown[]) => { + warnings.push(args); + warn(...args); + }; + let snapshot: DocumentMessage = { + protocol: PROTOCOL_VERSION, + type: "document", + content, + docVersion: 1, + themeKind: "light", + canWrite: true, + eol, + externalEpoch: 0, + epochGeneration: 1, + settledEditId: 0, + }; + const send = (message: HostToWebview) => win.postMessage(message, "*"); + Object.defineProperty(win, "acquireVsCodeApi", { + value: () => ({ + getState: () => undefined, + setState: () => {}, + postMessage(message: WebviewToHost) { + messages.push(message); + if (message.type === "ready") { + send(snapshot); + } + if (message.type === "edit") { + snapshot = { + ...snapshot, + content: message.content, + docVersion: snapshot.docVersion + 1, + settledEditId: message.editId, + }; + send(snapshot); + } + }, + }), + }); + const script = doc.createElement("script"); + script.type = "module"; + script.src = "/__quoll-production/index.js"; + doc.body.append(script); + try { + await expect.poll(() => messages.some((message) => message.type === "ready")).toBe(true); + await expect.poll(() => doc.querySelector(".cm-content")).not.toBeNull(); + const editor = doc.querySelector(".cm-content"); + if (!editor) { + throw new Error("Production editor did not mount"); + } + const view = EditorView.findFromDOM(editor); + if (!view) { + throw new Error("Production EditorView not found"); + } + await expect.poll(() => view.state.doc.toString()).toBe(content.replace(/\r\n?/g, "\n")); + + // Observe dispatch without injecting foreign facets, effects or extensions. + let documentChanges = 0; + const dispatch = view.dispatch.bind(view); + view.dispatch = (...args: (Transaction | readonly Transaction[] | TransactionSpec)[]) => { + const before = view.state.doc; + Reflect.apply(dispatch, view, args); + if (!before.eq(view.state.doc)) { + documentChanges++; + } + }; + const edits = () => + messages.filter((message): message is EditMessage => message.type === "edit"); + const requests = () => + messages.filter((message): message is ImageWriteMessage => message.type === "image-write"); + return { + frame, + win, + view, + messages, + warnings, + edits, + requests, + changes: () => documentChanges, + async select(anchor: number, head = anchor) { + editor.focus(); + const a = view.domAtPos(anchor); + const b = view.domAtPos(head); + win.getSelection()?.setBaseAndExtent(a.node, a.offset, b.node, b.offset); + doc.dispatchEvent(new win.Event("selectionchange")); + await expect + .poll(() => [view.state.selection.main.anchor, view.state.selection.main.head]) + .toEqual([anchor, head]); + }, + paste(flavours: Record, files: readonly string[] = []) { + const transfer = new win.DataTransfer(); + for (const [type, value] of Object.entries(flavours)) { + transfer.setData(type, value); + } + for (const name of files) { + transfer.items.add(new win.File([name], name, { type: "image/png" })); + } + editor.dispatchEvent( + new win.ClipboardEvent("paste", { + clipboardData: transfer, + bubbles: true, + cancelable: true, + }) + ); + }, + reply(request: ImageWriteMessage, path: string | null) { + send( + path === null + ? { + protocol: PROTOCOL_VERSION, + type: "image-write-result", + requestId: request.requestId, + ok: false, + } + : { + protocol: PROTOCOL_VERSION, + type: "image-write-result", + requestId: request.requestId, + ok: true, + relativePath: path, + } + ); + }, + async type(text: string) { + const before = view.state.doc.length; + editor.focus(); + if (!doc.execCommand("insertText", false, text)) { + throw new Error("Browser text input failed"); + } + await expect.poll(() => view.state.doc.length).toBe(before + text.length); + }, + history(redo = false) { + editor.focus(); + const mac = /Mac/.test(win.navigator.platform); + editor.dispatchEvent( + new win.KeyboardEvent("keydown", { + key: "z", + code: "KeyZ", + metaKey: mac, + ctrlKey: !mac, + shiftKey: redo, + bubbles: true, + cancelable: true, + }) + ); + }, + async readOnly() { + snapshot = { ...snapshot, canWrite: false, docVersion: snapshot.docVersion + 1 }; + send(snapshot); + await expect.poll(() => view.state.readOnly).toBe(true); + }, + async posted(expected: string, count = 1) { + await expect.poll(() => edits().length).toBe(count); + expect(edits().at(-1)?.content).toBe(expected); + await expect.poll(() => snapshot.settledEditId).toBe(edits().at(-1)?.editId); + // Allow duplicate or delayed edits to surface past the production debounce. + await new Promise((resolve) => setTimeout(resolve, 450)); + expect(edits()).toHaveLength(count); + }, + dispose: () => frame.remove(), + }; + } catch (error) { + frame.remove(); + throw error; + } +} diff --git a/test/webview-browser/paste-arbitration.browser.test.ts b/test/webview-browser/paste-arbitration.browser.test.ts new file mode 100644 index 00000000..84f79edc --- /dev/null +++ b/test/webview-browser/paste-arbitration.browser.test.ts @@ -0,0 +1,230 @@ +import { expect, it } from "vitest"; + +import { productionPasteFixture } from "./helpers/production-paste-fixture.js"; + +it("fresh production assets are served without dist", async () => { + for (const [extension, mime] of [ + ["js", "text/javascript"], + ["css", "text/css"], + ]) { + const response = await fetch(`/__quoll-production/index.${extension}`); + expect(response.ok).toBe(true); + expect(response.headers.get("Content-Type")).toContain(mime); + expect((await response.text()).length).toBeGreaterThan(1000); + } +}); + +it("production bundle posts seeded edit", async () => { + const fixture = await productionPasteFixture("seed "); + try { + await fixture.select(5); + fixture.paste({ "text/plain": "hello" }); + await fixture.posted("seed hello"); + expect(fixture.view.state.doc.toString()).toBe("seed hello"); + expect(fixture.changes()).toBe(1); + expect(fixture.messages.filter((message) => message.type === "ready")).toHaveLength(1); + } finally { + fixture.dispose(); + } +}); + +it("production table and File insert exactly once", async () => { + const fixture = await productionPasteFixture(); + try { + await fixture.select(0); + fixture.paste( + { + "text/plain": "wrong plain representation", + "text/html": "
A
B
", + }, + ["table.png"] + ); + await expect.poll(() => fixture.requests().length).toBe(1); + expect(fixture.changes()).toBe(0); + expect(fixture.edits()).toHaveLength(0); + expect(fixture.view.state.doc.toString()).toBe(""); + fixture.reply(fixture.requests()[0], "./assets/table.png"); + const expected = "| A |\n| --- |\n| B |\n![](./assets/table.png)\n"; + await fixture.posted(expected); + expect(fixture.view.state.doc.toString()).toBe(expected); + expect(fixture.changes()).toBe(1); + } finally { + fixture.dispose(); + } +}); + +it("production reverse replies preserve clipboard image order", async () => { + const fixture = await productionPasteFixture(); + try { + await fixture.select(0); + fixture.paste({}, ["first.png", "second.png"]); + await expect.poll(() => fixture.requests().length).toBe(2); + // Identify slots by their captured bytes, independent of FileReader completion order. + const first = fixture + .requests() + .find((request) => request.data === fixture.win.btoa("first.png")); + const second = fixture + .requests() + .find((request) => request.data === fixture.win.btoa("second.png")); + expect(first).toBeDefined(); + expect(second).toBeDefined(); + if (!first || !second) { + throw new Error("Missing captured file request"); + } + fixture.reply(second, "./assets/second.png"); + await new Promise((resolve) => setTimeout(resolve, 450)); + expect(fixture.changes()).toBe(0); + expect(fixture.edits()).toHaveLength(0); + fixture.reply(first, "./assets/first.png"); + const expected = "![](./assets/first.png)\n![](./assets/second.png)\n"; + await fixture.posted(expected); + expect(fixture.view.state.doc.toString()).toBe(expected); + expect(fixture.changes()).toBe(1); + } finally { + fixture.dispose(); + } +}); + +it("production caption survives File rejection", async () => { + const fixture = await productionPasteFixture(); + try { + await fixture.select(0); + fixture.paste({ "text/html": "

HTML caption

" }, ["rejected.png"]); + await expect.poll(() => fixture.requests().length).toBe(1); + expect(fixture.changes()).toBe(0); + fixture.reply(fixture.requests()[0], null); + await fixture.posted("HTML caption\n"); + expect(fixture.view.state.doc.toString()).toBe("HTML caption\n"); + expect(fixture.changes()).toBe(1); + expect(fixture.frame.contentDocument?.querySelector('[role="status"]')?.textContent).toContain( + "Paste could not be completed in full." + ); + } finally { + fixture.dispose(); + } +}); + +it("production pending undo preserves redo", async () => { + const fixture = await productionPasteFixture("base "); + try { + await fixture.select(5); + await fixture.type("typed"); + await fixture.posted("base typed"); + // Keep the target outside the typing that Undo removes, so history cancellation is required. + await fixture.select(0); + fixture.paste({}, ["late.png"]); + await expect.poll(() => fixture.requests().length).toBe(1); + fixture.history(); + await fixture.posted("base ", 2); + expect(fixture.view.state.doc.toString()).toBe("base "); + fixture.reply(fixture.requests()[0], "./assets/late.png"); + await new Promise((resolve) => setTimeout(resolve, 450)); + expect(fixture.view.state.doc.toString()).toBe("base "); + expect(fixture.edits()).toHaveLength(2); + fixture.history(true); + await fixture.posted("base typed", 3); + expect(fixture.view.state.doc.toString()).toBe("base typed"); + expect(fixture.changes()).toBe(3); + expect(fixture.requests()).toHaveLength(1); + } finally { + fixture.dispose(); + } +}); + +it("production settlement preserves moved selection", async () => { + const content = `top\n\n${Array.from({ length: 120 }, (_, index) => `line ${index}`).join("\n")}`; + const fixture = await productionPasteFixture(content); + try { + await fixture.select(0); + fixture.paste({ "text/plain": "caption" }, ["moved.png"]); + await expect.poll(() => fixture.requests().length).toBe(1); + const mac = /Mac/.test(fixture.win.navigator.platform); + fixture.view.contentDOM.dispatchEvent( + new fixture.win.KeyboardEvent("keydown", { + key: "End", + code: "End", + metaKey: mac, + ctrlKey: !mac, + bubbles: true, + cancelable: true, + }) + ); + await expect.poll(() => fixture.view.state.selection.main.head).toBe(content.length); + await expect.poll(() => fixture.view.scrollDOM.scrollTop).toBeGreaterThan(100); + expect(fixture.changes()).toBe(0); + fixture.reply(fixture.requests()[0], "./assets/moved.png"); + const inserted = "caption\n![](./assets/moved.png)\n"; + await fixture.posted(inserted + content); + expect(fixture.view.state.doc.toString()).toBe(inserted + content); + expect(fixture.view.state.selection.main.anchor).toBe(inserted.length + content.length); + expect(fixture.view.state.selection.main.head).toBe(inserted.length + content.length); + expect(fixture.view.scrollDOM.scrollTop).toBeGreaterThan(100); + expect(fixture.changes()).toBe(1); + } finally { + fixture.dispose(); + } +}); + +it("production CRLF paste agrees with posted edit", async () => { + const fixture = await productionPasteFixture("head\r\ntail", "\r\n"); + try { + await fixture.select(0); + fixture.paste({ "text/plain": "one\r\ntwo" }, ["crlf.png"]); + await expect.poll(() => fixture.requests().length).toBe(1); + expect(fixture.changes()).toBe(0); + fixture.reply(fixture.requests()[0], "./assets/crlf.png"); + const inserted = "one\ntwo\n![](./assets/crlf.png)\n"; + const expected = `${inserted}head\ntail`; + await fixture.posted(expected.replace(/\n/g, "\r\n")); + expect(fixture.view.state.doc.toString()).toBe(expected); + expect(fixture.view.state.selection.main.anchor).toBe(inserted.length); + expect(fixture.view.state.selection.main.head).toBe(inserted.length); + expect(fixture.changes()).toBe(1); + } finally { + fixture.dispose(); + } +}); + +it("production readOnly settlement leaves document unchanged", async () => { + const fixture = await productionPasteFixture("keep"); + try { + await fixture.select(0, 4); + fixture.paste({ "text/plain": "replacement" }, ["readonly.png"]); + await expect.poll(() => fixture.requests().length).toBe(1); + await fixture.readOnly(); + fixture.reply(fixture.requests()[0], "./assets/readonly.png"); + await expect + .poll(() => + fixture.warnings.some( + (args) => + String(args[0]).includes("written but not linked") && + JSON.stringify(args).includes("./assets/readonly.png") + ) + ) + .toBe(true); + await new Promise((resolve) => setTimeout(resolve, 450)); + expect(fixture.view.state.doc.toString()).toBe("keep"); + expect(fixture.view.state.selection.main.anchor).toBe(0); + expect(fixture.view.state.selection.main.head).toBe(4); + expect(fixture.changes()).toBe(0); + expect(fixture.edits()).toHaveLength(0); + } finally { + fixture.dispose(); + } +}); + +it("production ordinary plain clipboard inserts once", async () => { + const fixture = await productionPasteFixture("before old after"); + try { + await fixture.select(7, 10); + fixture.paste({ "text/plain": "ordinary\nplain" }); + const expected = "before ordinary\nplain after"; + await fixture.posted(expected); + expect(fixture.view.state.doc.toString()).toBe(expected); + expect(fixture.view.state.selection.main.head).toBe(7 + "ordinary\nplain".length); + expect(fixture.changes()).toBe(1); + expect(fixture.requests()).toHaveLength(0); + } finally { + fixture.dispose(); + } +}); diff --git a/vitest.browser.config.ts b/vitest.browser.config.ts index bb9596f7..57efda31 100644 --- a/vitest.browser.config.ts +++ b/vitest.browser.config.ts @@ -5,10 +5,53 @@ // contract can only be checked in a real browser. The electron E2E suite is // host-side only. Kept SEPARATE from vitest.config.ts so the fast node/happy-dom // unit suite is unaffected. + import { playwright } from "@vitest/browser-playwright"; +import esbuild from "esbuild"; import { defineConfig } from "vitest/config"; +// @ts-expect-error — the production build factory is an untyped JS module. +import { createBuildConfigs } from "./esbuild.config.mjs"; + +// Vitest loads this config for both its runner and browser servers. +// Share the in-memory build across those separate config module instances. +const buildCache = globalThis as typeof globalThis & { + __quollProductionPasteBuild?: ReturnType; +}; export default defineConfig({ + plugins: [ + { + name: "quoll-production-paste-assets", + async configureServer(server) { + const { webviewConfig } = createBuildConfigs({ production: true }); + buildCache.__quollProductionPasteBuild ??= esbuild.build({ + ...webviewConfig, + write: false, + }); + const { outputFiles } = await buildCache.__quollProductionPasteBuild; + if (!outputFiles) { + throw new Error("Production build emitted no assets"); + } + const assets = new Map( + outputFiles.map((file) => [ + `/__quoll-production/${file.path.endsWith(".css") ? "index.css" : "index.js"}`, + file, + ]) + ); + server.middlewares.use((req, res, next) => { + const asset = assets.get(req.url ?? ""); + if (!asset) { + return next(); + } + res.setHeader( + "Content-Type", + asset.path.endsWith(".css") ? "text/css" : "text/javascript" + ); + res.end(asset.contents); + }); + }, + }, + ], define: { QUOLL_PERF: "false" }, test: { include: ["test/webview-browser/**/*.browser.test.ts"], From dcaa97f29fae974c870e665733dbb5eb13cc3b28 Mon Sep 17 00:00:00 2001 From: Mitsuki Fukunaga Date: Tue, 10 Nov 2026 09:06:12 +0900 Subject: [PATCH 7/9] refactor(paste): drop a redundant range guard and spell out separator branches --- src/webview/cm/paste/paste-operation.ts | 27 ++++++++++++------------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/src/webview/cm/paste/paste-operation.ts b/src/webview/cm/paste/paste-operation.ts index 195f09df..e4464937 100644 --- a/src/webview/cm/paste/paste-operation.ts +++ b/src/webview/cm/paste/paste-operation.ts @@ -55,13 +55,11 @@ export const pendingPasteOperations = StateField.define { - if (from < target.to && to > target.from) { - overwritten = true; - } - }); - } + tr.changes.iterChanges((from, to) => { + if (from < target.to && to > target.from) { + overwritten = true; + } + }); return !overwritten; }) .map((target) => { @@ -332,13 +330,14 @@ export function composePasteReplacement( const paths = slots.filter((path): path is string => typeof path === "string"); if (paths.length) { const atLineStart = target.from === state.doc.lineAt(target.from).from; - const prefix = text.length - ? text.sliceString(text.length - 1) === "\n" - ? "" - : "\n" - : atLineStart - ? "" - : "\n"; + let prefix = ""; + if (text.length) { + if (text.sliceString(text.length - 1) !== "\n") { + prefix = "\n"; + } + } else if (!atLineStart) { + prefix = "\n"; + } text = text.append(state.toText(prefix + paths.map((path) => `![](${path})\n`).join(""))); } return text; From dd6e83e0a85c98d761a6a32ca3ea43cb4a3fc786 Mon Sep 17 00:00:00 2001 From: Mitsuki Fukunaga Date: Tue, 10 Nov 2026 09:20:14 +0900 Subject: [PATCH 8/9] docs(paste): drop references to deleted paste handlers from comments and test titles --- src/webview/cm/paste/html-to-markdown.ts | 32 ++++++------------- test/webview/cm-crlf-line-model.test.ts | 8 ++--- test/webview/helpers/clipboard-double.ts | 32 +++++++------------ test/webview/helpers/image-paste-doubles.ts | 6 ++-- .../webview/paste/cm-html-table-paste.test.ts | 2 +- .../paste/cm-list-reindent-paste.test.ts | 2 +- test/webview/paste/cm-paste-url-link.test.ts | 4 +-- 7 files changed, 33 insertions(+), 53 deletions(-) diff --git a/src/webview/cm/paste/html-to-markdown.ts b/src/webview/cm/paste/html-to-markdown.ts index 84776708..f1723b82 100644 --- a/src/webview/cm/paste/html-to-markdown.ts +++ b/src/webview/cm/paste/html-to-markdown.ts @@ -4,22 +4,14 @@ // (happy-dom provides it under test), so this stays inside Quoll's supply-chain // default-deny. // -// TWO output channels, both of which INVITE the caller to prefer the clipboard's -// own bytes. Neither commands it — what the caller does depends on what else the -// clipboard carries, so do not assume either output string is unreachable: -// - `null` — nothing convertible (empty walk, cap breached, parse error). The -// caller normally defers, but when NOTHING can absorb the defer — no plain/uri -// fallback, no image file item for imagePaste — over a non-empty selection it -// SWALLOWS the event instead (deferring would let CM's `doPaste("")` delete the -// selection) — no paste of any kind happens. +// TWO output channels, interpreted by the clipboard coordinator: +// - `null` — nothing convertible. `decideRichPaste` returns null, and the +// clipboard's own text (if any) is pasted by `decideClipboardPaste`. // - `emittedMarkdownSyntax === false` — the walk produced escaped text and line -// structure only. The conversion is valid Markdown, but the caller prefers the -// clipboard's own `text/plain` bytes over this module's escaped rendering. -// With no plain flavour — or with an image file riding along, where imagePaste -// keeps the plain flavour from ever landing — it inserts this module's output -// after all, so the escaped rendering is a live path, not dead code. -// This is the dominant path for clipboards that carry a merely presentational -// HTML flavour. See the `HtmlToMarkdownResult` docblock at the bottom. +// structure only. When `text/plain` or `text/uri-list` exists, +// `decideClipboardPaste` inserts those clipboard bytes. This module's escaped +// rendering is used only when neither flavour exists (an HTML-only clipboard). +// See the `HtmlToMarkdownResult` docblock at the bottom. // // Design notes (why each choice, so a future edit doesn't regress it): // - Structure is read via an EXPLICIT direct-child walk (Array.from(childNodes)), @@ -38,13 +30,9 @@ // construct. Pasted text therefore never activates a construct that the same // text typed by hand would not. NOTE this escaping is precisely what makes the // conversion unusable for a syntax-free fragment — Markdown the user typed by -// hand would come back as `\- \[ \]` — which is why the caller defers on -// `emittedMarkdownSyntax === false`. On an ordinary clipboard, one carrying a -// safe plain fallback, that defer inserts the clipboard's own bytes verbatim, -// exactly as typing them would. It is NOT unconditional: the caller inserts this -// escaped rendering after all whenever the plain flavour cannot land — there is -// none (an HTML-only clipboard), or an image file item rides along and -// imagePaste consumes the event before CM's plain paste can run. +// hand would come back as `\- \[ \]`. When `emittedMarkdownSyntax === false`, +// the coordinator uses the clipboard's plain or URI-list text when available; +// this escaped rendering is used for an HTML-only clipboard. // - Whether a container is visually EMPTY is decided by ONE rule — // `blankAfterInvisible` (full-strip of the `\p{DI} ∪ \p{Cf}` format+ignorable class + // whitespace), measured over diff --git a/test/webview/cm-crlf-line-model.test.ts b/test/webview/cm-crlf-line-model.test.ts index bc30ace2..9d998fe0 100644 --- a/test/webview/cm-crlf-line-model.test.ts +++ b/test/webview/cm-crlf-line-model.test.ts @@ -200,7 +200,7 @@ describe("Quoll's own multi-line insert paths, in a CRLF document", () => { expectCleanLineModel(view.state.doc); }); - it("htmlTablePaste keeps a clean line model", () => { + it("clipboard paste — table keeps a clean line model", () => { const { handle, view } = mount(); handle.applyDocument({ content: "intro\r\ntext", @@ -227,7 +227,7 @@ describe("Quoll's own multi-line insert paths, in a CRLF document", () => { expectCleanLineModel(view.state.doc); }); - it("richHtmlPaste keeps a clean line model", () => { + it("clipboard paste — rich HTML keeps a clean line model", () => { const { handle, view } = mount(); handle.applyDocument({ content: "intro\r\ntext", @@ -242,7 +242,7 @@ describe("Quoll's own multi-line insert paths, in a CRLF document", () => { expectCleanLineModel(view.state.doc); }); - it("listReindentPaste keeps a clean line model", () => { + it("clipboard paste — list re-indent keeps a clean line model", () => { const { handle, view } = mount(); // Fixture mirrors cm-list-reindent-paste.test.ts's LOOSE fixture: the caret // sits at the end of the blank interior line " " of a loose nested item, @@ -264,7 +264,7 @@ describe("Quoll's own multi-line insert paths, in a CRLF document", () => { expectCleanLineModel(view.state.doc); }); - it("imagePaste (resolveImageWrite) keeps a clean line model", async () => { + it("image paste via the clipboard coordinator (resolveImageWrite) keeps a clean line model", async () => { const { handle, view } = mount(); handle.applyDocument({ content: "ab\r\ncd", diff --git a/test/webview/helpers/clipboard-double.ts b/test/webview/helpers/clipboard-double.ts index db3afa9e..067771a8 100644 --- a/test/webview/helpers/clipboard-double.ts +++ b/test/webview/helpers/clipboard-double.ts @@ -1,11 +1,9 @@ // A `DataTransfer` stand-in for suites that drive real paste events. // -// Shared rather than re-declared per suite because two handlers now read the SAME -// membership predicate — `isIngestibleImageItem` (cm/image/image-paste.ts) decides -// both what imagePaste ingests and what richHtmlPaste may defer to it. A double -// that drifted between suites would let one of them pass on a clipboard shape the -// other could never construct, which is the test-side version of exactly the bug -// sharing that predicate removes. +// Shared by suites that exercise the clipboard coordinator. `filesFromTransfer` +// (cm/image/image-paste.ts) acquires Files via `getAsFile()`, and the coordinator +// consumes them. Keeping one DataTransfer shape lets those suites construct the +// same clipboard inputs. // // Shaped like the real thing in one way that matters: `items` carries a // `kind: "string"` entry for EVERY text flavour present, as a browser's @@ -14,9 +12,8 @@ /** One FILE entry. Omit `file` for a real File; state it explicitly as `null` OR * `undefined` to model the item that matches on kind+type yet yields nothing. - * imagePaste's `if (file)` declines both spellings, so anything mirroring its - * acceptance has to decline both — and a handler that gets this wrong defers into - * a decline and lets CM's `doPaste("")` delete the selection. */ + * `filesFromTransfer` (cm/image/image-paste.ts) includes only items whose + * `getAsFile()` returns a File. */ export type FileItemSpec = { type: string; file?: File | null | undefined }; export type ClipboardFlavours = { @@ -26,18 +23,14 @@ export type ClipboardFlavours = { files?: FileItemSpec[]; }; -/** A file item carrying a real File — the shape imagePaste ingests. */ +/** A file item carrying a real File. */ export const IMAGE_FILE: FileItemSpec[] = [{ type: "image/png" }]; /** Build the `clipboardData` / `dataTransfer` object for a synthetic paste or drop. * * ⚠️ `getAsFile()` returns null for every `kind: "string"` item, per the DOM spec. - * That is also why `isIngestibleImageItem`'s `kind === "file"` test cannot be made - * independently non-vacuous from here: a SPEC-CONFORMANT non-file item already - * fails its `getAsFile()` condition, so dropping the `kind` test changes no - * observable behaviour. Making it observable would need a double that violates the - * spec, which would pin a clipboard the browser never produces. The `kind` test is - * a short-circuit matching the DOM contract, not a load-bearing condition. */ + * This double models that contract; string flavours are represented in `items` + * alongside file items, as they are in a browser DataTransfer. */ export function makeClipboardData(data: ClipboardFlavours): { getData: (type: string) => string; items: DataTransferItem[]; @@ -68,9 +61,8 @@ export function makeClipboardData(data: ClipboardFlavours): { // `"file" in spec`, not `spec.file === undefined`: the latter cannot tell an // omitted key from an explicit `file: undefined`, which is one of the two // no-File shapes this helper exists to be able to construct. - // Non-empty content deliberately: imagePaste skips a zero-byte file (`file.size - // === 0`), so a `new File([""], …)` default would make every ingestion test - // pass the membership predicate and then silently do nothing. + // Non-empty content is the default so the File carries bytes for the + // FileReader path. Empty Files are forwarded to the host too. getAsFile: () => file, }; }); @@ -90,7 +82,7 @@ export function makeClipboardData(data: ClipboardFlavours): { items: [...stringItems, ...fileItems] as unknown as DataTransferItem[], files, // A real DataTransfer reports "Files" alongside the text flavours whenever it - // carries any file entry — the list imagePaste's dragover guard reads. + // carries any file entry — the list the image drop extension's dragover guard reads. types: [...store.keys(), ...(files.length > 0 ? ["Files"] : [])], }; } diff --git a/test/webview/helpers/image-paste-doubles.ts b/test/webview/helpers/image-paste-doubles.ts index ca7fef7e..6a668754 100644 --- a/test/webview/helpers/image-paste-doubles.ts +++ b/test/webview/helpers/image-paste-doubles.ts @@ -1,6 +1,6 @@ -// Doubles shared by the suites that drive imagePaste through real paste events: -// its own (image/cm-image-paste.test.ts) and the richHtmlPaste + imagePaste pair -// (paste/cm-rich-html-image-paste.test.ts). +// Doubles shared by the suites that drive the clipboard coordinator and image +// transport through real paste events: image/cm-image-paste.test.ts and +// paste/cm-rich-html-image-paste.test.ts. import type { ImageWriteMessage, WebviewToHost } from "../../../src/shared/protocol.js"; diff --git a/test/webview/paste/cm-html-table-paste.test.ts b/test/webview/paste/cm-html-table-paste.test.ts index 5a4a466d..9742381e 100644 --- a/test/webview/paste/cm-html-table-paste.test.ts +++ b/test/webview/paste/cm-html-table-paste.test.ts @@ -388,7 +388,7 @@ function firePaste(view: EditorView, data: { html?: string; text?: string }): Ev return event; } -describe("htmlTablePaste — handler", () => { +describe("clipboard paste — table", () => { it("inserts a GFM table for an HTML-table paste and consumes the event", () => { const view = mount(""); const event = firePaste(view, { diff --git a/test/webview/paste/cm-list-reindent-paste.test.ts b/test/webview/paste/cm-list-reindent-paste.test.ts index 80f07f3b..4987f626 100644 --- a/test/webview/paste/cm-list-reindent-paste.test.ts +++ b/test/webview/paste/cm-list-reindent-paste.test.ts @@ -136,7 +136,7 @@ function firePaste(view: EditorView, text: string): Event { // column 4, which only our transform produces) must be ABSENT. On ACCEPT we // preventDefault (CM core does not run), so exact-content assertions are safe. -describe("listReindentPaste — handler", () => { +describe("clipboard paste — list re-indent", () => { // Fixture note: the caret sits at the end of the blank interior line " " of a // LOOSE item "b" (which has a following continuation " more"), col 2. A blank // interior line resolves via `listItemAt` (a bare TRAILING blank line does not diff --git a/test/webview/paste/cm-paste-url-link.test.ts b/test/webview/paste/cm-paste-url-link.test.ts index fcf5b142..c74fdd31 100644 --- a/test/webview/paste/cm-paste-url-link.test.ts +++ b/test/webview/paste/cm-paste-url-link.test.ts @@ -101,7 +101,7 @@ function firePaste(view: EditorView, text: string): Event { return event; } -describe("pasteUrlOverSelection — handler", () => { +describe("clipboard paste — URL over selection", () => { it("wraps a single-line selection as a link and consumes the event", () => { const view = mount("select me", 0, "select".length); // select "select" const event = firePaste(view, "https://example.com"); @@ -185,7 +185,7 @@ describe("pasteUrlOverSelection — handler", () => { // Syntax-context guard (ported from the built-in pasteURLAsLink): a URL is NEVER // wrapped into a non-plain-text construct. Wrapping `](url)` into inline/fenced // code, an existing link, etc. would corrupt it, so the handler defers. -describe("pasteUrlOverSelection — syntax-context guard", () => { +describe("clipboard paste — URL syntax-context guard", () => { it("defers when the selection is inside an inline code span", () => { const view = mount("`code`", 1, 5); // select "code" between the backticks firePaste(view, "https://example.com"); From 5007e161241842e7c44f630230488c97c18f30eb Mon Sep 17 00:00:00 2001 From: Mitsuki Fukunaga Date: Tue, 10 Nov 2026 09:21:29 +0900 Subject: [PATCH 9/9] fix(paste): log swallowed errors and keep failure reporting independent of metadata cleanup Bind and log the causes of the bare catches in the coordinator and the image transport, run clearMetadata through a non-throwing helper so onUnlinkedPath/onFailure always fire, ignore a reader load after cancel, and pin the lost-operation arm and cancel closure. --- src/webview/cm/image/image-paste.ts | 6 +- src/webview/cm/paste/paste-operation.ts | 25 ++-- test/webview/image/cm-image-paste.test.ts | 37 ++++++ test/webview/paste/cm-paste-operation.test.ts | 117 +++++++++++++++++- 4 files changed, 175 insertions(+), 10 deletions(-) diff --git a/src/webview/cm/image/image-paste.ts b/src/webview/cm/image/image-paste.ts index 16f86f0b..76516e2d 100644 --- a/src/webview/cm/image/image-paste.ts +++ b/src/webview/cm/image/image-paste.ts @@ -78,6 +78,9 @@ export function createImageFileTransport(opts: { post: (message: WebviewToHost) }; const reader = new FileReader(); reader.onload = () => { + if (!pending.has(requestId)) { + return; + } const result = reader.result; if (typeof result !== "string") { // Defensive browser-contract failure; log metadata without clipboard bytes. @@ -114,7 +117,8 @@ export function createImageFileTransport(opts: { post: (message: WebviewToHost) reader.onabort = failed; try { reader.readAsDataURL(file); - } catch { + } catch (err) { + console.error("[quoll] failed to start reading pasted image", { err, requestId }); failed(); } return () => { diff --git a/src/webview/cm/paste/paste-operation.ts b/src/webview/cm/paste/paste-operation.ts index e4464937..49080675 100644 --- a/src/webview/cm/paste/paste-operation.ts +++ b/src/webview/cm/paste/paste-operation.ts @@ -121,6 +121,13 @@ export function createPasteOperationCoordinator(options: { } view.dispatch(tr); } + function clearMetadataSafely(view: EditorView, id: string) { + try { + clearMetadata(view, id); + } catch (err) { + console.error("[quoll] failed to clear paste metadata", { err, requestId: id }); + } + } function release(id: string) { const operation = operations.get(id); operations.delete(id); @@ -133,7 +140,7 @@ export function createPasteOperationCoordinator(options: { for (const [id, operation] of operations) { release(id); if (clearState) { - clearMetadata(operation.view, id); + clearMetadataSafely(operation.view, id); } for (const path of operation.slots) { if (typeof path === "string") { @@ -162,7 +169,7 @@ export function createPasteOperationCoordinator(options: { } function fail(view: EditorView, id: string) { const operation = release(id); - clearMetadata(view, id); + clearMetadataSafely(view, id); for (const path of operation?.slots ?? []) { if (typeof path === "string") { options.onUnlinkedPath?.(path); @@ -217,8 +224,9 @@ export function createPasteOperationCoordinator(options: { selectionMoved: false, }), }); - } catch { - clearMetadata(view, id); + } catch (err) { + console.error("[quoll] paste capture failed", { err, requestId: id }); + clearMetadataSafely(view, id); options.onFailure(); return null; } @@ -273,7 +281,7 @@ export function createPasteOperationCoordinator(options: { } } release(id); - clearMetadata(view, id); + clearMetadataSafely(view, id); return; } try { @@ -298,15 +306,16 @@ export function createPasteOperationCoordinator(options: { } view.dispatch(tr); release(id); - clearMetadata(view, id); + clearMetadataSafely(view, id); if (slots.some((path) => path === null)) { options.onFailure(); } - } catch { + } catch (err) { + console.error("[quoll] paste completion failed", { err, requestId: id }); if (view.state !== state) { // Adoption is terminal even when dispatch did not finish successfully. release(id); - clearMetadata(view, id); + clearMetadataSafely(view, id); options.onFailure(); return; } diff --git a/test/webview/image/cm-image-paste.test.ts b/test/webview/image/cm-image-paste.test.ts index 8dcc4f6c..7dc38865 100644 --- a/test/webview/image/cm-image-paste.test.ts +++ b/test/webview/image/cm-image-paste.test.ts @@ -109,6 +109,43 @@ it("transport settles callbacks without inserting", async () => { expect(settled).toHaveBeenCalledWith(0, "assets/a.png"); expect(view.state.doc.toString()).toBe("hello"); }); +it("cancel prevents a queued reader callback and rejects the host reply", () => { + let reader: StubFileReader | undefined; + stubFileReader((started) => { + reader = started; + }); + const post = vi.fn<(message: WebviewToHost) => void>(); + const settled = vi.fn(); + const transport = createImageFileTransport({ post }); + const cancel = transport.start([pngFile()], settled); + expect(reader).toBeDefined(); + reader!.result = "data:image/png;base64,eA=="; + const onload = reader!.onload!; + onload(); + const requestId = imageWrites(post)[0].requestId; + cancel(); + onload(); + expect(imageWrites(post)).toHaveLength(1); + expect(settled).not.toHaveBeenCalled(); + expect(transport.resolve(requestId, "assets/a.png")).toBe(false); +}); + +it("readAsDataURL throw logs the cause and settles the slot", () => { + const cause = new Error("reader failed to start"); + stubFileReader(() => { + throw cause; + }); + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + const post = vi.fn<(message: WebviewToHost) => void>(); + const settled = vi.fn(); + createImageFileTransport({ post }).start([pngFile()], settled); + expect(error).toHaveBeenCalledWith("[quoll] failed to start reading pasted image", { + err: cause, + requestId: expect.any(String), + }); + expect(settled).toHaveBeenCalledExactlyOnceWith(0, null); + expect(imageWrites(post)).toEqual([]); +}); it("reverse replies preserve clipboard order in one replacement", async () => { const { view, paste, post } = mount("old"); view.dispatch({ selection: { anchor: 0, head: 3 } }); diff --git a/test/webview/paste/cm-paste-operation.test.ts b/test/webview/paste/cm-paste-operation.test.ts index 11dff164..b6aa0b4d 100644 --- a/test/webview/paste/cm-paste-operation.test.ts +++ b/test/webview/paste/cm-paste-operation.test.ts @@ -384,14 +384,52 @@ it("registration dispatch throw starts no I/O", () => { const coordinator = createPasteOperationCoordinator({ onFailure: failure, startResources }); const view = new EditorView({ extensions: [coordinator.extension] }); try { + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + const cause = new Error("dispatch failed"); vi.spyOn(view, "dispatch").mockImplementation(() => { - throw new Error("dispatch failed"); + throw cause; }); expect(coordinator.capture(view, { from: 0, to: 0, render: () => "x" })).toBeNull(); expect(view.state.doc.toString()).toBe(""); expect(view.state.field(pendingPasteOperations)).toEqual([]); expect(startResources).not.toHaveBeenCalled(); expect(failure).toHaveBeenCalledOnce(); + expect(error).toHaveBeenCalledWith("[quoll] paste capture failed", { + err: cause, + requestId: "paste-1", + }); + } finally { + view.destroy(); + vi.restoreAllMocks(); + } +}); + +it.each([ + false, + true, +])("lost operation releases paths and defers failure (dispose: %s)", async (dispose) => { + const onFailure = vi.fn(); + const onUnlinkedPath = vi.fn(); + const release = vi.fn(); + const coordinator = createPasteOperationCoordinator({ + onFailure, + onUnlinkedPath, + startResources: () => release, + }); + const view = new EditorView({ doc: "0123456789", extensions: [coordinator.extension] }); + try { + const id = coordinator.capture(view, { from: 5, to: 9, render: () => "x" }, 2)!; + coordinator.settle(id, 0, "./a.png"); + view.dispatch({ changes: { from: 6, to: 7, insert: "z" } }); + expect(release).toHaveBeenCalledOnce(); + expect(onUnlinkedPath).toHaveBeenCalledExactlyOnceWith("./a.png"); + expect(onFailure).not.toHaveBeenCalled(); + expect(coordinator.has(id)).toBe(false); + if (dispose) { + coordinator.dispose(); + } + await Promise.resolve(); + expect(onFailure).toHaveBeenCalledTimes(dispose ? 0 : 1); } finally { view.destroy(); } @@ -812,6 +850,83 @@ it("throwing live render terminates once and reports unlinked path", () => { } }); +it("completion logs its cause and reports paths when metadata cleanup throws", () => { + const onFailure = vi.fn(); + const onUnlinkedPath = vi.fn(); + const cause = new Error("render failed"); + const cleanupCause = new Error("cleanup failed"); + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + const coordinator = createPasteOperationCoordinator({ onFailure, onUnlinkedPath }); + const view = new EditorView({ doc: "target", extensions: [coordinator.extension] }); + try { + const id = coordinator.capture( + view, + { + from: 0, + to: 6, + render: () => { + throw cause; + }, + }, + 1 + )!; + coordinator.settle(id, 0, "./a.png"); + vi.spyOn(view, "dispatch").mockImplementation(() => { + throw cleanupCause; + }); + expect(() => coordinator.complete(id)).not.toThrow(); + expect(error).toHaveBeenCalledWith("[quoll] paste completion failed", { + err: cause, + requestId: id, + }); + expect(error).toHaveBeenCalledWith("[quoll] failed to clear paste metadata", { + err: cleanupCause, + requestId: id, + }); + expect(onUnlinkedPath).toHaveBeenCalledExactlyOnceWith("./a.png"); + expect(onFailure).toHaveBeenCalledOnce(); + } finally { + view.destroy(); + vi.restoreAllMocks(); + } +}); + +it("dispose reports every unlinked path when one metadata cleanup throws", () => { + const onUnlinkedPath = vi.fn(); + const release = vi.fn(); + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + const coordinator = createPasteOperationCoordinator({ + onFailure: vi.fn(), + onUnlinkedPath, + startResources: () => release, + }); + const view = new EditorView({ doc: "0123456789", extensions: [coordinator.extension] }); + try { + const a = coordinator.capture(view, { from: 1, to: 2, render: () => "a" }, 1)!; + const b = coordinator.capture(view, { from: 8, to: 9, render: () => "b" }, 1)!; + coordinator.settle(a, 0, "./a.png"); + coordinator.settle(b, 0, "./b.png"); + const cleanupCause = new Error("cleanup failed"); + const dispatch = view.dispatch.bind(view); + vi.spyOn(view, "dispatch") + .mockImplementationOnce(() => { + throw cleanupCause; + }) + .mockImplementation((...args) => dispatch(...args)); + expect(() => coordinator.dispose()).not.toThrow(); + expect(error).toHaveBeenCalledWith("[quoll] failed to clear paste metadata", { + err: cleanupCause, + requestId: a, + }); + expect(release).toHaveBeenCalledTimes(2); + expect(onUnlinkedPath.mock.calls).toEqual([["./a.png"], ["./b.png"]]); + expect(view.state.field(pendingPasteOperations).map((target) => target.id)).toEqual([a]); + } finally { + view.destroy(); + vi.restoreAllMocks(); + } +}); + it("postcommit failure never reinserts", () => { vi.useFakeTimers(); const onFailure = vi.fn();