fix(editor): paste one line per caret when the counts match (#1830)

* fix(editor): paste one line per caret when the counts match

Multi-caret paste inserted the whole clipboard at every caret: seven
carets and a six-line clipboard produced six full copies inside the
first pair of quotes and left the rest untouched.

When the clipboard has exactly one line per caret, each caret now gets
its own line — the JetBrains behavior the issue expects. Any other
count still pastes the full text at every caret. Shared by all three
paste entry points (normal, plain text, history) through dispatchPaste.

Fixes #1828

* test(editor): cover trailing-newline paste at seven carets
This commit is contained in:
arookieofc authored and GitHub committed 2026-11-06 21:40:08 +08:00
1 parent e2d82e2ec8
commit ef95cd1716
3 files changed
+101 -7

No files matched your search

+70 -3
View File
@@ -1,9 +1,9 @@
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { EditorState } from "@codemirror/state";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { EditorSelection, EditorState } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import { commands } from "@/platform/ipc";
import { isRemoteWindow } from "@/platform/remote";
import { clipboardDocumentPath, clipboardFilesExtension, pasteEditorClipboard } from "./clipboard";
import { clipboardDocumentPath, clipboardFilesExtension, pasteEditorClipboard, pasteEditorText } from "./clipboard";
import { markdownImages } from "./markdownImages";
vi.mock("@/platform/remote", () => ({ isRemoteWindow: vi.fn(() => false) }));
@@ -204,3 +204,70 @@ it("does not insert into a read-only editor", async () => {
expect(view.state.doc.toString()).toBe("");
expect(list).not.toHaveBeenCalled();
});
describe("multi-caret paste", () => {
const view = (doc: string, ranges: Array<[number, number]>) => {
const v = new EditorView({
state: EditorState.create({
doc,
selection: EditorSelection.create(
ranges.length > 1
? ranges.map(([from, to]) => EditorSelection.range(from, to))
: [EditorSelection.cursor(ranges[0]![0])],
),
extensions: EditorState.allowMultipleSelections.of(true),
}),
parent: document.body,
});
views.push(v);
return v;
};
it("pastes one line per caret when the counts match", () => {
const line = 'icjsdiocjosid "" cdscsd';
// A caret inside each pair of quotes (index 15), as in the issue.
const v = view(
Array(7).fill(line).join("\n"),
Array.from({ length: 7 }, (_, i) => {
const from = i * (line.length + 1) + 15;
return [from, from] as [number, number];
}),
);
pasteEditorText(v, "s\nd\nd\ns\nd\nd\ne");
expect(v.state.doc.toString()).toBe(
Array.from({ length: 7 }, (_, i) => `icjsdiocjosid "${"sddsdde"[i]}" cdscsd`).join("\n"),
);
});
it("keeps a trailing newline as the last caret's empty line (issue #1828)", () => {
const line = 'icjsdiocjosid "" cdscsd';
// Seven carets, six copied lines with a trailing newline: split keeps the
// empty seventh segment, so the last caret gets "" as the issue expects.
const v = view(
Array(7).fill(line).join("\n"),
Array.from({ length: 7 }, (_, i) => {
const from = i * (line.length + 1) + 15;
return [from, from] as [number, number];
}),
);
pasteEditorText(v, "s\nd\nd\ns\nd\nd\n");
expect(v.state.doc.toString()).toBe(
Array.from({ length: 7 }, (_, i) => `icjsdiocjosid "${"sddsdd"[i] ?? ""}" cdscsd`).join("\n"),
);
});
it("pastes the whole text at every caret when the counts differ", () => {
const v = view("a b", [
[1, 1],
[3, 3],
]);
pasteEditorText(v, "x\ny\nz");
expect(v.state.doc.toString()).toBe("ax\ny\nz bx\ny\nz");
});
it("pastes the whole text at a single caret", () => {
const v = view("a", [[1, 1]]);
pasteEditorText(v, "x\ny");
expect(v.state.doc.toString()).toBe("ax\ny");
});
});
+28 -2
View File
@@ -1,4 +1,4 @@
import { Facet, Prec } from "@codemirror/state";
import { EditorSelection, Facet, Prec } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import { pasteClipboardFiles, readClipboardText } from "@/platform/clipboard";
import { isRemoteWindow } from "@/platform/remote";
@@ -13,9 +13,35 @@ function clipboardFileFormatter(view: EditorView): ((paths: string[]) => string)
: undefined;
}
/**
* The dispatch shared by every paste entry point. With several carets and a
* clipboard of exactly one line per caret, each caret gets its own line
* (JetBrains behavior): `"a\nb"` at two carets inserts `a` then `b`, not the
* block twice. Any other count pastes the full text at every caret.
* `userEvent` is `"input.paste"` unless the caller passes another (plain-text
* paste opts out of Reformat on Paste through `"input.pastePlain"`).
*/
export function dispatchPaste(view: EditorView, text: string, userEvent = "input.paste") {
const ranges = view.state.selection.ranges;
const lines = text.replace(/\r\n?/g, "\n").split("\n");
if (ranges.length > 1 && lines.length === ranges.length) {
let i = 0;
const spec = view.state.changeByRange((range) => {
const insert = lines[i++]!;
return {
changes: { from: range.from, to: range.to, insert },
range: EditorSelection.cursor(range.from + insert.length),
};
});
view.dispatch({ ...spec, userEvent, scrollIntoView: true });
} else {
view.dispatch(view.state.replaceSelection(text), { userEvent, scrollIntoView: true });
}
}
export function pasteEditorText(view: EditorView, text: string) {
if (!text || !view.dom.isConnected || view.state.readOnly) return;
view.dispatch(view.state.replaceSelection(text), { userEvent: "input.paste", scrollIntoView: true });
dispatchPaste(view, text);
view.focus();
}
+3 -2
View File
@@ -6,6 +6,7 @@ import { offsetToPos, posToOffset } from "@/editor/lsp/positions";
import { caretAnchor, openPopup } from "@/editor/lsp/popupStore";
import { executeAction } from "@/platform/actions";
import { clipLabel, clipboardHistory, recordClipboard } from "./clipboardHistory";
import { dispatchPaste } from "./cm/clipboard";
import { unwrapOptions, type ChainNode, type UnwrapOption } from "./cm/unwrap";
import type { EditorSlot } from "./model/slot";
@@ -13,7 +14,7 @@ const hint = (text: string) => void executeAction("editor.showHint", { text });
export function pasteText(view: EditorView, text: string) {
if (view.state.readOnly) return;
view.dispatch(view.state.replaceSelection(text), { userEvent: "input.paste", scrollIntoView: true });
dispatchPaste(view, text);
view.focus();
}
@@ -125,6 +126,6 @@ export async function pasteAsPlainText(view: EditorView) {
return;
}
if (!text) return;
view.dispatch(view.state.replaceSelection(text), { userEvent: "input.pastePlain", scrollIntoView: true });
dispatchPaste(view, text, "input.pastePlain");
view.focus();
}