mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-08 17:02:17 +00:00
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:
1 parent
e2d82e2ec8
commit
ef95cd1716
3 files changed
+101
-7
No files matched your search
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
Reference in new issue
Block a user