From 0630ed0729f9d7d2594090a17bbfccd745242290 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sun, 8 Nov 2026 06:13:21 +0800 Subject: [PATCH] feat(editor): clipboard: whole-line paste, paste history dialog, copy path chooser, escape and imports on paste (#2014) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Whole-line copies and cuts paste above the caret line from every paste entry point (shortcut, menu, history, plain text), including a copied blank line. Paste from History opens a dialog with filter, multi-select, delete and preview. Copy Path/Reference (⇧⌘C) opens a chooser for absolute path, file name, content-root/source-root paths, path with line and reference. Text pasted into a string literal is escaped (Smart Keys setting; Paste as Plain Text bypasses it), and missing imports are added after a paste (Auto Import setting: Ask / Always / Never). --- crates/ide-core/src/editor_settings.rs | 33 ++ src/bindings.ts | 4 + src/editor/core/actions.ts | 133 ++++- src/editor/core/clipboardHistory.test.ts | 57 +- src/editor/core/clipboardHistory.ts | 54 +- src/editor/core/cm/clipboard.test.ts | 186 ++++++- src/editor/core/cm/clipboard.ts | 24 +- src/editor/core/cm/interpolation.ts | 6 +- src/editor/core/cm/prefs.ts | 13 +- src/editor/core/cm/stringClipboard.test.ts | 258 +++++++++ src/editor/core/cm/stringClipboard.ts | 160 ++++++ src/editor/core/controller.ts | 1 + src/editor/core/copyPathPopup.test.tsx | 244 +++++++++ src/editor/core/extras.test.ts | 98 ++++ src/editor/core/extras.ts | 32 +- src/editor/core/index.ts | 3 + .../core/view/PasteHistoryDialog.module.css | 49 ++ .../core/view/PasteHistoryDialog.test.tsx | 202 +++++++ src/editor/core/view/PasteHistoryDialog.tsx | 243 +++++++++ src/editor/lsp/Popups.module.css | 16 + src/editor/lsp/Popups.test.tsx | 35 ++ src/editor/lsp/Popups.tsx | 48 +- src/editor/lsp/diagnostics.test.ts | 25 +- src/editor/lsp/diagnostics.ts | 17 +- src/editor/lsp/importsOnPaste.test.ts | 509 ++++++++++++++++++ src/editor/lsp/importsOnPaste.ts | 400 ++++++++++++++ src/editor/lsp/index.ts | 3 + src/editor/lsp/popupStore.ts | 8 +- src/features/project-tree/actions.ts | 4 +- src/features/project-tree/operations.ts | 14 +- .../settings/pages/editor/general.tsx | 17 +- src/platform/copyPath.test.ts | 51 ++ src/platform/copyPath.ts | 23 + src/platform/editorSettings.ts | 4 +- src/platform/keymaps/ideaParity.test.ts | 4 +- src/platform/keymaps/intellij-macos.ts | 5 +- src/platform/keymaps/intellij-windows.ts | 5 +- src/platform/keymaps/vscode-macos.ts | 3 + src/platform/locales/text/en.json | 29 +- src/platform/locales/text/zh.json | 23 +- 40 files changed, 2950 insertions(+), 93 deletions(-) create mode 100644 src/editor/core/cm/stringClipboard.test.ts create mode 100644 src/editor/core/cm/stringClipboard.ts create mode 100644 src/editor/core/copyPathPopup.test.tsx create mode 100644 src/editor/core/extras.test.ts create mode 100644 src/editor/core/view/PasteHistoryDialog.module.css create mode 100644 src/editor/core/view/PasteHistoryDialog.test.tsx create mode 100644 src/editor/core/view/PasteHistoryDialog.tsx create mode 100644 src/editor/lsp/importsOnPaste.test.ts create mode 100644 src/editor/lsp/importsOnPaste.ts create mode 100644 src/platform/copyPath.test.ts create mode 100644 src/platform/copyPath.ts diff --git a/crates/ide-core/src/editor_settings.rs b/crates/ide-core/src/editor_settings.rs index c5b32ef98..28fbe27c1 100644 --- a/crates/ide-core/src/editor_settings.rs +++ b/crates/ide-core/src/editor_settings.rs @@ -166,6 +166,16 @@ impl Default for CompletionSettings { pub struct AutoImportSettings { pub add_unambiguous: bool, pub optimize_on_the_fly: bool, + pub imports_on_paste: ImportsOnPaste, +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub enum ImportsOnPaste { + #[default] + Ask, + Always, + Never, } /// Editor › General › Code Folding. @@ -251,6 +261,7 @@ pub struct SmartKeysSettings { pub tab_out_of_pair: bool, /// Reformat on paste: `none` | `indentBlock` | `indentEachLine`. pub reformat_on_paste: String, + pub escape_on_paste: bool, pub smart_indent: bool, /// Enter › Insert documentation comment stub: Enter after `/**` closes the comment. pub insert_doc_stub: bool, @@ -268,6 +279,7 @@ impl Default for SmartKeysSettings { surround_selection: true, tab_out_of_pair: true, reformat_on_paste: "indentEachLine".into(), + escape_on_paste: true, smart_indent: true, insert_doc_stub: true, backspace_unindent: "autoindent".into(), @@ -464,3 +476,24 @@ impl Default for EncodingSettings { } } } + +#[cfg(test)] +mod auto_import_tests { + use super::{AutoImportSettings, ImportsOnPaste}; + + #[test] + fn imports_on_paste_defaults_to_ask_for_existing_settings() { + let settings: AutoImportSettings = serde_json::from_str(r#"{"addUnambiguous":true}"#).unwrap(); + assert!(settings.add_unambiguous); + assert_eq!(settings.imports_on_paste, ImportsOnPaste::Ask); + } + + #[test] + fn imports_on_paste_modes_round_trip() { + for mode in ["ask", "always", "never"] { + let json = serde_json::json!({ "importsOnPaste": mode }); + let settings: AutoImportSettings = serde_json::from_value(json).unwrap(); + assert_eq!(serde_json::to_value(settings).unwrap()["importsOnPaste"], mode); + } + } +} diff --git a/src/bindings.ts b/src/bindings.ts index cc9bdaa76..8e7c7d973 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -1940,6 +1940,7 @@ export type AttachableProcess = { export type AutoImportSettings = { addUnambiguous?: boolean, optimizeOnTheFly?: boolean, + importsOnPaste?: ImportsOnPaste, }; /** When local changes are uploaded to the default deployment server. */ @@ -4598,6 +4599,8 @@ export type ImportedRunConfig_Serialize = { ignored: string[], }; +export type ImportsOnPaste = "ask" | "always" | "never"; + export type IndentConfig = IndentConfig_Serialize | IndentConfig_Deserialize; export type IndentConfig_Deserialize = { @@ -7477,6 +7480,7 @@ export type SmartKeysSettings = { tabOutOfPair?: boolean, /** Reformat on paste: `none` | `indentBlock` | `indentEachLine`. */ reformatOnPaste?: string, + escapeOnPaste?: boolean, smartIndent?: boolean, /** Enter › Insert documentation comment stub: Enter after `/**` closes the comment. */ insertDocStub?: boolean, diff --git a/src/editor/core/actions.ts b/src/editor/core/actions.ts index e6410539e..d8c35480c 100644 --- a/src/editor/core/actions.ts +++ b/src/editor/core/actions.ts @@ -47,7 +47,7 @@ import { reverseLines, sortLines } from "./cm/lines"; import { customRegions } from "./cm/regions"; import { enclosingHeaders } from "./cm/stickyScroll"; import { moveElementLeft, moveElementRight } from "./cm/moveElement"; -import { caretAnchor, openPopup } from "@/editor/lsp/popupStore"; +import { caretAnchor, openPopup, updatePopup, useLspPopup, type LspPopup } from "@/editor/lsp/popupStore"; import { cursorCodeBlockEnd, cursorCodeBlockStart, @@ -86,6 +86,8 @@ import { structure } from "@/editor/structure"; import { tx } from "@/platform/i18n"; import { lspRequest } from "@/editor/lsp/lsp"; import { readFileLines, withDocument } from "@/editor/lsp/files"; +import { pathToCopy } from "@/platform/copyPath"; +import { shortcutLabel } from "@/platform/keymap"; /** Text-editing actions: the editor has focus, or a popup (Find Action) runs them on it. */ export const TEXT_WHEN = "editorTextFocus || textEditorActive && popupOpen"; @@ -475,8 +477,7 @@ async function resolveReference(view: EditorView, slot: EditorSlot): Promise) { const failed = (error: unknown) => { notifications().notify({ level: "error", @@ -496,6 +497,93 @@ function copyReference(view: EditorView, slot: EditorSlot) { } } +function copyPathReferencePopup(view: EditorView, slot: EditorSlot) { + const path = slot.model.path; + const line = view.state.doc.lineAt(view.state.selection.main.head).number; + const flavours = [ + { id: "project.copyAbsolutePath", label: "Absolute Path", value: pathToCopy("absolute", path) }, + { id: "project.copyFileName", label: "File Name", value: pathToCopy("name", path) }, + { id: "project.copyRelativePath", label: "Path From Content Root", value: pathToCopy("relative", path) }, + { id: "project.copySourceRootPath", label: "Path From Source Root", value: pathToCopy("source", path) }, + { + id: "editor.copyPathWithLineNumber", + label: "Path with line number", + value: `${pathToCopy("relative", path)}:${line}`, + }, + ]; + const popup: Extract = { + kind: "list", + title: tx("Copy Path/Reference…"), + anchor: caretAnchor(view, view.state.selection.main.head), + items: [ + ...flavours + .filter((f) => f.value !== null) + .map((f) => ({ + id: f.id, + label: tx(f.label), + preview: f.value!, + shortcut: shortcutLabel(f.id), + run: () => { + void executeAction(f.id, f.id === "editor.copyPathWithLineNumber" ? f.value : path); + }, + })), + { + id: "editor.copyReference", + label: tx("Reference"), + preview: tx("Resolving reference…"), + disabled: tx("Resolving reference…"), + shortcut: shortcutLabel("editor.copyReference"), + run: () => {}, + }, + ], + }; + openPopup(popup); + void resolveReference(view, slot) + .then((reference) => { + if (useLspPopup.getState().popup !== popup) return; + updatePopup("list", { + items: popup.items.map((item) => + item.id === "editor.copyReference" + ? { + ...item, + preview: reference, + disabled: null, + run: () => { + void executeAction(item.id, reference); + }, + } + : item, + ), + }); + }) + .catch(() => { + if (useLspPopup.getState().popup === popup) + updatePopup("list", { + items: popup.items.map((item) => + item.id === "editor.copyReference" + ? { ...item, preview: undefined, disabled: tx("Cannot copy reference") } + : item, + ), + }); + }); +} + +function copyEditorPath(text: string) { + void navigator.clipboard + .writeText(text) + .then(() => { + recordClipboard(text); + void executeAction("editor.showHint", { text: tx`Copied ${text}` }); + }) + .catch((error: unknown) => { + notifications().notify({ + level: "error", + title: tx("Cannot access the clipboard"), + body: error instanceof Error ? error.message : String(error), + }); + }); +} + /** ⌃G; when nothing more is found, a hint first and the search from the top on the next press (IDEA). */ function addNextOccurrence(view: EditorView) { if (nextOccurrence(view) === "notFound") @@ -606,10 +694,17 @@ export function registerEditorActions(): () => void { keywords: ["hippie", "complete word", "dabbrev"], }, ), - text("editor.copyReference", "Copy Reference", (v, slot) => copyReference(v, slot), { + { + id: "editor.copyReference", + title: "Copy Reference", + when: TEXT_WHEN, + run: ({ arg }) => { + if (typeof arg === "string") copyReference(Promise.resolve(arg)); + else withView((v, slot) => copyReference(resolveReference(v, slot)))(); + }, category: "Edit", keywords: ["fully qualified name", "fqn", "copy path"], - }), + }, text("code.pasteAsPlainText", "Paste as Plain Text", (v) => pasteAsPlainText(v), { category: "Edit", icon: "paste", @@ -1026,7 +1121,7 @@ export function registerEditorActions(): () => void { }, { id: "editor.copyPath", - title: "Copy Path", + title: "Absolute Path", category: "Edit", icon: "copy", when: "textEditorActive", @@ -1034,7 +1129,31 @@ export function registerEditorActions(): () => void { keyWhen: "editorFocus || !toolWindowFocus", run: () => { const p = ctrl.activePath(); - if (p) void navigator.clipboard?.writeText(p); + if (p) copyEditorPath(p); + }, + }, + { + id: "editor.copyPathReference", + title: "Copy Path/Reference…", + category: "Edit", + icon: "copy", + when: "textEditorActive", + keyWhen: "editorFocus || !toolWindowFocus", + run: withView(copyPathReferencePopup), + }, + { + id: "editor.copyPathWithLineNumber", + title: "Copy Path with Line Number", + category: "Edit", + when: TEXT_WHEN, + run: ({ arg }) => { + if (typeof arg === "string") copyEditorPath(arg); + else + withView((view, slot) => + copyEditorPath( + `${pathToCopy("relative", slot.model.path)}:${view.state.doc.lineAt(view.state.selection.main.head).number}`, + ), + )(); }, }, { diff --git a/src/editor/core/clipboardHistory.test.ts b/src/editor/core/clipboardHistory.test.ts index c8de4e3c9..6fe4f6656 100644 --- a/src/editor/core/clipboardHistory.test.ts +++ b/src/editor/core/clipboardHistory.test.ts @@ -8,7 +8,9 @@ import { clipboardHistory, copiedText, installClipboardTracking, + isLinewiseClipboardText, recordClipboard, + removeClipboardEntries, } from "./clipboardHistory"; beforeEach(() => clearClipboardHistory()); @@ -19,10 +21,13 @@ describe("clipboard history (Paste from History)", () => { recordClipboard("b"); recordClipboard("a"); recordClipboard(""); - expect(clipboardHistory()).toEqual(["a", "b"]); + expect(clipboardHistory()).toEqual([ + { text: "a", linewise: false }, + { text: "b", linewise: false }, + ]); for (let i = 0; i < MAX_CLIPBOARD_ENTRIES + 5; i++) recordClipboard(`x${i}`); expect(clipboardHistory()).toHaveLength(MAX_CLIPBOARD_ENTRIES); - expect(clipboardHistory()[0]).toBe(`x${MAX_CLIPBOARD_ENTRIES + 4}`); + expect(clipboardHistory()[0]?.text).toBe(`x${MAX_CLIPBOARD_ENTRIES + 4}`); }); it("copies the selections, or whole caret lines without a selection", () => { @@ -43,13 +48,57 @@ describe("clipboard history (Paste from History)", () => { EditorSelection.cursor(5), ]), }); - expect(copiedText(carets)).toBe("one\ntwo\n"); + expect(copiedText(carets)).toBe("one\ntwo"); + }); + + it("keeps a line-wise flag with older history entries and restores it when promoted", () => { + recordClipboard("one", true); + recordClipboard("two"); + expect(clipboardHistory()).toEqual([ + { text: "two", linewise: false }, + { text: "one", linewise: true }, + ]); + expect(isLinewiseClipboardText("two")).toBe(false); + expect(isLinewiseClipboardText("one")).toBe(false); + const entry = clipboardHistory()[1]!; + recordClipboard(entry.text, entry.linewise); + expect(isLinewiseClipboardText("one")).toBe(true); + expect(isLinewiseClipboardText("one\n")).toBe(false); + expect(isLinewiseClipboardText("other app")).toBe(false); + }); + + it("replaces the flag when the same text is copied as a selection", () => { + recordClipboard("one", true); + recordClipboard("one"); + expect(clipboardHistory()).toEqual([{ text: "one", linewise: false }]); + expect(isLinewiseClipboardText("one")).toBe(false); + }); + + it("clears remembered line-wise text with the history", () => { + recordClipboard("one", true); + clearClipboardHistory(); + expect(isLinewiseClipboardText("one")).toBe(false); + }); + + it("removes entries by content without disturbing the remaining history", () => { + recordClipboard("oldest"); + recordClipboard("middle"); + recordClipboard("newest"); + removeClipboardEntries(["oldest", "newest", "missing", "newest"]); + expect(clipboardHistory().map((e) => e.text)).toEqual(["middle"]); + removeClipboardEntries([]); + expect(clipboardHistory().map((e) => e.text)).toEqual(["middle"]); + recordClipboard("newest"); + expect(clipboardHistory().map((e) => e.text)).toEqual(["newest", "middle"]); + removeClipboardEntries(clipboardHistory().map((e) => e.text)); + expect(clipboardHistory().map((e) => e.text)).toEqual([]); }); it("labels rows by their first non-blank line and line count", () => { expect(clipLabel("\n foo()\nbar\n")).toEqual({ label: "foo()", detail: "3 lines" }); expect(clipLabel("x".repeat(100), 10).label).toBe(`${"x".repeat(9)}…`); expect(clipLabel(" ").label).toBe("(whitespace)"); + expect(clipLabel("first\rsecond\r").detail).toBe("2 lines"); }); describe("outside editors", () => { @@ -62,7 +111,7 @@ describe("clipboard history (Paste from History)", () => { document.body.appendChild(input); input.setSelectionRange(6, 11); input.dispatchEvent(new Event("copy", { bubbles: true })); - expect(clipboardHistory()[0]).toBe("world"); + expect(clipboardHistory()[0]).toEqual({ text: "world", linewise: false }); input.remove(); }); }); diff --git a/src/editor/core/clipboardHistory.ts b/src/editor/core/clipboardHistory.ts index 43b9fb084..90ef08e6e 100644 --- a/src/editor/core/clipboardHistory.ts +++ b/src/editor/core/clipboardHistory.ts @@ -6,33 +6,52 @@ import { EditorView } from "@codemirror/view"; import type { EditorState } from "@codemirror/state"; import { tx } from "@/platform/i18n"; +import { filterCopiedText } from "./cm/clipboard"; export const MAX_CLIPBOARD_ENTRIES = 30; -let entries: string[] = []; +interface ClipboardEntry { + text: string; + linewise: boolean; +} -export function clipboardHistory(): readonly string[] { +let entries: ClipboardEntry[] = []; + +export function clipboardHistory(): readonly ClipboardEntry[] { return entries; } /** Adds `text` on top; an existing equal entry moves up instead of repeating. */ -export function recordClipboard(text: string) { - if (!text) return; - entries = [text, ...entries.filter((e) => e !== text)].slice(0, MAX_CLIPBOARD_ENTRIES); +export function recordClipboard(text: string, linewise = false) { + if (!text && !linewise) return; + entries = [{ text, linewise }, ...entries.filter((e) => e.text !== text)].slice(0, MAX_CLIPBOARD_ENTRIES); +} + +/** Only the latest copy determines the current clipboard's paste mode. */ +export function isLinewiseClipboardText(text: string): boolean { + return entries[0]?.linewise === true && entries[0].text === text; } export function clearClipboardHistory() { entries = []; } +export function removeClipboardEntries(texts: readonly string[]) { + const removed = new Set(texts); + entries = entries.filter((e) => !removed.has(e.text)); +} + /** What CodeMirror copies: the selections, or the caret lines when nothing is selected. */ export function copiedText(state: EditorState): string { const ranges = state.selection.ranges; if (ranges.some((r) => !r.empty)) - return ranges - .filter((r) => !r.empty) - .map((r) => state.sliceDoc(r.from, r.to)) - .join(state.lineBreak); + return filterCopiedText( + state, + ranges + .filter((r) => !r.empty) + .map((r) => state.sliceDoc(r.from, r.to)) + .join(state.lineBreak), + ); const lines: string[] = []; let last = -1; for (const r of ranges) { @@ -41,21 +60,28 @@ export function copiedText(state: EditorState): string { last = line.number; lines.push(line.text); } - return lines.join(state.lineBreak) + state.lineBreak; + return lines.join(state.lineBreak); } /** Label of a history row: first non-blank line, shortened, plus the line count. */ export function clipLabel(text: string, max = 80): { label: string; detail: string | undefined } { const lines = text.split(/\r\n?|\n/); const first = (lines.find((l) => l.trim()) ?? "").trim(); - const label = first.length > max ? `${first.slice(0, max - 1)}…` : first || "(whitespace)"; - const count = text.endsWith("\n") ? lines.length - 1 : lines.length; + const label = first.length > max ? `${first.slice(0, max - 1)}…` : first || tx("(whitespace)"); + const count = /[\r\n]$/.test(text) ? lines.length - 1 : lines.length; return { label, detail: count > 1 ? tx`${count} lines` : undefined }; } +function recordEditorClipboard(_e: Event, view: EditorView) { + recordClipboard( + copiedText(view.state), + view.state.selection.ranges.every((r) => r.empty), + ); +} + export const clipboardHistoryExtension = EditorView.domEventHandlers({ - copy: (_e, view) => void recordClipboard(copiedText(view.state)), - cut: (_e, view) => void recordClipboard(copiedText(view.state)), + copy: recordEditorClipboard, + cut: recordEditorClipboard, }); /** Copies outside editors (inputs, tool windows) go into the history too. */ diff --git a/src/editor/core/cm/clipboard.test.ts b/src/editor/core/cm/clipboard.test.ts index 4839a2994..d179255d9 100644 --- a/src/editor/core/cm/clipboard.test.ts +++ b/src/editor/core/cm/clipboard.test.ts @@ -1,15 +1,30 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { EditorSelection, EditorState } from "@codemirror/state"; +import { EditorSelection, EditorState, Transaction, type Extension } from "@codemirror/state"; +import { indentService, indentUnit } from "@codemirror/language"; import { EditorView } from "@codemirror/view"; import { commands } from "@/platform/ipc"; import { isRemoteWindow } from "@/platform/remote"; -import { clipboardDocumentPath, clipboardFilesExtension, pasteEditorClipboard, pasteEditorText } from "./clipboard"; +import { + clearClipboardHistory, + clipboardHistory, + clipboardHistoryExtension, + recordClipboard, +} from "../clipboardHistory"; +import { + clipboardDocumentPath, + clipboardFilesExtension, + dispatchPaste, + pasteEditorClipboard, + pasteEditorText, +} from "./clipboard"; import { markdownImages } from "./markdownImages"; +import { pasteReindentFilter } from "./prefs"; vi.mock("@/platform/remote", () => ({ isRemoteWindow: vi.fn(() => false) })); const views: EditorView[] = []; beforeEach(() => { + clearClipboardHistory(); vi.stubGlobal("__TAURI_INTERNALS__", {}); Object.defineProperty(document, "execCommand", { configurable: true, value: vi.fn(() => false) }); Object.defineProperty(document, "queryCommandSupported", { configurable: true, value: vi.fn(() => false) }); @@ -205,6 +220,173 @@ it("does not insert into a read-only editor", async () => { expect(list).not.toHaveBeenCalled(); }); +describe("line-wise paste", () => { + const make = (doc: string, positions: number[] = [1], extensions: Extension = []) => { + const view = new EditorView({ + doc, + selection: EditorSelection.create(positions.map((pos) => EditorSelection.cursor(pos))), + extensions: [clipboardHistoryExtension, EditorState.allowMultipleSelections.of(true), extensions], + parent: document.body, + }); + views.push(view); + return view; + }; + + const copy = (view: EditorView, type = "copy") => { + view.focus(); + let text = ""; + const event = new Event(type, { bubbles: true, cancelable: true }); + Object.defineProperty(event, "clipboardData", { + value: { clearData() {}, setData: (_type: string, value: string) => (text = value) }, + }); + view.contentDOM.dispatchEvent(event); + return text; + }; + + it("pastes a copied caret line above the line through the context menu", async () => { + const view = make("one\ntarget"); + const text = copy(view); + expect(text).toBe("one"); + Object.defineProperty(navigator, "clipboard", { configurable: true, value: { readText: async () => text } }); + view.dispatch({ selection: { anchor: 7 } }); + + await pasteEditorClipboard(view); + + expect(view.state.doc.toString()).toBe("one\none\ntarget"); + expect(view.state.selection.main.head).toBe(11); + }); + + it("tracks a line-wise cut before the line is removed", () => { + const view = make("one\ntarget"); + const text = copy(view, "cut"); + expect(view.state.doc.toString()).toBe("target"); + expect(clipboardHistory()[0]).toEqual({ text: "one", linewise: true }); + view.dispatch({ selection: { anchor: 3 } }); + dispatchPaste(view, text); + expect(view.state.doc.toString()).toBe("one\ntarget"); + expect(view.state.selection.main.head).toBe(7); + }); + + it("replaces a non-empty selection even when the copy was line-wise", () => { + const view = make("one\ntarget"); + const text = copy(view); + view.dispatch({ selection: { anchor: 5, head: 8 } }); + dispatchPaste(view, text); + expect(view.state.doc.toString()).toBe("one\ntoneet"); + expect(view.state.selection.main.head).toBe(8); + }); + + it("pastes unmatched text at the caret", () => { + const view = make("one\ntarget"); + copy(view); + view.dispatch({ selection: { anchor: 7 } }); + dispatchPaste(view, "other\n"); + expect(view.state.doc.toString()).toBe("one\ntarother\nget"); + }); + + it("does not treat a later selection copy as line-wise", () => { + const view = make("one\ntarget"); + copy(view); + view.dispatch({ selection: { anchor: 5, head: 8 } }); + const text = copy(view); + expect(clipboardHistory()[0]).toEqual({ text: "arg", linewise: false }); + view.dispatch({ selection: { anchor: 7 } }); + dispatchPaste(view, text); + expect(view.state.doc.toString()).toBe("one\ntarargget"); + }); + + it("copies distinct caret lines and pastes the block once per distinct target line", () => { + const view = make("one\ntwo", [1, 2, 5]); + const text = copy(view); + expect(text).toBe("one\ntwo"); + expect(clipboardHistory()[0]).toEqual({ text, linewise: true }); + view.dispatch({ + selection: EditorSelection.create( + [1, 2, 4, 7].map((pos) => EditorSelection.cursor(pos)), + 2, + ), + }); + dispatchPaste(view, text); + expect(view.state.doc.toString()).toBe("one\ntwo\none\none\ntwo\ntwo"); + expect(view.state.selection.ranges.map((r) => r.head)).toEqual([9, 10, 20, 23]); + expect(view.state.selection.mainIndex).toBe(2); + }); + + it("keeps mixed empty and non-empty selections on the usual per-caret split path", () => { + const view = make("one\ntwo"); + copy(view); + view.dispatch({ selection: EditorSelection.create([EditorSelection.range(0, 2), EditorSelection.cursor(5)]) }); + dispatchPaste(view, "one"); + expect(view.state.doc.toString()).toBe("onee\ntonewo"); + }); + + it.each([0, 3])("moves a caret at column %s down with its original line", (pos) => { + const view = make("one", [pos]); + dispatchPaste(view, copy(view)); + expect(view.state.doc.toString()).toBe("one\none"); + expect(view.state.selection.main.head).toBe(pos + 4); + }); + + it("copies and pastes an empty caret line", () => { + const view = make("", [0]); + pasteEditorText(view, copy(view)); + expect(view.state.doc.toString()).toBe("\n"); + expect(view.state.selection.main.head).toBe(1); + }); + + it("uses document line breaks without counting CRLF as two document positions", () => { + const view = make("one\r\ntwo", [1, 5], EditorState.lineSeparator.of("\r\n")); + const text = copy(view); + expect(text).toBe("one\r\ntwo"); + view.dispatch({ selection: { anchor: 2 } }); + dispatchPaste(view, text); + expect(view.state.sliceDoc()).toBe("one\r\ntwo\r\none\r\ntwo"); + expect(view.state.selection.main.head).toBe(10); + }); + + it("leaves native paste to CodeMirror without inserting the line twice", () => { + const view = make("one\ntarget", [1], clipboardFilesExtension); + const text = copy(view); + view.dispatch({ selection: { anchor: 7 } }); + const event = new Event("paste", { bubbles: true, cancelable: true }); + Object.defineProperty(event, "clipboardData", { + value: { getData: () => text, files: [], types: ["text/plain"] }, + }); + view.contentDOM.dispatchEvent(event); + expect(view.state.doc.toString()).toBe("one\none\ntarget"); + expect(view.state.selection.main.head).toBe(11); + }); + + it.each(["indentBlock", "indentEachLine"] as const)("keeps the original caret when reformatting with %s", (mode) => { + const view = make( + "if {\n target\n}", + [10], + [indentUnit.of(" "), indentService.of((_cx, pos) => (pos === 0 ? 0 : 2)), pasteReindentFilter(mode)], + ); + recordClipboard(" a();\n b();", true); + dispatchPaste(view, " a();\n b();"); + const inserted = mode === "indentBlock" ? " a();\n b();\n" : " a();\n b();\n"; + expect(view.state.doc.toString()).toBe(`if {\n${inserted} target\n}`); + expect(view.state.selection.main.head).toBe(10 + inserted.length); + expect(view.state.doc.lineAt(view.state.selection.main.head).text).toBe(" target"); + }); + + it("preserves paste event annotations for plain-text and normal paste", () => { + const events: Array = []; + const view = make( + "one\ntarget", + [7], + EditorView.updateListener.of((update) => { + events.push(...update.transactions.map((tr) => tr.annotation(Transaction.userEvent))); + }), + ); + recordClipboard("one", true); + dispatchPaste(view, "one"); + dispatchPaste(view, "one", "input.pastePlain"); + expect(events).toEqual(["input.paste", "input.pastePlain"]); + }); +}); + describe("multi-caret paste", () => { const view = (doc: string, ranges: Array<[number, number]>) => { const v = new EditorView({ diff --git a/src/editor/core/cm/clipboard.ts b/src/editor/core/cm/clipboard.ts index a82ba5881..6c77a3a9f 100644 --- a/src/editor/core/cm/clipboard.ts +++ b/src/editor/core/cm/clipboard.ts @@ -1,11 +1,16 @@ -import { EditorSelection, Facet, Prec } from "@codemirror/state"; +import { EditorSelection, Facet, Prec, type EditorState } from "@codemirror/state"; import { EditorView } from "@codemirror/view"; import { pasteClipboardFiles, readClipboardText } from "@/platform/clipboard"; import { isRemoteWindow } from "@/platform/remote"; +import { isLinewiseClipboardText } from "../clipboardHistory"; import { isMarkdown, relativeImagePath } from "./markdownImages"; export const clipboardDocumentPath = Facet.define({ combine: (paths) => paths[0] ?? "" }); +export function filterCopiedText(state: EditorState, text: string): string { + return state.facet(EditorView.clipboardOutputFilter).reduce((value, filter) => filter(value, state), text); +} + function clipboardFileFormatter(view: EditorView): ((paths: string[]) => string) | undefined { const path = view.state.facet(clipboardDocumentPath); return isMarkdown(path) && !isRemoteWindow() @@ -14,8 +19,9 @@ function clipboardFileFormatter(view: EditorView): ((paths: string[]) => string) } /** - * The dispatch shared by every paste entry point. With several carets and a - * multi-line clipboard, the lines spread across the carets the way IntelliJ + * Line-wise copies go above each distinct caret line, keeping carets on their + * original text. Otherwise, with several carets and a multi-line clipboard, + * the lines spread across the carets the way IntelliJ * does (its ClipboardTextPerCaretSplitter): caret *i* gets line *i*, missing * lines become empty inserts, and lines beyond the caret count are dropped — * `"a\nb"` at two carets inserts `a` then `b`, not the block twice, while a @@ -25,7 +31,15 @@ function clipboardFileFormatter(view: EditorView): ((paths: string[]) => string) * 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 state = view.state; + const ranges = state.selection.ranges; + if (isLinewiseClipboardText(text) && ranges.every((r) => r.empty)) { + const insert = state.toText(text + state.lineBreak); + const starts = [...new Set(ranges.map((r) => state.doc.lineAt(r.head).from))]; + const changes = state.changes(starts.map((from) => ({ from, insert }))); + view.dispatch({ changes, selection: state.selection.map(changes, 1), userEvent, scrollIntoView: true }); + return; + } const lines = pasteLinesPerCaret(text, ranges.length); if (lines) { let i = 0; @@ -59,7 +73,7 @@ function pasteLinesPerCaret(text: string, caretCount: number): string[] | undefi } export function pasteEditorText(view: EditorView, text: string) { - if (!text || !view.dom.isConnected || view.state.readOnly) return; + if ((!text && !isLinewiseClipboardText(text)) || !view.dom.isConnected || view.state.readOnly) return; dispatchPaste(view, text); view.focus(); } diff --git a/src/editor/core/cm/interpolation.ts b/src/editor/core/cm/interpolation.ts index 1615957f4..51d0f3d84 100644 --- a/src/editor/core/cm/interpolation.ts +++ b/src/editor/core/cm/interpolation.ts @@ -25,7 +25,7 @@ export function interpolationParser(base: StreamParser, dialect: Interpola return { name: base.name, languageData: base.languageData, - tokenTable: { ...base.tokenTable, templateDelimiter: tags.special(tags.brace) }, + tokenTable: { ...base.tokenTable, templateDelimiter: tags.special(tags.brace), stringRaw: tags.string }, startState: (indentUnit) => ({ frames: [{ code: start(indentUnit), depth: 0 }], indentUnit }), copyState: (state) => ({ ...state, @@ -76,7 +76,7 @@ export function interpolationParser(base: StreamParser, dialect: Interpola if (stream.next() === "\\" && literal.escape) stream.next(); } if (stream.eol() && !literal.multiline) frame.literal = undefined; - return "string"; + return dialect === "Kotlin" && literal.quote.length > 1 ? "stringRaw" : "string"; } // Probe with a copy so quotes/braces inside comments or characters stay opaque. const from = stream.pos; @@ -118,7 +118,7 @@ export function interpolationParser(base: StreamParser, dialect: Interpola !(dialect === "Scala" && stream.string.slice(0, from).endsWith("raw")), multiline: quote.length >= 3 || verbatim, }; - return "string"; + return dialect === "Kotlin" && quote.length > 1 ? "stringRaw" : "string"; } if (state.frames.length > 1 && style !== "comment" && !style?.startsWith("string") && end === from + 1) { if (frame.depth === 0 && stream.match(frame.close ?? "}")) { diff --git a/src/editor/core/cm/prefs.ts b/src/editor/core/cm/prefs.ts index 04edc133c..50484b401 100644 --- a/src/editor/core/cm/prefs.ts +++ b/src/editor/core/cm/prefs.ts @@ -50,6 +50,8 @@ import { import { enterInLineComment, lineCommentStyle, type LineCommentStyle } from "./comments"; import { enterInDocComment } from "./docComment"; import { skipClosingQuote, smartBackspace, smartBackspaceApplies, tabOut } from "./smartKeys"; +import { stringClipboard } from "./stringClipboard"; +import { filterCopiedText } from "./clipboard"; // --- soft wraps --------------------------------------------------------------- @@ -276,9 +278,11 @@ export function pasteReindentFilter(mode: "indentBlock" | "indentEachLine") { } const block = indentPastedText(text, targetCol, useTabs ? "\t" : " ", tabSize, unitCols); const out = prefix + (prefix ? block.trimStart() : block); + const formatted = start.changes({ from, to, insert: out }); const first: TransactionSpec = { - changes: { from, to, insert: out }, - selection: EditorSelection.cursor(from + out.length), + changes: formatted, + // Keep line-wise paste carets on their original text after reformatting. + selection: tr.newSelection.map(tr.changes.invertedDesc, 1).map(formatted, 1), userEvent: "input.paste", scrollIntoView: true, }; @@ -293,7 +297,7 @@ export function pasteReindentFilter(mode: "indentBlock" | "indentEachLine") { if (lineStart > lineEnd) return first; const reindent = indentRange(pasted, lineStart, lineEnd); if (reindent.empty) return first; - return [first, { changes: reindent, selection: EditorSelection.cursor(reindent.mapPos(end, 1)), sequential: true }]; + return [first, { changes: reindent, selection: pasted.selection.map(reindent, 1), sequential: true }]; }); } @@ -350,6 +354,8 @@ const richCopy = EditorView.domEventHandlers({ const sel = view.state.selection; if (sel.ranges.length !== 1 || sel.main.empty || !e.clipboardData) return false; const { from, to } = sel.main; + const text = view.state.sliceDoc(from, to); + if (filterCopiedText(view.state, text) !== text) return false; try { const html = richTextHtml(view, from, to); e.clipboardData.clearData(); @@ -543,6 +549,7 @@ export function prefsExtension(o: PrefsOptions): Extension { if (!g.dragAndDrop) out.push(noTextDrag); if (g.caretScroll === "center") out.push(centerCaret); if (g.richTextCopy) out.push(richCopy); + if (k.escapeOnPaste) out.push(stringClipboard(o.language)); if (!o.readOnly && (k.reformatOnPaste === "indentBlock" || k.reformatOnPaste === "indentEachLine")) out.push(pasteReindentFilter(k.reformatOnPaste)); diff --git a/src/editor/core/cm/stringClipboard.test.ts b/src/editor/core/cm/stringClipboard.test.ts new file mode 100644 index 000000000..dc8c4aa4d --- /dev/null +++ b/src/editor/core/cm/stringClipboard.test.ts @@ -0,0 +1,258 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { EditorSelection, EditorState } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +import { ensureSyntaxTree } from "@codemirror/language"; +import { history, redo, undo } from "@codemirror/commands"; +import { validateEditorExtras } from "@/platform/editorSettings"; +import { copiedText } from "../clipboardHistory"; +import { dispatchPaste } from "./clipboard"; +import { findLanguageDescription, loadSupport } from "./languages"; +import { prefsExtension } from "./prefs"; + +const views: EditorView[] = []; +afterEach(() => views.splice(0).forEach((view) => view.destroy())); + +const modes = [ + ["Java", "java"], + ["Kotlin", "kotlin"], + ["JavaScript", "javascript"], + ["TypeScript", "typescript"], + ["Python", "python"], + ["Rust", "rust"], + ["Go", "go"], + ["C", "c"], + ["C++", "cpp"], +] as const; + +async function make(mode: string, language: string, marked: string, settings: unknown = {}) { + const support = await loadSupport(findLanguageDescription(mode, "")!); + const ranges: ReturnType[] = []; + let doc = ""; + let anchor = 0; + for (const ch of marked) { + if (ch === "|") ranges.push(EditorSelection.cursor(doc.length)); + else if (ch === "«") anchor = doc.length; + else if (ch === "»") ranges.push(EditorSelection.range(anchor, doc.length)); + else doc += ch; + } + const view = new EditorView({ + parent: document.body, + state: EditorState.create({ + doc, + selection: EditorSelection.create(ranges), + extensions: [ + support!, + EditorState.allowMultipleSelections.of(true), + history(), + prefsExtension({ + prefs: validateEditorExtras(settings), + path: "", + softWrap: false, + showWhitespace: false, + hardWrapAt: 120, + readOnly: false, + language, + }), + ], + }), + }); + views.push(view); + ensureSyntaxTree(view.state, doc.length, 1000); + return view; +} + +describe("string clipboard", () => { + it.each(modes)("%s escapes ordinary pastes into a string", async (mode, language) => { + const view = await make(mode, language, 'value = "|"'); + dispatchPaste(view, 'say "hi"\\path\nnext'); + expect(view.state.doc.toString()).toBe('value = "say \\"hi\\"\\\\path\\nnext"'); + }); + + it.each(modes)("%s unescapes copied string contents", async (mode, language) => { + const view = await make(mode, language, 'value = "«say \\"hi\\"\\\\path\\nnext\\t!»"'); + expect(copiedText(view.state)).toBe('say "hi"\\path\nnext\t!'); + }); + + it.each(modes)("%s round-trips pasted control characters and Unicode", async (mode, language) => { + const view = await make(mode, language, 'value = "|"'); + const text = "😀\0\x01\b\t\n\f\v\u2028\u2029$"; + dispatchPaste(view, text); + expect(view.state.doc.lines).toBe(1); + view.dispatch({ selection: { anchor: 9, head: view.state.doc.length - 1 } }); + expect(copiedText(view.state)).toBe(text); + }); + + it.each([ + ["JavaScript", "javascript"], + ["TypeScript", "typescript"], + ["Python", "python"], + ])("%s escapes only the single-quoted string's delimiter", async (mode, language) => { + const view = await make(mode, language, "value = '|'"); + dispatchPaste(view, `say 'hi' and "bye"`); + expect(view.state.doc.toString()).toBe(`value = 'say \\'hi\\' and "bye"'`); + }); + + it.each(["input.pastePlain", "input.type", "input.drop"])("leaves %s unchanged", async (userEvent) => { + const view = await make("JavaScript", "javascript", 'value = "|"'); + dispatchPaste(view, '"\\\n', userEvent); + expect(view.state.doc.toString()).toBe('value = ""\\\n"'); + }); + + it("disables both escaping and unescaping with the setting", async () => { + const settings = { smartKeys: { escapeOnPaste: false, reformatOnPaste: "none" } }; + const view = await make("Java", "java", 'value = "«a\\nb»"', settings); + expect(copiedText(view.state)).toBe("a\\nb"); + dispatchPaste(view, '"\\\n'); + expect(view.state.doc.toString()).toBe('value = ""\\\n"'); + expect(validateEditorExtras({}).smartKeys.escapeOnPaste).toBe(true); + }); + + it("escapes Kotlin dollars and unescapes them on copy", async () => { + const view = await make("Kotlin", "kotlin", 'val s = "|"'); + dispatchPaste(view, "$name ${call()}"); + expect(view.state.doc.toString()).toBe('val s = "\\$name \\${call()}"'); + view.dispatch({ selection: { anchor: 9, head: view.state.doc.length - 1 } }); + expect(copiedText(view.state)).toBe("$name ${call()}"); + }); + + it.each([ + ["Java", "java", 'value = """\n|\n"""'], + ["Kotlin", "kotlin", 'val s = """\n"|"\n"""'], + ["Kotlin", "kotlin", 'val s = """|"""'], + ["Kotlin", "kotlin", 'val s = "before |${name} after"'], + ["Kotlin", "kotlin", 'val s = "before ${name} |after"'], + ["JavaScript", "javascript", "value = `|`"], + ["TypeScript", "typescript", "value = `hello |${name}`"], + ["Python", "python", 'value = r"|"'], + ["Python", "python", 'value = f"|{name}"'], + ["Python", "python", 'value = b"|"'], + ["Python", "python", "value = '''|'''"], + ["Python", "python", 'value = """\n"|"\n"""'], + ["Rust", "rust", 'let s = r#"|"#;'], + ["Rust", "rust", 'let s = r"|";'], + ["Rust", "rust", 'let s = b"|";'], + ["Go", "go", 's := `"|"`'], + ["C++", "cpp", 's = R"("|\n")";'], + ["Java", "java", "value = '|'"], + ["Go", "go", "s := '|'"], + ["JavaScript", "javascript", '// "|"'], + ["Kotlin", "kotlin", '/* "|" */'], + ["JavaScript", "javascript", 'value = "unfinished|'], + ["JavaScript", "javascript", 'value = |"text"'], + ["JavaScript", "javascript", 'value = "text"|'], + ["JavaScript", "javascript", 'value = «"text»"'], + ["JavaScript", "plaintext", 'value = "|"'], + ])("%s leaves unsupported literals/code unchanged: %s %s", async (mode, language, source) => { + const view = await make(mode, language, source, { smartKeys: { reformatOnPaste: "none" } }); + const before = view.state; + const { from, to } = before.selection.main; + const text = '"\\\n'; + dispatchPaste(view, text); + expect(view.state.doc.toString()).toBe(before.sliceDoc(0, from) + text + before.sliceDoc(to)); + }); + + it.each([ + ["JavaScript", "javascript", 'value = «"a\\nb"»'], + ["JavaScript", "javascript", 'value = "«a\\nb" + "c»"'], + ["JavaScript", "javascript", "value = `«a\\nb»`"], + ["Kotlin", "kotlin", 'val s = """\n"«a\\nb»"\n"""'], + ["Python", "python", 'value = r"«a\\nb»"'], + ["Python", "python", 'value = f"«a\\nb»{name}"'], + ["Rust", "rust", 'let s = r#"«a\\nb»"#;'], + ["Go", "go", "s := `«a\\nb»`"], + ])("%s preserves copied source outside one plain string: %s %s", async (mode, language, source) => { + const view = await make(mode, language, source); + const { from, to } = view.state.selection.main; + expect(copiedText(view.state)).toBe(view.state.sliceDoc(from, to)); + }); + + it("requires every caret to be inside a plain string", async () => { + const view = await make("JavaScript", "javascript", 'value = "|"; other = |'); + dispatchPaste(view, '"'); + expect(view.state.doc.toString()).toBe('value = """; other = "'); + }); + + it("escapes per-caret inserts with each literal's delimiter and maps all carets", async () => { + const view = await make("JavaScript", "javascript", `a = "|"; b = '«old»'; c = "|";`); + dispatchPaste(view, `one"\ntwo'\nthree\\`); + expect(view.state.doc.toString()).toBe(`a = "one\\""; b = 'two\\''; c = "three\\\\";`); + for (const range of view.state.selection.ranges) { + expect(range.empty).toBe(true); + expect(view.state.sliceDoc(range.head, range.head + 1)).toMatch(/["']/); + } + }); + + it("copies multiple string selections as decoded fragments", async () => { + const view = await make("JavaScript", "javascript", "a = \"«one\\n»\"; b = '«two\\t»'"); + expect(copiedText(view.state)).toBe("one\n\ntwo\t"); + view.dispatch({ + selection: EditorSelection.create([ + EditorSelection.range(5, 10), + EditorSelection.range(18, view.state.doc.length), + ]), + }); + expect(copiedText(view.state)).toBe( + view.state.selection.ranges.map((r) => view.state.sliceDoc(r.from, r.to)).join("\n"), + ); + }); + + it("replaces a substring and preserves the caret, undo and redo", async () => { + const view = await make("JavaScript", "javascript", 'value = "before «old» after"'); + dispatchPaste(view, '😀"\nnext'); + expect(view.state.doc.toString()).toBe('value = "before 😀\\"\\nnext after"'); + expect(view.state.selection.main.head).toBe(view.state.doc.toString().indexOf(" after")); + expect(undo(view)).toBe(true); + expect(view.state.doc.toString()).toBe('value = "before old after"'); + expect(redo(view)).toBe(true); + expect(view.state.doc.toString()).toBe('value = "before 😀\\"\\nnext after"'); + }); + + it.each(['value = "a\\|nb"', 'value = "a«\\»nb"', 'value = "a\\u12|34b"'])( + "preserves edits that split an existing escape: %s", + async (source) => { + const view = await make("JavaScript", "javascript", source); + const before = view.state; + const { from, to } = before.selection.main; + dispatchPaste(view, '"'); + expect(view.state.doc.toString()).toBe(before.sliceDoc(0, from) + '"' + before.sliceDoc(to)); + }, + ); + + it.each([ + ["Java", "java", "\\u4f60\\u597d\\141", "你好a"], + ["Kotlin", "kotlin", "\\u4f60\\u597d", "你好"], + ["JavaScript", "javascript", "\\u{1f600}\\x21", "😀!"], + ["Python", "python", "\\U0001f600\\x21\\141", "😀!a"], + ["Rust", "rust", "\\u{1f600}\\x21", "😀!"], + ["Go", "go", "\\U0001f600\\x21\\141", "😀!a"], + ["JavaScript", "javascript", "\\u{bad_name}\\xZZ", "\\u{bad_name}\\xZZ"], + ["Rust", "rust", "\\xFF\\u{d800}", "\\xFF\\u{d800}"], + ])("%s decodes supported escapes and preserves invalid escapes", async (mode, language, text, expected) => { + const view = await make(mode, language, `value = "«${text}»"`); + expect(copiedText(view.state)).toBe(expected); + }); + + it("handles native paste, copy, cut and rich copy consistently", async () => { + const view = await make("JavaScript", "javascript", 'value = "|"'); + const paste = new Event("paste", { bubbles: true, cancelable: true }); + Object.defineProperty(paste, "clipboardData", { value: { getData: () => 'say "hi"\nnext', files: [] } }); + view.focus(); + view.contentDOM.dispatchEvent(paste); + expect(view.state.doc.toString()).toBe('value = "say \\"hi\\"\\nnext"'); + view.dispatch({ selection: { anchor: 9, head: view.state.doc.length - 1 } }); + for (const type of ["copy", "cut"]) { + const data = new Map(); + const event = new Event(type, { bubbles: true, cancelable: true }); + Object.defineProperty(event, "clipboardData", { + value: { + clearData: () => data.clear(), + setData: (format: string, text: string) => data.set(format, text), + }, + }); + view.contentDOM.dispatchEvent(event); + expect(data.get("text/plain")).toBe('say "hi"\nnext'); + expect(data.has("text/html")).toBe(false); + } + expect(view.state.doc.toString()).toBe('value = ""'); + }); +}); diff --git a/src/editor/core/cm/stringClipboard.ts b/src/editor/core/cm/stringClipboard.ts new file mode 100644 index 000000000..b9b5c5cca --- /dev/null +++ b/src/editor/core/cm/stringClipboard.ts @@ -0,0 +1,160 @@ +import { ChangeSet, EditorState, Prec, type Extension } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +import { ensureSyntaxTree } from "@codemirror/language"; + +const LANGUAGES = new Set(["java", "kotlin", "javascript", "typescript", "python", "rust", "go", "c", "cpp"]); +const JS = new Set(["javascript", "typescript"]); + +interface PlainString { + quote: string; + language: string; +} + +function readEscape(text: string, at: number, language: string): { end: number; value: string } { + const rest = text.slice(at + 1); + const ch = rest[0]; + const basic: Record = { n: "\n", r: "\r", t: "\t", "\\": "\\", '"': '"', "'": "'" }; + if (language !== "rust") basic.b = "\b"; + if (!["kotlin", "rust"].includes(language)) basic.f = "\f"; + if (["python", "go", "c", "cpp"].includes(language)) basic.a = "\x07"; + if (!["java", "kotlin", "rust"].includes(language)) basic.v = "\v"; + if (language === "kotlin") basic.$ = "$"; + if (language === "java") basic.s = " "; + if (language === "c" || language === "cpp") basic["?"] = "?"; + if (ch && basic[ch] !== undefined) return { end: at + 2, value: basic[ch] }; + let numeric: RegExpExecArray | null = null; + let radix = 16; + if (ch === "u") { + if (language === "rust") numeric = /^u\{([\da-fA-F][\da-fA-F_]{0,6})\}/.exec(rest); + else if (JS.has(language)) numeric = /^u\{([\da-fA-F]+)\}/.exec(rest); + if (!numeric && language !== "rust") + numeric = (language === "java" ? /^u+([\da-fA-F]{4})/ : /^u([\da-fA-F]{4})/).exec(rest); + } else if (ch === "U" && ["python", "go", "c", "cpp"].includes(language)) { + numeric = /^U([\da-fA-F]{8})/.exec(rest); + } else if (ch === "x" && !["java", "kotlin"].includes(language)) { + numeric = (language === "c" || language === "cpp" ? /^x([\da-fA-F]+)/ : /^x([\da-fA-F]{2})/).exec(rest); + } else if (ch && /[0-7]/.test(ch)) { + radix = 8; + if (["python", "c", "cpp"].includes(language)) numeric = /^([0-7]{1,3})/.exec(rest); + else if (language === "java") numeric = /^([0-3][0-7]{0,2}|[4-7][0-7]?)/.exec(rest); + else if (language === "go") numeric = /^([0-7]{3})/.exec(rest); + else if (ch === "0" && (language === "rust" || (JS.has(language) && !/\d/.test(rest[1] ?? "")))) + return { end: at + 2, value: "\0" }; + } + if (numeric) { + const value = Number.parseInt(numeric[1]!.replaceAll("_", ""), radix); + const scalar = !(value >= 0xd800 && value <= 0xdfff); + const valid = + value <= 0x10ffff && + (!(language === "rust" || language === "go") || scalar) && + !(language === "rust" && ch === "x" && value > 0x7f) && + !(language === "go" && radix === 8 && value > 0xff); + if (valid) return { end: at + 1 + numeric[0].length, value: String.fromCodePoint(value) }; + } + if (ch === "\n" && !["java", "kotlin", "go"].includes(language)) { + const whitespace = language === "rust" ? /^\n\s*/.exec(rest)![0] : "\n"; + return { end: at + 1 + whitespace.length, value: "" }; + } + const end = Math.min(at + 2, text.length); + return { end, value: text.slice(at, end) }; +} + +function plainStringAt(state: EditorState, from: number, to: number, language: string): PlainString | null { + const tree = ensureSyntaxTree(state, to, 50); + if (!tree) return null; + for (let node = tree.resolveInner(from, 1); ;) { + if (["String", "StringLiteral", "string"].includes(node.name)) { + const text = state.sliceDoc(node.from, node.to); + const quote = text[0]; + if ( + (quote !== '"' && !(quote === "'" && (JS.has(language) || language === "python"))) || + text.length < 2 || + text.at(-1) !== quote || + text.startsWith(quote.repeat(3)) || + from < node.from + 1 || + to > node.to - 1 + ) + return null; + // Stream modes may merge adjacent tokens. Only accept one complete literal, + // and never split an existing escape sequence at either selection boundary. + for (let i = 1; i < text.length - 1; i++) { + if (text[i] === quote) return null; + if (text[i] !== "\\") continue; + const { end } = readEscape(text, i, language); + if (end > text.length - 1) return null; + if ([from, to].some((pos) => pos > node.from + i && pos < node.from + end)) return null; + i = end - 1; + } + return { quote, language }; + } + if (!node.parent) return null; + node = node.parent; + } +} + +function escapeText(text: string, literal: PlainString): string { + // oxlint-disable-next-line no-control-regex -- pasted control characters need literal escapes + return text.replace(/[\\"'\n\r\t\x00-\x1f\u2028\u2029$]/g, (ch) => { + if (ch === "\\" || ch === literal.quote || (ch === "$" && literal.language === "kotlin")) return `\\${ch}`; + if (ch === "\n") return "\\n"; + if (ch === "\r") return "\\r"; + if (ch === "\t") return "\\t"; + const code = ch.charCodeAt(0); + if (code < 32 || (JS.has(literal.language) && code >= 0x2028)) { + if (literal.language === "rust") return `\\u{${code.toString(16)}}`; + if (literal.language === "c" || literal.language === "cpp") return `\\${code.toString(8).padStart(3, "0")}`; + return `\\u${code.toString(16).padStart(4, "0")}`; + } + return ch; + }); +} + +function unescapeText(text: string, language: string): string { + let out = ""; + for (let i = 0; i < text.length; i++) { + if (text[i] !== "\\") out += text[i]; + else { + const escape = readEscape(text, i, language); + out += escape.value; + i = escape.end - 1; + } + } + return out; +} + +export function stringClipboard(language: string | undefined): Extension { + if (!language || !LANGUAGES.has(language)) return []; + return [ + // Transaction filters run in reverse precedence: escape before paste reindentation. + Prec.lowest( + EditorState.transactionFilter.of((tr) => { + if (!tr.docChanged || !tr.isUserEvent("input.paste") || tr.startState.readOnly) return tr; + const state = tr.startState; + const literals = state.selection.ranges.map((r) => plainStringAt(state, r.from, r.to, language)); + if (literals.some((literal) => !literal)) return tr; + const corrections: { from: number; to: number; insert: string }[] = []; + let valid = true; + tr.changes.iterChanges((from, to, fromB, toB, inserted) => { + const index = state.selection.ranges.findIndex((r) => r.from === from && r.to === to); + if (index < 0) { + valid = false; + return; + } + const text = inserted.toString(); + const escaped = escapeText(text, literals[index]!); + if (escaped !== text) corrections.push({ from: fromB, to: toB, insert: escaped }); + }); + if (!valid || !corrections.length) return tr; + const changes = ChangeSet.of(corrections, tr.newDoc.length, state.lineBreak); + return [tr, { changes, selection: tr.newSelection.map(changes, 1), sequential: true }]; + }), + ), + EditorView.clipboardOutputFilter.of((text, state) => { + const ranges = state.selection.ranges; + if (ranges.some((r) => r.empty || !plainStringAt(state, r.from, r.to, language))) return text; + const selected = ranges.map((r) => state.sliceDoc(r.from, r.to)); + if (text !== selected.join(state.lineBreak)) return text; + return selected.map((part) => unescapeText(part, language)).join(state.lineBreak); + }), + ]; +} diff --git a/src/editor/core/controller.ts b/src/editor/core/controller.ts index 217a8de1f..7073fdf13 100644 --- a/src/editor/core/controller.ts +++ b/src/editor/core/controller.ts @@ -752,6 +752,7 @@ export class EditorController implements SlotHost { { label: "Copy / Paste Special", submenu: [ + { kind: "action", action: "editor.copyPathReference" }, { kind: "action", action: "editor.copyReference" }, { kind: "action", action: "code.pasteAsPlainText" }, { kind: "action", action: "code.pasteFromHistory" }, diff --git a/src/editor/core/copyPathPopup.test.tsx b/src/editor/core/copyPathPopup.test.tsx new file mode 100644 index 000000000..6560c8d06 --- /dev/null +++ b/src/editor/core/copyPathPopup.test.tsx @@ -0,0 +1,244 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { executeAction, getAction, isActionKeyEnabled, registerAction } from "@/platform/actions"; +import { useContextKeys } from "@/platform/context"; +import { installKeyboardDispatcher, shortcutLabel, useKeymap } from "@/platform/keymap"; +import { useProject } from "@/platform/project"; +import { useProjectRoots } from "@/platform/projectRoots"; +import { closePopup, openPopup, useLspPopup } from "@/editor/lsp/popupStore"; +import { LspPopups } from "@/editor/lsp/Popups"; +import { registerProjectActions } from "@/features/project-tree/actions"; +import { useProjectTree } from "@/features/project-tree/store"; +import { useContextMenuStore } from "@/ui"; +import { provideNotificationService } from "@/workbench/contributions"; +import * as controller from "./controller"; +import { createMemoryBackend } from "./backend"; +import { registerEditorActions } from "./actions"; +import { useOutline } from "./model/document"; +import { initialTabsState } from "./model/tabs"; + +const path = "/p/src/A.java"; +const documentText = "package com.acme;\nclass A {}\n"; +const writeText = vi.fn(async (_text: string) => undefined); +const hint = vi.fn(); +const notify = vi.fn(); +const disposers: Array<() => void> = []; +let ctrl: controller.EditorController; +let host: HTMLDivElement; + +const popup = () => { + const value = useLspPopup.getState().popup; + if (value?.kind !== "list") throw new Error("Expected copy chooser"); + return value; +}; + +beforeEach(async () => { + vi.clearAllMocks(); + vi.stubGlobal("navigator", { clipboard: { writeText }, userAgent: navigator.userAgent }); + vi.stubGlobal("ClipboardItem", undefined); + provideNotificationService({ notify }); + useProject.setState({ project: { name: "p", root: "/p", settings: {} } as never, settings: {} as never }); + useProjectRoots.setState({ root: "/p", detected: [{ path: "src", kind: "source", detectedFrom: null }] }); + useProjectTree.setState({ root: "/p" }); + useKeymap.setState({ preset: "intellij-macos", overrides: {} }); + ctrl = new controller.EditorController(createMemoryBackend({ [path]: documentText })); + vi.spyOn(controller, "editorController").mockReturnValue(ctrl); + await ctrl.openFile(path, { focus: false }); + host = document.createElement("div"); + document.body.appendChild(host); + const view = ctrl.activeSlot!.mount(host); + ctrl.onMounted(ctrl.activeSlot!); + view.dispatch({ selection: { anchor: documentText.indexOf("A {}") } }); + view.focus(); + useOutline.setState({ + [path]: [ + { + name: "A", + kind: "class", + detail: null, + children: [], + range: { start: { line: 1, col: 0 }, end: { line: 1, col: 10 } }, + selection_range: { start: { line: 1, col: 6 }, end: { line: 1, col: 7 } }, + }, + ], + }); + useContextKeys.setState( + { textEditorActive: true, editorFocus: true, editorTextFocus: true, projectOpen: true }, + true, + ); + disposers.push( + registerEditorActions(), + registerProjectActions(), + registerAction({ id: "editor.showHint", title: "Hint", run: ({ arg }) => hint(arg) }), + ); +}); + +afterEach(() => { + act(() => closePopup()); + disposers + .splice(0) + .reverse() + .forEach((dispose) => dispose()); + ctrl.stop(); + host.remove(); + controller.useTabs.setState(initialTabsState(), true); + useProject.setState({ project: null, settings: null }); + useProjectRoots.setState({ root: null, detected: [] }); + useProjectTree.setState({ root: null }); + useContextMenuStore.setState({ menu: null }); + useContextKeys.setState({}, true); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe("editor Copy Path/Reference chooser", () => { + it("keeps the old action available as Absolute Path with a copied hint", async () => { + expect(getAction("editor.copyPath")!.title).toBe("Absolute Path"); + await executeAction("editor.copyPath"); + expect(writeText).toHaveBeenCalledExactlyOnceWith(path); + expect(useLspPopup.getState().popup).toBeNull(); + expect(hint).toHaveBeenCalledWith({ text: `Copied ${path}` }); + }); + + it("opens at the caret without copying, with every preview and assigned shortcut", async () => { + vi.spyOn(ctrl.activeSlot!.view!, "coordsAtPos").mockReturnValue({ left: 120, right: 121, top: 80, bottom: 100 }); + useKeymap.setState({ overrides: { "project.copyFileName": ["Meta+Alt+N"] } }); + await executeAction("editor.copyPathReference"); + await vi.waitFor(() => expect(popup().items.at(-1)?.disabled).toBeNull()); + expect(writeText).not.toHaveBeenCalled(); + expect(popup().anchor).toEqual({ x: 120, y: 100 }); + expect(popup().items.map(({ label, preview }) => [label, preview])).toEqual([ + ["Absolute Path", path], + ["File Name", "A.java"], + ["Path From Content Root", "src/A.java"], + ["Path From Source Root", "A.java"], + ["Path with line number", "src/A.java:2"], + ["Reference", "com.acme.A"], + ]); + for (const item of popup().items) expect(item.shortcut).toBe(shortcutLabel(item.id)); + expect(popup().items[1]!.shortcut).toBeTruthy(); + }); + + it.each([ + "project.copyAbsolutePath", + "project.copyFileName", + "project.copyRelativePath", + "project.copySourceRootPath", + "editor.copyPathWithLineNumber", + "editor.copyReference", + ])("copies the preview through %s and confirms it", async (id) => { + await executeAction("editor.copyPathReference"); + await vi.waitFor(() => expect(popup().items.at(-1)?.disabled).toBeNull()); + const item = popup().items.find((item) => item.id === id)!; + await item.run(); + await vi.waitFor(() => expect(writeText).toHaveBeenCalledExactlyOnceWith(item.preview)); + await vi.waitFor(() => + expect(hint).toHaveBeenCalledWith({ + text: id === "editor.copyReference" ? `Copied reference: ${item.preview}` : `Copied ${item.preview}`, + }), + ); + }); + + it("omits source paths outside known roots and supports loose files", async () => { + useProjectRoots.setState({ detected: [{ path: "other", kind: "source", detectedFrom: null }] }); + await executeAction("editor.copyPathReference"); + expect(popup().items.some((item) => item.id === "project.copySourceRootPath")).toBe(false); + useProject.setState({ project: null, settings: null }); + useProjectTree.setState({ root: null }); + useContextKeys.setState({ projectOpen: false }); + await executeAction("editor.copyPathReference"); + expect(popup().items.find((item) => item.id === "editor.copyPathWithLineNumber")?.preview).toBe(`${path}:2`); + await popup() + .items.find((item) => item.id === "project.copyRelativePath")! + .run(); + expect(writeText).toHaveBeenCalledExactlyOnceWith(path); + }); + + it("uses path:line off a symbol and ignores stale reference resolution", async () => { + ctrl.activeSlot!.view!.dispatch({ selection: { anchor: documentText.length - 1 } }); + await executeAction("editor.copyPathReference"); + await vi.waitFor(() => expect(popup().items.at(-1)?.preview).toBe("src/A.java:2")); + void executeAction("editor.copyPathReference"); + closePopup(); + openPopup({ kind: "list", title: "Other", anchor: { x: 1, y: 1 }, items: [] }); + await Promise.resolve(); + expect(popup().title).toBe("Other"); + expect(popup().items).toEqual([]); + }); + + it("copies the reference preview even if the caret moves afterwards", async () => { + await executeAction("editor.copyPathReference"); + await vi.waitFor(() => expect(popup().items.at(-1)?.disabled).toBeNull()); + const reference = popup().items.at(-1)!; + ctrl.activeSlot!.view!.dispatch({ selection: { anchor: 0 } }); + await reference.run(); + await vi.waitFor(() => expect(writeText).toHaveBeenCalledExactlyOnceWith("com.acme.A")); + }); + + it("supports keyboard selection and Escape through the existing list popup", async () => { + await executeAction("editor.copyPathReference"); + render(); + expect(screen.getAllByRole("option")).toHaveLength(6); + expect(screen.getByTitle(path)).toBeInTheDocument(); + fireEvent.keyDown(screen.getByRole("listbox"), { key: "ArrowDown" }); + fireEvent.keyDown(screen.getByRole("listbox"), { key: "Enter" }); + await vi.waitFor(() => expect(writeText).toHaveBeenCalledExactlyOnceWith("A.java")); + expect(useLspPopup.getState().popup).toBeNull(); + await act(() => executeAction("editor.copyPathReference")); + fireEvent.keyDown(screen.getByRole("listbox"), { key: "Escape" }); + expect(useLspPopup.getState().popup).toBeNull(); + }); + + it("adds the chooser to Copy / Paste Special", () => { + vi.spyOn(ctrl.activeSlot!.view!, "posAtCoords").mockReturnValue(null); + ctrl.onContextMenu(ctrl.activeSlot!, new MouseEvent("contextmenu")); + const special = useContextMenuStore + .getState() + .menu!.items.find((item) => "label" in item && item.label === "Copy / Paste Special"); + expect(special && "submenu" in special && special.submenu).toContainEqual({ + kind: "action", + action: "editor.copyPathReference", + }); + }); + + it.each(["intellij-macos", "intellij-windows"])("routes %s shortcuts by focus", (preset) => { + useKeymap.setState({ preset }); + disposers.push(installKeyboardDispatcher(window)); + const key = () => + new KeyboardEvent("keydown", { + key: "C", + code: "KeyC", + shiftKey: true, + metaKey: preset.endsWith("macos"), + ctrlKey: preset.endsWith("windows"), + bubbles: true, + cancelable: true, + }); + window.dispatchEvent(key()); + expect(useLspPopup.getState().popup?.kind).toBe("list"); + expect(writeText).not.toHaveBeenCalled(); + closePopup(); + useContextKeys.setState({ + editorFocus: false, + editorTextFocus: false, + toolWindowFocus: true, + projectViewFocus: true, + }); + expect(isActionKeyEnabled(getAction("editor.copyPathReference")!)).toBe(false); + expect(isActionKeyEnabled(getAction("project.copyAbsolutePath")!)).toBe(true); + useContextKeys.setState({ projectViewFocus: false, terminalFocus: true }); + const terminalKey = key(); + window.dispatchEvent(terminalKey); + expect(terminalKey.defaultPrevented).toBe(false); + expect(useLspPopup.getState().popup).toBeNull(); + expect(writeText).not.toHaveBeenCalled(); + }); + + it("reports clipboard failures without a copied hint", async () => { + writeText.mockRejectedValueOnce(new Error("Clipboard denied")); + await executeAction("editor.copyPathReference"); + await popup().items[0]!.run(); + await vi.waitFor(() => expect(notify).toHaveBeenCalledWith(expect.objectContaining({ level: "error" }))); + expect(hint).not.toHaveBeenCalled(); + }); +}); diff --git a/src/editor/core/extras.test.ts b/src/editor/core/extras.test.ts new file mode 100644 index 000000000..83917c32e --- /dev/null +++ b/src/editor/core/extras.test.ts @@ -0,0 +1,98 @@ +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { EditorState } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +import { indentService, indentUnit } from "@codemirror/language"; +import { openPopup } from "@/editor/lsp/popupStore"; +import { executeAction } from "@/platform/actions"; +import { clearClipboardHistory, clipboardHistory, recordClipboard } from "./clipboardHistory"; +import { pasteAsPlainText, pasteFromHistory } from "./extras"; +import { pasteReindentFilter } from "./cm/prefs"; +import { openPasteHistory } from "./view/PasteHistoryDialog"; + +vi.mock("./view/PasteHistoryDialog", () => ({ openPasteHistory: vi.fn() })); +vi.mock("@/platform/actions", () => ({ executeAction: vi.fn() })); + +vi.mock("@/editor/lsp/popupStore", () => ({ + caretAnchor: vi.fn(() => ({ x: 0, y: 0 })), + openPopup: vi.fn(), +})); + +let view: EditorView; +const extra: EditorView[] = []; +beforeEach(() => { + clearClipboardHistory(); + vi.clearAllMocks(); + view = new EditorView({ state: EditorState.create({ doc: "target" }), parent: document.body }); +}); +afterEach(() => { + view.destroy(); + view.dom.remove(); + extra.splice(0).forEach((v) => v.destroy()); +}); + +it("opens the chooser and pastes its result through the existing paste path", () => { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, "clipboard", { configurable: true, value: { writeText } }); + recordClipboard("older"); + recordClipboard("newer\nsecond line"); + view.dispatch({ selection: { anchor: 0, head: 6 } }); + pasteFromHistory(view); + expect(openPopup).not.toHaveBeenCalled(); + const [target, paste] = vi.mocked(openPasteHistory).mock.calls[0]!; + expect(target).toBe(view); + const joined = "newer\nsecond line\nolder"; + paste(joined, false); + expect(view.state.doc.toString()).toBe(joined); + expect(clipboardHistory()[0]).toEqual({ text: joined, linewise: false }); + expect(writeText).toHaveBeenCalledWith(joined); + expect(view.hasFocus).toBe(true); +}); + +it("pastes a chosen line-wise entry above the caret line and keeps its flag", () => { + Object.defineProperty(navigator, "clipboard", { configurable: true, value: { writeText: vi.fn(async () => {}) } }); + recordClipboard("one", true); + recordClipboard("selected text"); + view.dispatch({ selection: { anchor: 3 } }); + pasteFromHistory(view); + const [, paste] = vi.mocked(openPasteHistory).mock.calls[0]!; + paste("one", true); + expect(view.state.doc.toString()).toBe("one\ntarget"); + expect(view.state.selection.main.head).toBe(7); + expect(clipboardHistory()[0]).toEqual({ text: "one", linewise: true }); +}); + +it("keeps the existing empty-history hint", () => { + pasteFromHistory(view); + expect(openPasteHistory).not.toHaveBeenCalled(); + expect(executeAction).toHaveBeenCalledWith("editor.showHint", { + text: "Nothing to paste: the clipboard history is empty", + }); +}); + +it.each(["indentBlock", "indentEachLine"] as const)( + "pastes line-wise as plain text without %s reformatting", + async (mode) => { + const text = " a();\n b();"; + Object.defineProperty(navigator, "clipboard", { configurable: true, value: { readText: async () => text } }); + const v = new EditorView({ + doc: "if {\n target\n}", + selection: { anchor: 10 }, + extensions: [indentUnit.of(" "), indentService.of((_cx, pos) => (pos === 0 ? 0 : 2)), pasteReindentFilter(mode)], + parent: document.body, + }); + extra.push(v); + recordClipboard(text, true); + await pasteAsPlainText(v); + expect(v.state.doc.toString()).toBe(`if {\n${text}\n target\n}`); + expect(v.state.selection.main.head).toBe(10 + text.length + 1); + }, +); + +it.each([true, false])("handles an empty plain-text clipboard with line-wise flag %s", async (linewise) => { + Object.defineProperty(navigator, "clipboard", { configurable: true, value: { readText: async () => "" } }); + if (linewise) recordClipboard("", true); + view.dispatch({ selection: { anchor: 3 } }); + await pasteAsPlainText(view); + expect(view.state.doc.toString()).toBe(linewise ? "\ntarget" : "target"); + expect(view.state.selection.main.head).toBe(linewise ? 4 : 3); +}); diff --git a/src/editor/core/extras.ts b/src/editor/core/extras.ts index e5fb27fce..0c46fe72a 100644 --- a/src/editor/core/extras.ts +++ b/src/editor/core/extras.ts @@ -5,7 +5,8 @@ import { structure } from "@/editor/structure"; 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 { clipboardHistory, isLinewiseClipboardText, recordClipboard } from "./clipboardHistory"; +import { openPasteHistory } from "./view/PasteHistoryDialog"; import { dispatchPaste } from "./cm/clipboard"; import { unwrapOptions, type ChainNode, type UnwrapOption } from "./cm/unwrap"; import type { EditorSlot } from "./model/slot"; @@ -25,29 +26,10 @@ export function pasteFromHistory(view: EditorView) { hint("Nothing to paste: the clipboard history is empty"); return; } - openPopup({ - kind: "list", - anchor: caretAnchor(view, view.state.selection.main.head), - title: "Choose Content to Paste", - items: items.map((text, i) => { - const { label, detail } = clipLabel(text); - return { - id: `${i}`, - label, - detail, - preview: - text - .split(/\r\n?|\n/) - .slice(1, 4) - .join(" ⏎ ") - .trim() || undefined, - run: () => { - recordClipboard(text); - void navigator.clipboard?.writeText(text).catch(() => {}); - pasteText(view, text); - }, - }; - }), + openPasteHistory(view, (text, linewise) => { + recordClipboard(text, linewise); + void navigator.clipboard?.writeText(text).catch(() => {}); + pasteText(view, text); }); } @@ -125,7 +107,7 @@ export async function pasteAsPlainText(view: EditorView) { hint("Cannot read the clipboard"); return; } - if (!text) return; + if (!text && !isLinewiseClipboardText(text)) return; dispatchPaste(view, text, "input.pastePlain"); view.focus(); } diff --git a/src/editor/core/index.ts b/src/editor/core/index.ts index 479033b8e..21ae6ec03 100644 --- a/src/editor/core/index.ts +++ b/src/editor/core/index.ts @@ -20,6 +20,7 @@ import { installColorScheme } from "./colorSchemeRuntime"; import { documentBanner } from "./view/Banner"; import { EditorAreaView } from "./view/EditorArea"; import { GotoLineDialog } from "./view/GotoLineDialog"; +import { PasteHistoryDialogHost, registerPasteHistoryActions } from "./view/PasteHistoryDialog"; import { NavBarPopup } from "./view/NavBarPopup"; import { installClipboardTracking } from "./clipboardHistory"; import { @@ -65,8 +66,10 @@ export function activate(): void | (() => void) { installColorScheme(), provideEditorArea(EditorAreaView), registerEditorActions(), + registerPasteHistoryActions(), registerEditorBanner(documentBanner), registerOverlay({ id: "editor.gotoLine", component: GotoLineDialog }), + registerOverlay({ id: "editor.pasteHistory", component: PasteHistoryDialogHost }), registerOverlay({ id: "editor.navigationBar", component: NavBarPopup }), installClipboardTracking(), registerStatusItem({ diff --git a/src/editor/core/view/PasteHistoryDialog.module.css b/src/editor/core/view/PasteHistoryDialog.module.css new file mode 100644 index 000000000..7acfe3e70 --- /dev/null +++ b/src/editor/core/view/PasteHistoryDialog.module.css @@ -0,0 +1,49 @@ +.dialog { + height: min(580px, calc(100vh - 48px)); +} + +.dialog [data-dialog-body] { + display: flex; + flex-direction: column; + overflow: hidden; +} + +.body { + display: flex; + flex-direction: column; + gap: var(--space-2); + flex: 1; + min-height: 0; + padding: var(--space-3); +} + +.list { + flex: 2; + min-height: 84px; +} + +.label { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + font-family: var(--font-mono); +} + +.detail { + flex: none; + color: var(--fg-secondary); + font-size: var(--fs-small); +} + +.preview { + flex: 3; + min-height: 0; + overflow: hidden; + border: 1px solid var(--border-strong); + border-radius: var(--radius-small); +} + +.preview > :global(.cm-editor) { + height: 100%; +} diff --git a/src/editor/core/view/PasteHistoryDialog.test.tsx b/src/editor/core/view/PasteHistoryDialog.test.tsx new file mode 100644 index 000000000..20c543be1 --- /dev/null +++ b/src/editor/core/view/PasteHistoryDialog.test.tsx @@ -0,0 +1,202 @@ +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { EditorState, StateEffect } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +import { language } from "@codemirror/language"; +import { javascript } from "@codemirror/lang-javascript"; +import { executeAction } from "@/platform/actions"; +import { getContext } from "@/platform/context"; +import { applyKeymapSettings, installKeyboardDispatcher } from "@/platform/keymap"; +import { clearClipboardHistory, clipboardHistory, recordClipboard } from "../clipboardHistory"; +import { compartments, fontExtension } from "../cm/setup"; +import { PasteHistoryDialogHost, openPasteHistory, registerPasteHistoryActions } from "./PasteHistoryDialog"; + +let view: EditorView; +let paste: ReturnType void>>; +let disposeActions: () => void; +beforeEach(() => { + disposeActions = registerPasteHistoryActions(); + applyKeymapSettings(undefined, undefined); + clearClipboardHistory(); + paste = vi.fn(); + view = new EditorView({ + parent: document.body, + state: EditorState.create({ + doc: "target", + extensions: [javascript(), compartments.font.of(fontExtension({ fontSize: 17 }))], + }), + }); +}); +afterEach(() => { + if (screen.queryByRole("dialog")) fireEvent.keyDown(document.activeElement!, { key: "Escape" }); + disposeActions(); + view.destroy(); + view.dom.remove(); +}); + +function open(...entries: string[]) { + for (const entry of entries.toReversed()) recordClipboard(entry); + view.focus(); + render(); + act(() => openPasteHistory(view, paste)); + return screen.getByRole("textbox", { name: "Filter clipboard history" }); +} + +function options() { + return screen.getAllByRole("option"); +} + +function chosen() { + return options().filter((row) => row.getAttribute("aria-selected") === "true"); +} + +describe("Paste from History chooser", () => { + it("shows newest first and a read-only preview with the target grammar and editor font", () => { + open("const newest = 1;\n// detail", "older"); + expect(options().map((row) => row.textContent)).toEqual(["const newest = 1;2 lines", "older1 line"]); + const content = screen.getByLabelText("Paste preview"); + expect(content).toHaveAttribute("contenteditable", "false"); + expect(content).toHaveAttribute("tabindex", "0"); + const preview = EditorView.findFromDOM(content)!; + expect(preview.state.readOnly).toBe(true); + expect(preview.state.facet(language)?.name).toBe("javascript"); + expect(preview.dom.style.getPropertyValue("--openidea-editor-font-size")).toBe("17px"); + expect(preview.state.doc.toString()).toBe("const newest = 1;\n// detail"); + expect(content.querySelector("span")).not.toBeNull(); + fireEvent.click(options()[1]!); + expect(EditorView.findFromDOM(screen.getByLabelText("Paste preview"))!.state.doc.toString()).toBe("older"); + expect(paste).not.toHaveBeenCalled(); + }); + + it("filters full contents as you type and resets selection, including empty results", () => { + const search = open("newest", "first line\nHidden Needle", "oldest"); + fireEvent.change(search, { target: { value: "needle" } }); + expect(options()).toHaveLength(1); + expect(options()[0]).toHaveTextContent("first line"); + expect(chosen()).toHaveLength(1); + fireEvent.change(search, { target: { value: "absent" } }); + expect(screen.queryByRole("option")).not.toBeInTheDocument(); + expect(screen.getByText("No matches")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Paste" })).toBeDisabled(); + fireEvent.keyDown(search, { key: "Enter" }); + expect(paste).not.toHaveBeenCalled(); + fireEvent.change(search, { target: { value: "" } }); + expect(options()).toHaveLength(3); + expect(chosen()[0]).toBe(options()[0]); + }); + + it("pastes Cmd-click selections in list order and restores editor focus", async () => { + open("newest", "middle", "oldest"); + fireEvent.click(options()[2]!); + fireEvent.click(options()[0]!, { metaKey: true }); + expect(chosen()).toEqual([options()[0], options()[2]]); + fireEvent.click(screen.getByRole("button", { name: "Paste" })); + expect(paste).toHaveBeenCalledExactlyOnceWith("newest\noldest", false); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + await waitFor(() => expect(view.hasFocus).toBe(true)); + }); + + it("extends and shrinks selection with Shift+Arrow from the search field", () => { + const search = open("newest", "middle", "oldest"); + fireEvent.keyDown(search, { key: "ArrowDown", shiftKey: true }); + fireEvent.keyDown(search, { key: "ArrowDown", shiftKey: true }); + expect(chosen()).toHaveLength(3); + fireEvent.keyDown(search, { key: "ArrowUp", shiftKey: true }); + expect(chosen()).toHaveLength(2); + fireEvent.keyDown(search, { key: "Enter" }); + expect(paste).toHaveBeenCalledExactlyOnceWith("newest\nmiddle", false); + }); + + it("supports Shift-click and keyboard navigation in the list", () => { + open("newest", "middle", "oldest"); + fireEvent.click(options()[2]!, { shiftKey: true }); + expect(chosen()).toHaveLength(3); + const list = screen.getByRole("listbox"); + fireEvent.keyDown(list, { key: "ArrowUp", shiftKey: true }); + expect(chosen()).toHaveLength(2); + fireEvent.keyDown(list, { key: "Enter" }); + expect(paste).toHaveBeenCalledExactlyOnceWith("newest\nmiddle", false); + }); + + it("deletes selected entries, selects a neighbour, and handles removing the last entry", () => { + const search = open("newest", "middle", "oldest"); + fireEvent.click(options()[2]!, { metaKey: true }); + fireEvent.keyDown(search, { key: "Delete" }); + expect(clipboardHistory().map((e) => e.text)).toEqual(["middle"]); + expect(chosen()[0]).toHaveTextContent("middle"); + fireEvent.keyDown(search, { key: "Backspace" }); + expect(clipboardHistory().map((e) => e.text)).toEqual([]); + expect(screen.getByText("Nothing to paste: the clipboard history is empty")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Paste" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Delete" })).toBeDisabled(); + expect(paste).not.toHaveBeenCalled(); + }); + + it("deletes only matching selections while allowing Backspace to edit the filter", () => { + const search = open("keep", "delete this", "also keep"); + fireEvent.change(search, { target: { value: "delete" } }); + const event = new KeyboardEvent("keydown", { key: "Backspace", bubbles: true, cancelable: true }); + fireEvent(search, event); + expect(event.defaultPrevented).toBe(false); + expect(clipboardHistory()).toHaveLength(3); + fireEvent.keyDown(search, { key: "Delete" }); + expect(clipboardHistory().map((e) => e.text)).toEqual(["keep", "also keep"]); + fireEvent.change(search, { target: { value: "" } }); + expect(chosen()[0]).toHaveTextContent("keep"); + }); + + it("pastes on double-click while single-click only selects", () => { + open("newest", "oldest"); + fireEvent.click(options()[1]!, { detail: 1 }); + expect(paste).not.toHaveBeenCalled(); + fireEvent.click(options()[1]!, { detail: 2 }); + expect(paste).toHaveBeenCalledExactlyOnceWith("oldest", false); + }); + + it("returns focus on Escape without pasting and ignores IME commit keys", async () => { + const search = open("newest", "oldest"); + fireEvent.keyDown(search, { key: "Enter", keyCode: 229 }); + expect(paste).not.toHaveBeenCalled(); + expect(screen.getByRole("dialog")).toBeInTheDocument(); + fireEvent.keyDown(search, { key: "Escape" }); + await waitFor(() => expect(view.hasFocus).toBe(true)); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + expect(paste).not.toHaveBeenCalled(); + }); + + it("scopes chooser actions and default shortcuts to the dialog", async () => { + const disposeKeyboard = installKeyboardDispatcher(); + try { + expect(await executeAction("editor.pasteHistory.remove")).toBe(false); + open("newest", "oldest"); + const list = screen.getByRole("listbox"); + act(() => list.focus()); + fireEvent.keyDown(list, { key: "Backspace" }); + expect(clipboardHistory().map((e) => e.text)).toEqual(["oldest"]); + fireEvent.keyDown(list, { key: "Enter" }); + expect(paste).toHaveBeenCalledExactlyOnceWith("oldest", false); + expect(getContext().pasteHistoryOpen).toBe(false); + expect(getContext().pasteHistoryListFocus).toBe(false); + } finally { + disposeKeyboard(); + } + }); + + it("resets the chooser when opened again while already shown", () => { + const search = open("newest", "oldest"); + fireEvent.change(search, { target: { value: "oldest" } }); + act(() => openPasteHistory(view, paste)); + const reopened = screen.getByRole("textbox", { name: "Filter clipboard history" }); + expect(reopened).toHaveValue(""); + fireEvent.keyDown(reopened, { key: "Enter" }); + expect(paste).toHaveBeenCalledExactlyOnceWith("newest", false); + }); + + it.each(["read-only", "closed"])("does not paste into an editor that became %s", (state) => { + const search = open("newest"); + if (state === "read-only") view.dispatch({ effects: StateEffect.appendConfig.of(EditorState.readOnly.of(true)) }); + else view.dom.remove(); + fireEvent.keyDown(search, { key: "Enter" }); + expect(paste).not.toHaveBeenCalled(); + }); +}); diff --git a/src/editor/core/view/PasteHistoryDialog.tsx b/src/editor/core/view/PasteHistoryDialog.tsx new file mode 100644 index 000000000..570074bbd --- /dev/null +++ b/src/editor/core/view/PasteHistoryDialog.tsx @@ -0,0 +1,243 @@ +import { useEffect, useLayoutEffect, useRef, useState, type KeyboardEvent } from "react"; +import { create } from "zustand"; +import { EditorState } from "@codemirror/state"; +import { EditorView, highlightSpecialChars, lineNumbers } from "@codemirror/view"; +import { language, syntaxHighlighting } from "@codemirror/language"; +import { tx } from "@/platform/i18n"; +import { isImeKey } from "@/platform/ime"; +import { executeAction, registerActions } from "@/platform/actions"; +import { setContext } from "@/platform/context"; +import { bindingsFor, normalizeBinding, strokeOf } from "@/platform/keymap"; +import { + Button, + Dialog, + List, + SearchField, + SINGLE_FIRST, + T, + isRowSelected, + listNavigate, + rowSelectionMode, + selectRow, + selectableText, + selectedRows, +} from "@/ui"; +import { clipLabel, clipboardHistory, removeClipboardEntries } from "../clipboardHistory"; +import { openideaHighlightStyle } from "../cm/highlight"; +import { compartments } from "../cm/setup"; +import { openideaTheme } from "../cm/theme"; +import styles from "./PasteHistoryDialog.module.css"; + +interface Request { + view: EditorView; + paste: (text: string, linewise: boolean) => void; +} + +const usePasteHistory = create<{ request: Request | null; seq: number }>(() => ({ request: null, seq: 0 })); +let commands: { paste: (text?: string) => void; remove: () => void } | null = null; + +export function registerPasteHistoryActions() { + return registerActions([ + { + id: "editor.pasteHistory.paste", + title: "Paste", + hidden: true, + when: "pasteHistoryOpen", + keyWhen: "pasteHistoryListFocus", + run: ({ arg }) => commands?.paste(typeof arg === "string" ? arg : undefined), + }, + { + id: "editor.pasteHistory.remove", + title: "Delete", + hidden: true, + when: "pasteHistoryOpen", + keyWhen: "pasteHistoryListFocus", + run: () => commands?.remove(), + }, + ]); +} + +export function openPasteHistory(view: EditorView, paste: Request["paste"]) { + usePasteHistory.setState((s) => ({ request: { view, paste }, seq: s.seq + 1 })); +} + +export function PasteHistoryDialogHost() { + const { request, seq } = usePasteHistory(); + if (!request) return null; + return ( + { + usePasteHistory.setState({ request: null }); + queueMicrotask(() => { + if (request.view.dom.isConnected) request.view.focus(); + }); + }} + /> + ); +} + +function PasteHistoryDialog({ view, paste, onClose }: Request & { onClose: () => void }) { + const [entries, setEntries] = useState(clipboardHistory); + const [query, setQuery] = useState(""); + const [selection, setSelection] = useState(SINGLE_FIRST); + const search = useRef(null); + const rows = entries + .filter(({ text }) => text.toLocaleLowerCase().includes(query.toLocaleLowerCase())) + .map(({ text, linewise }) => ({ key: text, linewise, ...clipLabel(text) })); + const selected = selectedRows(rows, selection); + const preview = rows[selection.lead]?.key ?? ""; + + const submit = (only?: string) => { + const chosen = only === undefined ? selected : rows.filter((row) => row.key === only); + const text = chosen.map((row) => row.key).join("\n"); + if (!chosen.length || !view.dom.isConnected || view.state.readOnly) return; + onClose(); + // A single whole-line entry keeps pasting as whole lines; a merge pastes as text. + paste(text, chosen.length === 1 && chosen[0]!.linewise); + }; + const remove = () => { + removeClipboardEntries(selected.map((row) => row.key)); + const remaining = rows.filter((row) => !selected.some((s) => s.key === row.key)); + const lead = Math.max(0, Math.min(selection.lead, remaining.length - 1)); + setEntries(clipboardHistory()); + setSelection({ lead, anchor: lead, keys: null }); + search.current?.focus(); + }; + const filter = (value: string) => { + setQuery(value); + setSelection(SINGLE_FIRST); + }; + useLayoutEffect(() => { + commands = { paste: submit, remove }; + return () => { + commands = null; + }; + }); + useEffect(() => { + setContext("pasteHistoryOpen", true); + return () => { + setContext("pasteHistoryOpen", false); + setContext("pasteHistoryListFocus", false); + }; + }, []); + const runPaste = (text?: string) => void executeAction("editor.pasteHistory.paste", text); + const runRemove = () => void executeAction("editor.pasteHistory.remove"); + const onKeyDown = (e: KeyboardEvent) => { + if (e.defaultPrevented || isImeKey(e.nativeEvent)) return; + const inSearch = e.target === search.current; + const inList = e.target instanceof Element && !!e.target.closest('[role="listbox"]'); + if (!inSearch && !inList) return; + const stroke = strokeOf(e.nativeEvent); + const bound = (id: string) => bindingsFor(id).some((binding) => normalizeBinding(binding) === stroke); + if (bound("editor.pasteHistory.paste")) { + e.preventDefault(); + runPaste(); + } else if (bound("editor.pasteHistory.remove") && (!inSearch || !query || e.key !== "Backspace")) { + e.preventDefault(); + runRemove(); + } else if (inSearch && ["ArrowUp", "ArrowDown", "PageUp", "PageDown"].includes(e.key)) { + const next = listNavigate(e.key, { items: rows, active: selection.lead }); + if (next !== null) { + e.preventDefault(); + setSelection(selectRow(rows, selection, next, rowSelectionMode(e))); + } + } else if (inList && e.key.length === 1 && !e.metaKey && !e.ctrlKey && !e.altKey) { + e.preventDefault(); + filter(query + e.key); + search.current?.focus(); + } + }; + + return ( + runPaste(), + disabled: !selected.length || view.state.readOnly, + }, + { label: "Cancel", role: "cancel" }, + ]} + footerStart={ + + } + > +
setContext("pasteHistoryListFocus", !!e.target.closest('[role="listbox"]'))} + onBlurCapture={() => setContext("pasteHistoryListFocus", false)} + > + + row.key} + activeIndex={selection.lead} + onSelect={(index, e) => setSelection(selectRow(rows, selection, index, rowSelectionMode(e)))} + isSelected={(row) => isRowSelected(selection, row.key)} + onActivate={(row, _index, e) => { + if (e.type === "keydown") runPaste(); + else if (e.detail === 2) runPaste(row.key); + }} + rowHeight={28} + className={styles.list} + aria-label={tx("Clipboard history")} + empty={{entries.length ? "No matches" : "Nothing to paste: the clipboard history is empty"}} + renderItem={(row) => ( + <> + {row.label} + {row.detail ?? tx("1 line")} + + )} + /> + +
+
+ ); +} + +function PastePreview({ source, text }: { source: EditorView; text: string }) { + const ref = useRef(null); + useEffect(() => { + const preview = new EditorView({ + parent: ref.current!, + state: EditorState.create({ + doc: text, + extensions: [ + openideaTheme, + syntaxHighlighting(openideaHighlightStyle), + source.state.facet(language)?.extension ?? [], + compartments.font.get(source.state) ?? [], + EditorState.tabSize.of(source.state.tabSize), + EditorState.readOnly.of(true), + EditorView.editable.of(false), + EditorView.contentAttributes.of({ "aria-label": tx("Paste preview"), class: selectableText, tabindex: "0" }), + lineNumbers(), + highlightSpecialChars(), + ], + }), + }); + return () => preview.destroy(); + }, [source, text]); + return
; +} diff --git a/src/editor/lsp/Popups.module.css b/src/editor/lsp/Popups.module.css index eb422edad..2c068e9d0 100644 --- a/src/editor/lsp/Popups.module.css +++ b/src/editor/lsp/Popups.module.css @@ -427,3 +427,19 @@ gap: 6px; font-size: var(--fs-ui); } + +.pasteImports { + display: flex; + flex-direction: column; + gap: 12px; + padding: 14px; + font-size: var(--fs-ui); +} +.pasteImportList { + display: flex; + flex-direction: column; + gap: 8px; + max-height: 280px; + overflow: auto; + overflow-wrap: anywhere; +} diff --git a/src/editor/lsp/Popups.test.tsx b/src/editor/lsp/Popups.test.tsx index 7a9a65ebe..4efdd7298 100644 --- a/src/editor/lsp/Popups.test.tsx +++ b/src/editor/lsp/Popups.test.tsx @@ -18,6 +18,7 @@ const { LspPopups, DocumentationWindow } = await import("./Popups"); const { closePopup, openPopup, useLspPopup } = await import("./popupStore"); const { useDocWindow, pushHistory, stepHistory, hoverCandidates } = await import("./docs"); import popupStyles from "./Popups.module.css"; +import { registerImportsOnPasteActions } from "./importsOnPaste"; const loc = (path: string, line: number): Location => ({ path, @@ -29,6 +30,40 @@ afterEach(() => { vi.clearAllMocks(); }); +describe("imports on paste dialog", () => { + it("checks all imports by default and applies only the selected ones through its action", () => { + const dispose = registerImportsOnPasteActions(); + const onApply = vi.fn(); + openPopup({ + kind: "importsOnPaste", + imports: ["Import java.util.List", "Import java.util.Map"], + onApply, + onClose: vi.fn(), + }); + render(); + const boxes = screen.getAllByRole("checkbox"); + expect(boxes).toHaveLength(2); + boxes.forEach((box) => expect(box).toBeChecked()); + fireEvent.click(boxes[1]!); + fireEvent.click(screen.getByRole("button", { name: "Add Imports" })); + expect(onApply).toHaveBeenCalledWith([0]); + fireEvent.click(boxes[0]!); + expect(screen.getByRole("button", { name: "Add Imports" })).toBeDisabled(); + dispose(); + }); + + it("Cancel dismisses the dialog without applying imports", () => { + const onClose = vi.fn(); + const onApply = vi.fn(); + openPopup({ kind: "importsOnPaste", imports: ["Import java.util.List"], onApply, onClose }); + render(); + fireEvent.click(screen.getByRole("button", { name: "Cancel" })); + expect(onClose).toHaveBeenCalledOnce(); + expect(onApply).not.toHaveBeenCalled(); + expect(useLspPopup.getState().popup).toBeNull(); + }); +}); + describe("list popup", () => { it("numbers rows 1-9 and runs a row by its number", () => { const run = vi.fn(); diff --git a/src/editor/lsp/Popups.tsx b/src/editor/lsp/Popups.tsx index 5317310cf..451b95a5a 100644 --- a/src/editor/lsp/Popups.tsx +++ b/src/editor/lsp/Popups.tsx @@ -43,6 +43,7 @@ import { describeSummary } from "./workspaceEdit"; import { useUsagesStatus } from "./navigation"; import styles from "./Popups.module.css"; import { tx } from "@/platform/i18n"; +import { executeAction } from "@/platform/actions"; export function BulbIcon({ fix }: { fix?: boolean }) { return ; @@ -221,7 +222,11 @@ function ListPopup({ popup }: { popup: Extract }) { {popup.numbered && {n > 0 && n <= 9 ? n : ""}} - {item.preview && {item.preview}} + {item.preview && ( + + {item.preview} + + )} {item.detail && {item.detail}} {item.shortcut && {item.shortcut}} @@ -662,6 +667,45 @@ function ReformatDialog({ popup }: { popup: Extract }) { + const [selected, setSelected] = useState(() => popup.imports.map((_item, i) => i)); + return ( + void executeAction("code.applyPastedImports", selected), + }, + { label: "Cancel", role: "cancel" }, + ]} + > +
+
+ Select imports to add to the pasted code: +
+
+ {popup.imports.map((label, i) => ( + + setSelected((current) => (checked ? [...current, i] : current.filter((n) => n !== i))) + } + /> + ))} +
+
+
+ ); +} + /** Status bar text while ⌘⇧F7 usages are highlighted ("3 usages of 'x' found…"). */ export function UsagesStatus() { const text = useUsagesStatus((s) => s.text); @@ -679,6 +723,8 @@ export function LspPopups() { const seq = useLspPopup((s) => s.seq); if (!popup) return null; switch (popup.kind) { + case "importsOnPaste": + return ; case "list": return ; case "rename": diff --git a/src/editor/lsp/diagnostics.test.ts b/src/editor/lsp/diagnostics.test.ts index 91d7ccd31..285602944 100644 --- a/src/editor/lsp/diagnostics.test.ts +++ b/src/editor/lsp/diagnostics.test.ts @@ -1,7 +1,14 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { Text } from "@codemirror/state"; import type { Diagnostic } from "@/bindings"; -import { diagnosticsFor, setFileDiagnostics, subscribeDiagnostics, toLintDiagnostics } from "./diagnostics"; +import { + diagnosticsFor, + installDiagnosticsEvents, + setFileDiagnostics, + subscribeDiagnostics, + toLintDiagnostics, +} from "./diagnostics"; +import { emitLocalIdeEvent } from "@/platform/ipc"; const d = ( line: number, @@ -19,6 +26,20 @@ const d = ( }); describe("editor diagnostics store", () => { + it("identifies fresh backend publications, including empty replacements, separately from local inspections", () => { + const stop = installDiagnosticsEvents(); + const listener = vi.fn(); + const off = subscribeDiagnostics("/p/paste.java", listener); + const items = [d(0, 0, 4)]; + setFileDiagnostics("/p/paste.java", "local", items); + expect(listener).toHaveBeenLastCalledWith(undefined); + emitLocalIdeEvent({ type: "diagnostics", path: "/p/paste.java", source: "jdtls", items }); + expect(listener).toHaveBeenLastCalledWith({ source: "jdtls", items }); + emitLocalIdeEvent({ type: "diagnostics", path: "/p/paste.java", source: "other-server", items: [] }); + expect(listener).toHaveBeenLastCalledWith({ source: "other-server", items: [] }); + off(); + stop(); + }); it("keeps one list per source and notifies subscribers of the path", () => { let calls = 0; const off = subscribeDiagnostics("/p/x.rs", () => calls++); diff --git a/src/editor/lsp/diagnostics.ts b/src/editor/lsp/diagnostics.ts index 5bef06e08..917ec8023 100644 --- a/src/editor/lsp/diagnostics.ts +++ b/src/editor/lsp/diagnostics.ts @@ -27,7 +27,12 @@ import { type InspectionRef, } from "@/platform/inspections"; -type Listener = () => void; +export interface DiagnosticPublication { + source: string; + items: Diagnostic[]; +} + +type Listener = (publication?: DiagnosticPublication) => void; const byPath = new Map>(); const listeners = new Map>(); @@ -45,11 +50,11 @@ export function setFileDiagnostics(path: string, source: string, items: Diagnost ); } -function storeDiagnostics(path: string, source: string, items: Diagnostic[]) { +function storeDiagnostics(path: string, source: string, items: Diagnostic[], published = false) { let sources = byPath.get(path); if (!items.length) { - if (!sources?.delete(source)) return; - if (!sources.size) byPath.delete(path); + if (!sources?.delete(source) && !published) return; + if (!sources?.size) byPath.delete(path); } else { if (!sources) byPath.set(path, (sources = new Map())); sources.set( @@ -58,7 +63,7 @@ function storeDiagnostics(path: string, source: string, items: Diagnostic[]) { ); noteDiagnosticCodes(source, items); } - listeners.get(path)?.forEach((l) => l()); + listeners.get(path)?.forEach((l) => l(published ? { source, items } : undefined)); } /** Diagnostics of `path` under the inspection profile in effect. */ @@ -97,7 +102,7 @@ export function subscribeDiagnostics(path: string, l: Listener): () => void { } export function installDiagnosticsEvents(): () => void { - const off = onIdeEvent("diagnostics", (e) => storeDiagnostics(e.path, e.source, e.items)); + const off = onIdeEvent("diagnostics", (e) => storeDiagnostics(e.path, e.source, e.items, true)); // Diagnostics published before this window (re)loaded were missed: ask for them again. const resend = () => { if (isTauri()) diff --git a/src/editor/lsp/importsOnPaste.test.ts b/src/editor/lsp/importsOnPaste.test.ts new file mode 100644 index 000000000..460e903a1 --- /dev/null +++ b/src/editor/lsp/importsOnPaste.test.ts @@ -0,0 +1,509 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { Compartment, EditorState, StateEffect, Text } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +import type { CodeAction, Diagnostic, TextEdit } from "@/bindings"; +import type { DiagnosticPublication } from "./diagnostics"; + +const mocks = vi.hoisted(() => ({ + query: vi.fn(), + resolve: vi.fn(), + apply: vi.fn(), + cancel: vi.fn(), + listener: undefined as ((event?: DiagnosticPublication) => void) | undefined, +})); +vi.mock("./diagnostics", () => ({ + subscribeDiagnostics: (_path: string, listener: typeof mocks.listener) => { + mocks.listener = listener; + return () => { + mocks.listener = undefined; + }; + }, +})); +vi.mock("@/platform/ipc", async (orig) => { + const actual = await orig(); + return { + ...actual, + isTauri: () => true, + commands: { + ...actual.commands, + lspCodeActionQuery: mocks.query, + lspResolveCodeAction: mocks.resolve, + applyWorkspaceEdit: mocks.apply, + cancelRequest: mocks.cancel, + }, + }; +}); +import { + importsOnPasteExtension, + mergePastedImports, + pastedDiagnostics, + pastedImport, + withCommandEdit, +} from "./importsOnPaste"; +import { lspDoc } from "./lsp"; +import { closePopup, openPopup, useLspPopup } from "./popupStore"; +import { useProject } from "@/platform/project"; +import { validateEditorExtras } from "@/platform/editorSettings"; +import { documentSynced, mirroredChange } from "@/editor/api"; + +const path = "/p/Main.java"; +const range = (line = 0, col = 0, endCol = 9) => ({ start: { line, col }, end: { line, col: endCol } }); +const diagnostic = (over: Partial = {}): Diagnostic => ({ + range: range(), + severity: "error", + message: "ArrayList cannot be resolved to a type", + code: "16777218", + source: "Java", + ...over, +}); +const edit = (text = "import java.util.ArrayList;\n", r = range(0, 0, 0)): TextEdit => ({ range: r, new_text: text }); +const action = (over: Partial = {}): CodeAction => ({ + title: "Import 'ArrayList' (java.util)", + kind: "quickfix", + isPreferred: false, + disabledReason: null, + edit: { files: [{ kind: "edit", path, edits: [edit()], version: 7 }], preconditions: [{ path, hash: "snapshot" }] }, + command: null, + data: null, + server: 1, + ...over, +}); +const ok = (data: T) => ({ status: "ok" as const, data }); +const views: EditorView[] = []; +const mode = (importsOnPaste: string) => + useProject.setState({ settings: { editor: { autoImport: { importsOnPaste } } } as never }); +function editor(readOnly = false, stateReadOnly = false, compartment?: Compartment) { + const view = new EditorView({ + parent: document.body, + state: EditorState.create({ + extensions: [ + lspDoc.of({ doc: 3, path, language: "java", virtual: false, readOnly, uiMode: "code" }), + compartment ? compartment.of(EditorState.readOnly.of(stateReadOnly)) : EditorState.readOnly.of(stateReadOnly), + importsOnPasteExtension(), + ], + }), + }); + views.push(view); + return view; +} +function paste(view: EditorView, text = "ArrayList items;", userEvent = "input.paste") { + view.dispatch({ changes: { from: view.state.doc.length, insert: text }, userEvent }); +} +async function publish(items = [diagnostic()], source = "jdtls") { + await vi.advanceTimersByTimeAsync(0); + mocks.listener?.({ source, items }); +} +const settle = () => vi.advanceTimersByTimeAsync(250); + +beforeEach(() => { + vi.useFakeTimers(); + vi.resetAllMocks(); + mode("ask"); + mocks.query.mockResolvedValue(ok({ actions: [action()], advertisedKinds: ["quickfix"] })); + mocks.resolve.mockImplementation(async (_doc, a) => ok({ ...a, edit: action().edit })); + mocks.apply.mockResolvedValue(ok(null)); + mocks.cancel.mockResolvedValue(ok(null)); +}); +afterEach(() => { + views.splice(0).forEach((view) => view.destroy()); + closePopup(); + useProject.setState({ settings: null }); + vi.useRealTimers(); +}); + +describe("paste import selection", () => { + it("limits unresolved diagnostics to pasted ranges, using UTF-16 offsets", () => { + const doc = Text.of(["😀 ArrayList old", "missing"]); + const inside = diagnostic({ range: range(0, 3, 12) }); + const warning = diagnostic({ + range: range(1, 0, 7), + severity: "warning", + message: '"missing" is not defined', + code: "reportUndefinedVariable", + }); + expect( + pastedDiagnostics( + doc, + [ + { from: 3, to: 12 }, + { from: 17, to: 24 }, + ], + [ + inside, + warning, + diagnostic({ range: range(0, 0, 2) }), + diagnostic({ range: range(0, 3, 13) }), + diagnostic({ range: range(0, 3, 3) }), + diagnostic({ range: range(8, 0, 4) }), + diagnostic({ range: range(0, 3, 90) }), + diagnostic({ range: range(0, 12, 3) }), + diagnostic({ range: inside.range, message: "Incompatible types", code: "2322" }), + diagnostic({ range: inside.range, unnecessary: true }), + ], + ), + ).toEqual([inside, warning]); + }); + + it.each([ + "Import 'ArrayList' (java.util)", + 'Add import from "react"', + 'Update import from "react"', + "Import `std::collections::HashMap`", + 'Add import: "fmt"', + "Add 'useState' to existing import declaration from 'react'", + ])("recognizes the import quick fix %s", (title) => { + expect(pastedImport([action({ title }), action({ title: "Create class ArrayList" })])?.title).toBe(title); + }); + + it("skips ambiguous, disabled, source and unrelated import actions", () => { + expect(pastedImport([action(), action({ title: "Import 'ArrayList' (other)" })])).toBeNull(); + for (const over of [ + { disabledReason: "unavailable" }, + { disabledReason: "" }, + { kind: "source.organizeImports" }, + { kind: "quickfixOther" }, + { title: "Remove unused import" }, + { title: "Import all missing names" }, + { title: "Optimize imports" }, + ]) + expect(pastedImport([action(over)])).toBeNull(); + expect(pastedImport([action({ isPreferred: true }), action({ title: "Import 'ArrayList' (other)" })])).toBeNull(); + }); + + it("merges independent insertions once, preserving preconditions and versions", () => { + const first = action(); + const second = action({ + edit: { ...first.edit!, files: [{ kind: "edit", path, version: 7, edits: [edit("import java.util.List;\n")] }] }, + }); + const merged = mergePastedImports(path, [first, second, first])!; + expect(merged.preconditions).toEqual(first.edit!.preconditions); + expect(merged.files).toEqual([ + { kind: "edit", path, version: 7, edits: [edit("import java.util.ArrayList;\nimport java.util.List;\n")] }, + ]); + expect(first.edit!.files[0]).toEqual(action().edit!.files[0]); + }); + + it("rejects conflicting replacements, cross-file edits, file operations, and command-only fixes", () => { + const replacing = action({ + edit: { files: [{ kind: "edit", path, edits: [edit("import A;", range(0, 0, 10))] }] }, + }); + const conflict = action({ edit: { files: [{ kind: "edit", path, edits: [edit("import B;", range(0, 0, 10))] }] } }); + expect(mergePastedImports(path, [replacing, conflict])).toBeNull(); + expect(mergePastedImports(path, [action({ edit: null })])).toBeNull(); + expect( + mergePastedImports(path, [action({ edit: { files: [{ kind: "create", path, overwrite: false }] } })]), + ).toBeNull(); + expect( + mergePastedImports(path, [action({ edit: { files: [{ kind: "edit", path: "/other", edits: [edit()] }] } })]), + ).toBeNull(); + expect( + mergePastedImports(path, [action(), action({ edit: { files: [], preconditions: [{ path, hash: "changed" }] } })]), + ).toBeNull(); + }); + + it("defaults missing and invalid settings to ask", () => { + for (const raw of [ + undefined, + {}, + { autoImport: { importsOnPaste: "invalid" } }, + { autoImport: { importsOnPaste: 4 } }, + ]) + expect(validateEditorExtras(raw).autoImport.importsOnPaste).toBe("ask"); + for (const importsOnPaste of ["ask", "always", "never"]) + expect(validateEditorExtras({ autoImport: { importsOnPaste } }).autoImport.importsOnPaste).toBe(importsOnPaste); + }); +}); + +describe("imports on paste", () => { + it("waits for the paste to sync and then requires a new diagnostic publication", async () => { + const view = editor(); + let synced!: () => void; + const sync = new Promise((resolve) => { + synced = resolve; + }); + view.dispatch({ effects: StateEffect.appendConfig.of(documentSynced.of(() => sync)) }); + paste(view); + await publish(); + await settle(); + expect(mocks.query).not.toHaveBeenCalled(); + synced(); + await settle(); + expect(mocks.query).not.toHaveBeenCalled(); + await publish(); + await settle(); + expect(mocks.query).toHaveBeenCalledOnce(); + }); + + it("tracks each pasted range in a multi-cursor transaction", async () => { + const view = editor(); + view.dispatch({ changes: { from: 0, insert: "old\nold" } }); + view.dispatch({ + changes: [ + { from: 0, insert: "ArrayList" }, + { from: 4, insert: "List" }, + ], + userEvent: "input.paste", + }); + await publish([diagnostic(), diagnostic({ range: range(1, 0, 4) }), diagnostic({ range: range(0, 9, 12) })]); + await settle(); + expect(mocks.query).toHaveBeenCalledTimes(2); + expect(mocks.query.mock.calls.map((call) => call[2])).toEqual([range(), range(1, 0, 4)]); + }); + + it("ignores paste transactions mirrored from another editor", async () => { + const view = editor(); + view.dispatch({ + changes: { from: 0, insert: "ArrayList" }, + userEvent: "input.paste", + annotations: mirroredChange.of(true), + }); + await publish(); + await settle(); + expect(mocks.query).not.toHaveBeenCalled(); + }); + + it("requests quick fixes only after fresh diagnostics and asks by default", async () => { + const view = editor(); + await publish(); + paste(view); + await settle(); + expect(mocks.query).not.toHaveBeenCalled(); + mocks.listener?.(); // A local inspection/profile update is not fresh LSP diagnostics. + await settle(); + expect(mocks.query).not.toHaveBeenCalled(); + await publish(); + await settle(); + expect(mocks.query.mock.calls[0]!.slice(1)).toEqual([3, range(), ["quickfix"]]); + const popup = useLspPopup.getState().popup; + expect(popup?.kind).toBe("importsOnPaste"); + expect(mocks.apply).not.toHaveBeenCalled(); + if (popup?.kind === "importsOnPaste") popup.onApply([0]); + await settle(); + expect(mocks.apply).toHaveBeenCalledWith(mergePastedImports(path, [action()])); + expect(useLspPopup.getState().popup).toBeNull(); + }); + + it("Always applies all distinct imports in one edit", async () => { + mode("always"); + const view = editor(); + paste(view, "ArrayList List ArrayList"); + const second = action({ + title: "Import 'List' (java.util)", + edit: { files: [{ kind: "edit", path, edits: [edit("import java.util.List;\n")] }] }, + }); + mocks.query.mockResolvedValueOnce(ok({ actions: [action()] })).mockResolvedValueOnce(ok({ actions: [second] })); + await publish([diagnostic(), diagnostic({ range: range(0, 10, 14) }), diagnostic({ range: range(0, 15, 24) })]); + await settle(); + expect(mocks.apply).toHaveBeenCalledOnce(); + expect(mocks.apply).toHaveBeenCalledWith(mergePastedImports(path, [action(), second])); + expect(useLspPopup.getState().popup).toBeNull(); + }); + + it("collects a large paste within the deadline and lists a repeated import once", async () => { + const view = editor(); + paste(view, Array.from({ length: 40 }, () => "ArrayList").join("\n")); + await publish(Array.from({ length: 40 }, (_value, line) => diagnostic({ range: range(line) }))); + await settle(); + expect(mocks.query).toHaveBeenCalledTimes(40); + const popup = useLspPopup.getState().popup; + expect(popup?.kind === "importsOnPaste" && popup.imports).toEqual([action().title]); + }); + + it.each(["input.pastePlain", "input.paste.image", "input.type"])("ignores %s", async (event) => { + paste(editor(), undefined, event); + await publish(); + await settle(); + expect(mocks.query).not.toHaveBeenCalled(); + }); + + it.each(["never", "readOnly", "stateReadOnly"])("ignores %s", async (setting) => { + if (setting === "never") mode("never"); + paste(editor(setting === "readOnly", setting === "stateReadOnly")); + await publish(); + await settle(); + expect(mocks.query).not.toHaveBeenCalled(); + }); + + it("ignores diagnostics outside the paste and unrelated problems inside it", async () => { + const view = editor(); + view.dispatch({ changes: { from: 0, insert: "ArrayList old;\n" } }); + paste(view); + await publish([diagnostic(), diagnostic({ range: range(1), message: "Type mismatch", code: "other" })]); + await settle(); + expect(mocks.query).not.toHaveBeenCalled(); + }); + + it("resolves lazy fixes and skips ambiguous fixes", async () => { + const view = editor(); + paste(view, "ArrayList List"); + mocks.query + .mockResolvedValueOnce(ok({ actions: [action({ edit: null, data: "lazy" })] })) + .mockResolvedValueOnce(ok({ actions: [action(), action({ title: "Import other.List" })] })); + await publish([diagnostic(), diagnostic({ range: range(0, 10, 14) })]); + await settle(); + expect(mocks.resolve).toHaveBeenCalledOnce(); + const popup = useLspPopup.getState().popup; + expect(popup?.kind === "importsOnPaste" && popup.imports).toEqual([action().title]); + }); + + it("ignores lazy resolution after another paste and skips fixes with no inspectable edit", async () => { + let resolved!: (v: ReturnType>) => void; + mocks.query.mockResolvedValueOnce(ok({ actions: [action({ edit: null, data: "lazy" })] })); + mocks.resolve.mockImplementationOnce( + () => + new Promise((r) => { + resolved = r; + }), + ); + const view = editor(); + paste(view); + await publish(); + await settle(); + paste(view, "\nArrayList"); + resolved(ok(action())); + await settle(); + expect(useLspPopup.getState().popup).toBeNull(); + const commandOnly = action({ edit: null, command: { title: "Import", command: "server.import", arguments: [] } }); + mocks.query.mockResolvedValueOnce(ok({ actions: [commandOnly] })); + mocks.resolve.mockResolvedValueOnce(ok(commandOnly)); + await publish([diagnostic({ range: range(1) })]); + await settle(); + expect(useLspPopup.getState().popup).toBeNull(); + expect(mocks.apply).not.toHaveBeenCalled(); + }); + + it("Cancel and an empty selection leave the pasted code alone", async () => { + const view = editor(); + paste(view); + await publish(); + await settle(); + closePopup(); + expect(mocks.apply).not.toHaveBeenCalled(); + paste(view, "\nArrayList"); + await publish([diagnostic({ range: range(1) })]); + await settle(); + const popup = useLspPopup.getState().popup; + if (popup?.kind === "importsOnPaste") popup.onApply([]); + expect(mocks.apply).not.toHaveBeenCalled(); + }); + + it("cancels on edits and ignores a late query result", async () => { + let finish!: (v: ReturnType>) => void; + mocks.query.mockImplementationOnce( + () => + new Promise((r) => { + finish = r; + }), + ); + const view = editor(); + paste(view); + await publish(); + await settle(); + view.dispatch({ changes: { from: 0, insert: "x" }, userEvent: "input.type" }); + expect(mocks.cancel).toHaveBeenCalledWith(mocks.query.mock.calls[0]![0]); + finish(ok({ actions: [action()] })); + await settle(); + expect(mocks.apply).not.toHaveBeenCalled(); + expect(useLspPopup.getState().popup).toBeNull(); + }); + + it("expires both missing diagnostics and a stalled query", async () => { + const view = editor(); + paste(view); + await vi.advanceTimersByTimeAsync(8100); + await publish(); + await settle(); + expect(mocks.query).not.toHaveBeenCalled(); + paste(view, "\nArrayList"); + mocks.query.mockImplementationOnce(() => new Promise(() => {})); + await publish([diagnostic({ range: range(1) })]); + await vi.advanceTimersByTimeAsync(8100); + expect(mocks.cancel).toHaveBeenCalledWith(mocks.query.mock.calls[0]![0]); + expect(mocks.apply).not.toHaveBeenCalled(); + }); + + it("closes stale dialogs on document edits and editor destruction", async () => { + const view = editor(); + paste(view); + await publish(); + await settle(); + expect(useLspPopup.getState().popup?.kind).toBe("importsOnPaste"); + view.dispatch({ changes: { from: 0, insert: "x" } }); + expect(useLspPopup.getState().popup).toBeNull(); + paste(view, "\nArrayList"); + await publish([diagnostic({ range: range(1) })]); + await settle(); + view.destroy(); + expect(useLspPopup.getState().popup).toBeNull(); + expect(mocks.listener).toBeUndefined(); + }); + + it("cancels when reconfigured read-only and handles server errors silently", async () => { + const compartment = new Compartment(); + const view = editor(false, false, compartment); + paste(view); + await publish(); + await settle(); + view.dispatch({ effects: compartment.reconfigure(EditorState.readOnly.of(true)) }); + expect(useLspPopup.getState().popup).toBeNull(); + view.dispatch({ effects: compartment.reconfigure(EditorState.readOnly.of(false)) }); + mocks.query.mockRejectedValueOnce(new Error("server stopped")); + paste(view, "\nArrayList"); + await publish([diagnostic({ range: range(1) })]); + await settle(); + expect(mocks.apply).not.toHaveBeenCalled(); + expect(useLspPopup.getState().popup).toBeNull(); + }); + + it("does not replace another open popup", async () => { + openPopup({ kind: "list", anchor: { x: 0, y: 0 }, title: "Intentions", items: [] }); + paste(editor()); + await publish(); + await settle(); + expect(useLspPopup.getState().popup?.kind).toBe("list"); + expect(mocks.apply).not.toHaveBeenCalled(); + }); +}); + +describe("withCommandEdit", () => { + const path = "/p/src/cart.ts"; + const action = (args: unknown[]): CodeAction => ({ + title: 'Update import from "./money.ts"', + kind: "quickfix", + isPreferred: false, + disabledReason: null, + edit: null, + command: { title: "", command: "_typescript.applyWorkspaceEdit", arguments: args as string[] }, + data: null, + server: 2, + }); + const lspEdit = { + documentChanges: [ + { + textDocument: { uri: "file:///p/src/cart.ts", version: 6 }, + edits: [ + { range: { start: { line: 0, character: 26 }, end: { line: 0, character: 26 } }, newText: "formatMoney, " }, + ], + }, + ], + }; + + it("lifts a command's LSP workspace edit for the pasted file", () => { + expect(withCommandEdit(action([lspEdit]), path).edit).toEqual({ + files: [ + { + kind: "edit", + path, + version: 6, + edits: [{ range: { start: { line: 0, col: 26 }, end: { line: 0, col: 26 } }, new_text: "formatMoney, " }], + }, + ], + }); + }); + + it("leaves commands that touch other files or carry no edit alone", () => { + const other = { changes: { "file:///p/src/money.ts": lspEdit.documentChanges[0]!.edits } }; + expect(withCommandEdit(action([other]), path).edit).toBeNull(); + expect(withCommandEdit(action(["not json"]), path).edit).toBeNull(); + }); +}); diff --git a/src/editor/lsp/importsOnPaste.ts b/src/editor/lsp/importsOnPaste.ts new file mode 100644 index 000000000..f0335ca4d --- /dev/null +++ b/src/editor/lsp/importsOnPaste.ts @@ -0,0 +1,400 @@ +import { Transaction, type Text } from "@codemirror/state"; +import { ViewPlugin, type EditorView, type ViewUpdate } from "@codemirror/view"; +import type { CodeAction, Diagnostic, FileEdit, TextEdit, WorkspaceEdit } from "@/bindings"; +import { ipcPath } from "@/platform/paths"; +import { commands, IpcError, nextRequestId } from "@/platform/ipc"; +import { editorPrefs } from "@/platform/editorSettings"; +import { registerAction } from "@/platform/actions"; +import { isExternalChange, transientChange, whenSynced } from "@/editor/api"; +import { subscribeDiagnostics, type DiagnosticPublication } from "./diagnostics"; +import { docOf, lspRequest } from "./lsp"; +import { comparePos, rangeToOffsets } from "./positions"; +import { closePopup, openPopup, useLspPopup, type LspPopup } from "./popupStore"; + +const WAIT_MS = 8000; +const SETTLE_MS = 200; +const IMPORT_FIX = /^(?:(?:add|insert|update) (?:an? )?import\b|import\b|add .+ to (?:an? )?existing import\b)/i; +const UNRESOLVED = + /cannot find (?:name|namespace|type|value|function|macro|trait|module)|cannot be resolved|failed to resolve|unresolved (?:reference|name|type|symbol|import)|use of undeclared|undeclared (?:name|identifier)|undefined(?::| (?:name|variable))|not defined|unknown (?:identifier|symbol|type|name)|does not exist in (?:the )?current context|type or namespace name .+ could not be found/i; +const UNRESOLVED_CODE = + /^(?:2304|2552|2503|reportUndefinedVariable|UNRESOLVED_REFERENCE|undeclared_var_use|E0405|E0412|E0422|E0425|E0432|E0433|CS0103|CS0246)$/; + +interface PastedRange { + from: number; + to: number; +} + +export function pastedDiagnostics( + doc: Text, + ranges: readonly PastedRange[], + items: readonly Diagnostic[], +): Diagnostic[] { + return items.filter((d) => { + if (d.unnecessary || d.deprecated || (!UNRESOLVED.test(d.message) && !UNRESOLVED_CODE.test(d.code ?? ""))) + return false; + if (comparePos(d.range.start, d.range.end) >= 0) return false; + for (const pos of [d.range.start, d.range.end]) { + if (pos.line < 0 || pos.line >= doc.lines || pos.col < 0 || pos.col > doc.line(pos.line + 1).length) return false; + } + const { from, to } = rangeToOffsets(doc, d.range); + return ranges.some((r) => r.from <= from && to <= r.to); + }); +} + +export function pastedImport(actions: readonly CodeAction[]): CodeAction | null { + const imports = actions.filter( + (a) => + a.disabledReason == null && + (!a.kind || a.kind === "quickfix" || a.kind.startsWith("quickfix.")) && + IMPORT_FIX.test(a.title) && + !/^import (?:all|missing)\b/i.test(a.title), + ); + return imports.length === 1 ? imports[0]! : null; +} + +interface LspTextEdit { + range: { start: { line: number; character: number }; end: { line: number; character: number } }; + newText: string; +} + +function lspEditsOf(arg: unknown): [string, number | null, LspTextEdit[]][] | null { + if (!arg || typeof arg !== "object") return []; + const edit = arg as { + changes?: Record; + documentChanges?: { textDocument?: { uri: string; version?: number | null }; edits?: LspTextEdit[] }[]; + }; + if (edit.documentChanges) { + const out: [string, number | null, LspTextEdit[]][] = []; + for (const change of edit.documentChanges) { + if (!change.textDocument || !change.edits) return null; + out.push([change.textDocument.uri, change.textDocument.version ?? null, change.edits]); + } + return out; + } + return edit.changes ? Object.entries(edit.changes).map(([uri, edits]) => [uri, null, edits]) : []; +} + +function uriPath(uri: string): string | null { + if (!uri.startsWith("file:")) return null; + const path = decodeURIComponent(new URL(uri).pathname); + return ipcPath(/^\/[A-Za-z]:/.test(path) ? path.slice(1) : path); +} + +/** + * Some servers (typescript-language-server) send an import fix as a command + * carrying an LSP WorkspaceEdit instead of an `edit`; lift it into one when it + * only edits `path`, so it merges and applies like any other fix. + */ +export function withCommandEdit(action: CodeAction, path: string): CodeAction { + if (action.edit || !action.command) return action; + const files: FileEdit[] = []; + for (const raw of action.command.arguments as unknown[]) { + let arg = raw; + if (typeof raw === "string") { + try { + arg = JSON.parse(raw); + } catch { + continue; + } + } + const docs = lspEditsOf(arg); + if (!docs) return action; + for (const [uri, version, edits] of docs) { + if (uriPath(uri) !== ipcPath(path)) return action; + files.push({ + kind: "edit", + path, + version, + edits: edits.map((e) => ({ + range: { + start: { line: e.range.start.line, col: e.range.start.character }, + end: { line: e.range.end.line, col: e.range.end.character }, + }, + new_text: e.newText, + })), + }); + } + } + return files.length ? { ...action, edit: { files } } : action; +} + +/** Merge edits computed against one snapshot, keeping their content/version guards. */ +export function mergePastedImports(path: string, actions: readonly CodeAction[]): WorkspaceEdit | null { + const edits: TextEdit[] = []; + const preconditions = new Map(); + let version: number | null | undefined; + for (const action of actions) { + if (!action.edit?.files.length) return null; + for (const p of action.edit.preconditions ?? []) { + if (preconditions.has(p.path) && preconditions.get(p.path) !== p.hash) return null; + preconditions.set(p.path, p.hash); + } + for (const file of action.edit.files) { + if (file.kind !== "edit" || file.path !== path || !file.edits.length) return null; + if (version != null && file.version != null && version !== file.version) return null; + version ??= file.version; + for (const edit of file.edits) { + if (!edit.new_text.trim() || comparePos(edit.range.start, edit.range.end) > 0) return null; + if (!edits.some((e) => JSON.stringify(e) === JSON.stringify(edit))) edits.push(edit); + } + } + } + edits.sort((a, b) => comparePos(a.range.start, b.range.start) || comparePos(a.range.end, b.range.end)); + const merged: TextEdit[] = []; + for (const edit of edits) { + const previous = merged.at(-1); + if (previous) { + const sameStart = comparePos(previous.range.start, edit.range.start) === 0; + const bothInsert = + comparePos(previous.range.start, previous.range.end) === 0 && + comparePos(edit.range.start, edit.range.end) === 0; + if (sameStart && bothInsert) { + // Only whole import lines can be concatenated at a shared insertion point. + if (!previous.new_text.endsWith("\n") || !edit.new_text.endsWith("\n")) return null; + previous.new_text += edit.new_text; + continue; + } + if (sameStart || comparePos(edit.range.start, previous.range.end) < 0) return null; + } + merged.push({ ...edit }); + } + return merged.length + ? { + files: [{ kind: "edit", path, edits: merged, version }], + preconditions: [...preconditions].map(([path, hash]) => ({ path, hash })), + } + : null; +} + +interface PendingPaste { + context: NonNullable>; + doc: Text; + ranges: PastedRange[]; + diagnostics: Map; + abort: AbortController; + deadline: ReturnType; + settle?: ReturnType; + running: boolean; + synced: boolean; + popup?: Extract; +} + +function cancelled(): IpcError { + return new IpcError({ kind: "cancelled", message: "cancelled" }); +} + +/** Resolution has no request id; abandon its result immediately on cancellation. */ +function abortable(promise: Promise, signal: AbortSignal): Promise { + return new Promise((resolve, reject) => { + const abort = () => reject(cancelled()); + if (signal.aborted) return abort(); + signal.addEventListener("abort", abort, { once: true }); + promise.then(resolve, reject).finally(() => signal.removeEventListener("abort", abort)); + }); +} + +const importsOnPastePlugin = ViewPlugin.fromClass( + class { + private pending?: PendingPaste; + private unsubscribe: () => void; + + constructor(private view: EditorView) { + const doc = docOf(view); + this.unsubscribe = doc ? subscribeDiagnostics(doc.path, (e) => this.published(e)) : () => {}; + } + + update(update: ViewUpdate) { + if ( + this.pending && + (!this.current(this.pending) || (update.focusChanged && !this.view.hasFocus && !this.pending.popup)) + ) + this.cancel(); + if (!update.docChanged) return; + this.cancel(); + const doc = docOf(this.view); + if (!doc || doc.readOnly || this.view.state.readOnly || editorPrefs().autoImport.importsOnPaste === "never") + return; + let ranges: PastedRange[] = []; + for (const tr of update.transactions) { + ranges = ranges.map((r) => ({ from: tr.changes.mapPos(r.from, 1), to: tr.changes.mapPos(r.to, -1) })); + if ( + tr.annotation(Transaction.userEvent) !== "input.paste" || + isExternalChange(tr) || + tr.annotation(transientChange) + ) + continue; + tr.changes.iterChanges((_a, _b, from, to) => { + if (from < to) ranges.push({ from, to }); + }); + } + if (!ranges.length) return; + const pending: PendingPaste = { + context: doc, + doc: this.view.state.doc, + ranges, + diagnostics: new Map(), + abort: new AbortController(), + deadline: setTimeout(() => this.cancel(), WAIT_MS), + running: false, + synced: false, + }; + this.pending = pending; + // Let the editor's update listener enqueue the paste before flushing it. + void Promise.resolve() + .then(() => whenSynced(this.view)) + .then(() => { + if (this.current(pending)) pending.synced = true; + }) + .catch(() => { + if (this.pending === pending) this.cancel(); + }); + } + + private current(p: PendingPaste) { + return ( + this.pending === p && + !p.abort.signal.aborted && + this.view.dom.isConnected && + this.view.state.doc === p.doc && + docOf(this.view) === p.context && + !this.view.state.readOnly && + !docOf(this.view)?.readOnly && + editorPrefs().autoImport.importsOnPaste !== "never" + ); + } + + private published(e?: DiagnosticPublication) { + const p = this.pending; + if (!p || !e || !p.synced || !this.current(p) || p.running) return; + p.diagnostics.set(e.source, e.items); + clearTimeout(p.settle); + p.settle = setTimeout(() => void this.collect(p), SETTLE_MS); + } + + private async collect(p: PendingPaste) { + if (!this.current(p)) return this.cancel(); + const doc = docOf(this.view)!; + const diagnostics = pastedDiagnostics(p.doc, p.ranges, [...p.diagnostics.values()].flat()); + if (!diagnostics.length) return; + p.running = true; + const imports: CodeAction[] = []; + const seen = new Set(); + try { + for (const d of diagnostics) { + const key = JSON.stringify(d.range); + if (seen.has(key)) continue; + seen.add(key); + const id = nextRequestId(); + const cancel = () => void commands.cancelRequest(id).catch(() => {}); + p.abort.signal.addEventListener("abort", cancel, { once: true }); + let actions: CodeAction[]; + try { + const result = await abortable( + lspRequest( + () => commands.lspCodeActionQuery(id, doc.doc, d.range, ["quickfix"]), + () => this.current(p), + ), + p.abort.signal, + ); + actions = result.actions; + } finally { + p.abort.signal.removeEventListener("abort", cancel); + } + if (!this.current(p)) return; + let fix = pastedImport(actions); + if (!fix) continue; + fix = withCommandEdit(fix, doc.path); + if (!fix.edit) + fix = await abortable( + lspRequest( + () => commands.lspResolveCodeAction(doc.doc, fix!), + () => this.current(p), + ), + p.abort.signal, + ); + if (!this.current(p)) return; + fix = withCommandEdit(fix, doc.path); + if (!pastedImport([fix]) || !mergePastedImports(doc.path, [fix])) continue; + if (imports.some((a) => JSON.stringify(a.edit) === JSON.stringify(fix.edit))) continue; + if (mergePastedImports(doc.path, [...imports, fix])) imports.push(fix); + } + if (!this.current(p) || !imports.length) return; + clearTimeout(p.deadline); + if (editorPrefs().autoImport.importsOnPaste === "always") await this.apply(p, imports); + else if (!useLspPopup.getState().popup) { + p.popup = { + kind: "importsOnPaste", + imports: imports.map((a) => a.title), + onApply: (selected) => + void this.apply( + p, + imports.filter((_a, i) => selected.includes(i)), + ), + onClose: () => { + if (this.pending === p) this.cancel(); + }, + }; + openPopup(p.popup); + } + } catch { + // Missing/slow servers leave the paste and normal quick fixes alone. + } finally { + if (!p.popup && this.pending === p) this.cancel(); + } + } + + private async apply(p: PendingPaste, imports: CodeAction[]) { + if (!this.current(p)) return this.cancel(); + const edit = mergePastedImports(docOf(this.view)!.path, imports); + this.cancel(); + if (!edit) return; + try { + await lspRequest( + () => commands.applyWorkspaceEdit(edit), + () => + this.view.dom.isConnected && + this.view.state.doc === p.doc && + docOf(this.view) === p.context && + !this.view.state.readOnly && + !p.context.readOnly && + editorPrefs().autoImport.importsOnPaste !== "never", + ); + } catch { + // Workspace edit preconditions reject an edit if the document changed. + } + } + + private cancel() { + const p = this.pending; + this.pending = undefined; + if (!p) return; + clearTimeout(p.deadline); + clearTimeout(p.settle); + p.abort.abort(); + if (p.popup && useLspPopup.getState().popup === p.popup) closePopup(); + } + + destroy() { + this.cancel(); + this.unsubscribe(); + } + }, +); + +export function importsOnPasteExtension() { + return importsOnPastePlugin; +} + +export function registerImportsOnPasteActions(): () => void { + return registerAction({ + id: "code.applyPastedImports", + title: "Add Imports", + category: "Code", + hidden: true, + run: ({ arg }) => { + const popup = useLspPopup.getState().popup; + if (popup?.kind === "importsOnPaste" && Array.isArray(arg)) + popup.onApply(arg.filter((i): i is number => Number.isInteger(i) && i >= 0 && i < popup.imports.length)); + }, + }); +} diff --git a/src/editor/lsp/index.ts b/src/editor/lsp/index.ts index 2b7a5b00c..6f1e8c170 100644 --- a/src/editor/lsp/index.ts +++ b/src/editor/lsp/index.ts @@ -27,6 +27,7 @@ import { inlineCompletionExtension, registerInlineCompletionActions } from "./in import { semanticExtension } from "./semantic"; import { usagesLensExtension } from "./codeLens"; import { autoImportExtension } from "./autoImport"; +import { importsOnPasteExtension, registerImportsOnPasteActions } from "./importsOnPaste"; import { installConfigurationNavigationEvents } from "./configurationNavigation"; import { installAvailabilityEvents } from "./availability"; import { registerActionsOnSave, registerFormatOnSave, registerLspActions } from "./actions"; @@ -60,6 +61,7 @@ export function lspExtensions(ctx: EditorDocContext): Extension[] { semanticExtension(), usagesLensExtension(), autoImportExtension(), + importsOnPasteExtension(), unresolvedReferences(), syntaxErrorsExtension(), spellingExtension(), @@ -92,6 +94,7 @@ export function activate(): () => void { component: DocumentationWindow, }), registerLspActions(), + registerImportsOnPasteActions(), registerInlineCompletionActions(), registerNavigationActions(), registerFormatOnSave(), diff --git a/src/editor/lsp/popupStore.ts b/src/editor/lsp/popupStore.ts index a855ccd35..65d9be45c 100644 --- a/src/editor/lsp/popupStore.ts +++ b/src/editor/lsp/popupStore.ts @@ -65,6 +65,12 @@ export interface ReformatOptions { } export type LspPopup = + | { + kind: "importsOnPaste"; + imports: string[]; + onApply: (selected: number[]) => void; + onClose: () => void; + } | { kind: "doc"; anchor: PopupPoint; @@ -152,7 +158,7 @@ export function focusPopup() { } function disposed(popup: LspPopup | null) { - if (popup?.kind === "list") popup.onClose?.(); + if (popup?.kind === "list" || popup?.kind === "importsOnPaste") popup.onClose?.(); } export function openPopup(popup: LspPopup) { diff --git a/src/features/project-tree/actions.ts b/src/features/project-tree/actions.ts index 77f604b61..d980b6d39 100644 --- a/src/features/project-tree/actions.ts +++ b/src/features/project-tree/actions.ts @@ -157,7 +157,8 @@ export function registerProjectActions(): () => void { id, title, category: "Project View", - when: OPEN, + when: "projectOpen || textEditorActive", + keyWhen: FOCUS, keywords: ["path", "clipboard"], run: ({ arg }) => copyPaths(kind, nodesFrom(arg)), }); @@ -384,6 +385,7 @@ export function registerProjectActions(): () => void { copyPath("absolute", "project.copyAbsolutePath", "Copy Absolute Path"), copyPath("relative", "project.copyRelativePath", "Copy Path From Content Root"), copyPath("name", "project.copyFileName", "Copy File Name"), + copyPath("source", "project.copySourceRootPath", "Copy Path From Source Root"), { id: "project.revealInFinder", title: tx`Reveal in ${revealLabel()}`, diff --git a/src/features/project-tree/operations.ts b/src/features/project-tree/operations.ts index 8b1dee9a3..aabb1de9e 100644 --- a/src/features/project-tree/operations.ts +++ b/src/features/project-tree/operations.ts @@ -20,7 +20,6 @@ import { nameOf, packageSegments, parentOf, - relativeTo, uniqueName, validateName, type PackageKind, @@ -41,6 +40,9 @@ import { import { tx } from "@/platform/i18n"; import { hostOs, isRemoteWindow, useRemote } from "@/platform/remote"; import { usePlatform } from "@/platform/platformInfo"; +import { pathToCopy, type PathKind } from "@/platform/copyPath"; +import { projectFolder, useProject } from "@/platform/project"; +export type { PathKind } from "@/platform/copyPath"; /** Nodes that may be renamed, moved or deleted (not roots, groups or library sources). */ const mutable = (n: ProjectNode) => !n.root && !n.placeholder && !n.readOnly; @@ -802,13 +804,12 @@ export async function importExternal(paths: string[], target: string) { // --------------------------------------------------------------------------- // Paths / external -export type PathKind = "absolute" | "relative" | "name"; - export function pathsFor(kind: PathKind, nodes: ProjectNode[] = selectedNodes()): string[] { const { root } = get(); - return nodes.map((n) => - kind === "absolute" ? n.path : kind === "name" ? nameOf(n.path) : relativeTo(root ?? "", n.path) || ".", - ); + const project = useProject.getState().project; + return nodes + .map((n) => pathToCopy(kind, n.path, project ? (projectFolder(project, n.path)?.root ?? null) : root)) + .filter((p): p is string => p !== null); } export async function copyPaths(kind: PathKind, nodes: ProjectNode[] = selectedNodes()) { @@ -816,6 +817,7 @@ export async function copyPaths(kind: PathKind, nodes: ProjectNode[] = selectedN if (!text) return; try { await navigator.clipboard.writeText(text); + void executeAction("editor.showHint", { text: tx`Copied ${text}` }); } catch (e) { fail(tx("Cannot access the clipboard"), e); } diff --git a/src/features/settings/pages/editor/general.tsx b/src/features/settings/pages/editor/general.tsx index 1a8621496..5c8494ec8 100644 --- a/src/features/settings/pages/editor/general.tsx +++ b/src/features/settings/pages/editor/general.tsx @@ -197,10 +197,20 @@ const AUTO_IMPORT = editorPage({ id: "editor.general.autoImport", title: "Auto Import", order: 0, - keywords: ["import", "organize imports", "unambiguous"], + keywords: ["import", "organize imports", "unambiguous", "paste"], sections: [ { items: [ + { + t: "select", + key: `${P}.autoImport.importsOnPaste`, + label: "Insert imports on paste", + options: [ + { value: "always", label: "Always" }, + { value: "ask", label: "Ask" }, + { value: "never", label: "Never" }, + ], + }, { t: "check", key: `${P}.autoImport.addUnambiguous`, @@ -673,6 +683,11 @@ const SMART_KEYS = editorPage({ { value: "indentEachLine", label: "Indent Each Line" }, ], }, + { + t: "check", + key: `${P}.smartKeys.escapeOnPaste`, + label: "Escape text on paste in string literals", + }, ], }, { diff --git a/src/platform/copyPath.test.ts b/src/platform/copyPath.test.ts new file mode 100644 index 000000000..cf5f93a9b --- /dev/null +++ b/src/platform/copyPath.test.ts @@ -0,0 +1,51 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { pathToCopy } from "./copyPath"; +import { useProject } from "./project"; +import { useProjectRoots } from "./projectRoots"; + +afterEach(() => { + useProject.setState({ project: null, settings: null }); + useProjectRoots.setState({ root: null, detected: [] }); +}); + +describe("shared copy path values", () => { + it("preserves root, outside paths and Windows IPC paths", () => { + expect(pathToCopy("absolute", "/p/a.ts", "/p")).toBe("/p/a.ts"); + expect(pathToCopy("name", "/p/a.ts", "/p")).toBe("a.ts"); + expect(pathToCopy("relative", "/p/src/a.ts", "/p")).toBe("src/a.ts"); + expect(pathToCopy("relative", "/p", "/p")).toBe("."); + expect(pathToCopy("relative", "/project/a.ts", "/p")).toBe("/project/a.ts"); + expect(pathToCopy("relative", "/src/a.ts", "/")).toBe("src/a.ts"); + expect(pathToCopy("relative", "C:/p/src/a.ts", "C:/p")).toBe("src/a.ts"); + }); + + it("uses the containing workspace folder as the content root", () => { + useProject.setState({ project: { root: "/workspace", workspace: { projects: ["/a", "/b"] } } as never }); + expect(pathToCopy("relative", "/b/src/a.ts")).toBe("src/a.ts"); + expect(pathToCopy("relative", "/outside/a.ts")).toBe("/outside/a.ts"); + }); + + it("uses effective nested source, test and resource roots and respects exclusions", () => { + useProject.setState({ + project: { root: "/p" } as never, + settings: { projectStructure: { sourceRoots: ["generated"], unmarked: ["src/old"] } } as never, + }); + useProjectRoots.setState({ + root: "/p", + detected: [ + { path: "src", kind: "source", detectedFrom: null }, + { path: "src/test", kind: "test", detectedFrom: null }, + { path: "res", kind: "resources", detectedFrom: null }, + { path: "src/out", kind: "excluded", detectedFrom: null }, + { path: "src/old", kind: "source", detectedFrom: null }, + ], + }); + expect(pathToCopy("source", "/p/src/a.ts")).toBe("a.ts"); + expect(pathToCopy("source", "/p/src/test/a.ts")).toBe("a.ts"); + expect(pathToCopy("source", "/p/res/config.json")).toBe("config.json"); + expect(pathToCopy("source", "/p/generated/a.ts")).toBe("a.ts"); + expect(pathToCopy("source", "/p/src/old/a.ts")).toBe("old/a.ts"); + for (const path of ["/p/src/out/a.ts", "/p/src-other/a.ts", "/outside/a.ts"]) + expect(pathToCopy("source", path)).toBeNull(); + }); +}); diff --git a/src/platform/copyPath.ts b/src/platform/copyPath.ts new file mode 100644 index 000000000..7a5a4f96e --- /dev/null +++ b/src/platform/copyPath.ts @@ -0,0 +1,23 @@ +import { isSameOrInside } from "./paths"; +import { basename, projectFolder, useProject } from "./project"; +import { projectRelative, rootOfPath } from "./projectRoots"; + +export type PathKind = "absolute" | "relative" | "name" | "source"; + +/** Shared by the project view, editor tabs and the caret's copy chooser. */ +export function pathToCopy( + kind: PathKind, + path: string, + root: string | null = projectFolder(useProject.getState().project, path)?.root ?? null, +): string | null { + if (kind === "absolute") return path; + if (kind === "name") return basename(path); + if (kind === "source") { + const source = rootOfPath(path); + const relative = projectRelative(path); + if (!source || source.kind === "excluded" || relative === null) return null; + return relative.slice(source.path.length + (source.path ? 1 : 0)) || "."; + } + if (!root || !isSameOrInside(path, root)) return path; + return path.slice(root.length + (root.endsWith("/") ? 0 : 1)) || "."; +} diff --git a/src/platform/editorSettings.ts b/src/platform/editorSettings.ts index b76f97c54..feec9194c 100644 --- a/src/platform/editorSettings.ts +++ b/src/platform/editorSettings.ts @@ -122,7 +122,7 @@ export const EDITOR_EXTRA_DEFAULTS = { parameterInfo: true, parameterInfoDelay: 1000, }, - autoImport: { addUnambiguous: false, optimizeOnTheFly: false }, + autoImport: { addUnambiguous: false, optimizeOnTheFly: false, importsOnPaste: "ask" as "ask" | "always" | "never" }, folding: { showOutline: true, fileHeader: true, imports: true, docComments: false }, tabs: { placement: "top", @@ -151,6 +151,7 @@ export const EDITOR_EXTRA_DEFAULTS = { surroundSelection: true, tabOutOfPair: true, reformatOnPaste: "indentEachLine", + escapeOnPaste: true, smartIndent: true, insertDocStub: true, backspaceUnindent: "autoindent", @@ -250,6 +251,7 @@ export function fontWeightVars(main: unknown, bold: unknown): Record<"--fw-code" export function validateEditorExtras(raw: unknown): EditorExtras { const out = coerceLike(raw, EDITOR_EXTRA_DEFAULTS); + if (!["ask", "always", "never"].includes(out.autoImport.importsOnPaste)) out.autoImport.importsOnPaste = "ask"; out.fontWeight = fontWeightOf(out.fontWeight, EDITOR_EXTRA_DEFAULTS.fontWeight); out.boldFontWeight = fontWeightOf(out.boldFontWeight, EDITOR_EXTRA_DEFAULTS.boldFontWeight); return out; diff --git a/src/platform/keymaps/ideaParity.test.ts b/src/platform/keymaps/ideaParity.test.ts index 5e89a8bd0..939a86e0a 100644 --- a/src/platform/keymaps/ideaParity.test.ts +++ b/src/platform/keymaps/ideaParity.test.ts @@ -26,7 +26,7 @@ describe("IDEA keymap parity", () => { ["refactor.inline", ["Meta+Alt+N"], ["Ctrl+Alt+N"]], ["debug.resume", ["Meta+Alt+R", "F9"], ["F9"]], ["code.quickDefinition", ["Alt+Space", "Meta+Y"], ["Ctrl+Shift+I"]], - ["editor.copyPath", ["Meta+Shift+C"], ["Ctrl+Shift+C"]], + ["editor.copyPathReference", ["Meta+Shift+C"], ["Ctrl+Shift+C"]], ["project.open", ["F4", "Meta+Down"], ["F4"]], ["project.delete", ["Delete", "Backspace", "Meta+Backspace"], ["Delete"]], ["file.reloadFromDisk", ["Meta+Alt+Y"], ["Ctrl+Alt+Y"]], @@ -95,7 +95,7 @@ describe("IDEA keymap parity", () => { it.each([intellijMacos, intellijWindows])("lets scoped actions win shared keys", (km) => { order(km, "code.splitLine", "http.runRequest"); order(km, "code.splitLine", "db.execute"); - order(km, "editor.copyPath", "project.copyAbsolutePath"); + order(km, "editor.copyPathReference", "project.copyAbsolutePath"); order(km, "file.reloadFromDisk", "project.reloadFromDisk"); order(km, "code.unfold", "project.expandAll"); order(km, "code.fold", "project.collapseAll"); diff --git a/src/platform/keymaps/intellij-macos.ts b/src/platform/keymaps/intellij-macos.ts index b1def422f..baf1977ed 100644 --- a/src/platform/keymaps/intellij-macos.ts +++ b/src/platform/keymaps/intellij-macos.ts @@ -195,7 +195,8 @@ export const intellijMacos: Record = { "window.previous": ["Meta+Alt+Shift+`"], "file.reloadFromDisk": ["Meta+Alt+Y"], // Before the project view's ⇧⌘C (Copy Absolute Path), which wins there. - "editor.copyPath": ["Meta+Shift+C"], + "editor.copyPath": [], + "editor.copyPathReference": ["Meta+Shift+C"], // Project view (when: projectViewFocus) "project.newFile": ["Meta+N"], @@ -408,4 +409,6 @@ export const intellijMacos: Record = { "find.openInWindow": ["Alt+F7"], "find.field.newLine": ["Meta+Shift+Enter"], "find.field.recentSearches": ["Alt+Down"], + "editor.pasteHistory.paste": ["Enter"], + "editor.pasteHistory.remove": ["Delete", "Backspace"], }; diff --git a/src/platform/keymaps/intellij-windows.ts b/src/platform/keymaps/intellij-windows.ts index 5fd52690d..a10656c73 100644 --- a/src/platform/keymaps/intellij-windows.ts +++ b/src/platform/keymaps/intellij-windows.ts @@ -196,7 +196,8 @@ export const intellijWindows: Record = { "window.previous": ["Ctrl+Alt+["], "file.reloadFromDisk": ["Ctrl+Alt+Y"], // Before the project view's Ctrl+Shift+C (Copy Absolute Path), which wins there. - "editor.copyPath": ["Ctrl+Shift+C"], + "editor.copyPath": [], + "editor.copyPathReference": ["Ctrl+Shift+C"], // Project view (when: projectViewFocus) "project.newFile": ["Alt+Insert"], @@ -406,4 +407,6 @@ export const intellijWindows: Record = { "find.openInWindow": ["Alt+F7"], "find.field.newLine": ["Ctrl+Shift+Enter"], "find.field.recentSearches": ["Alt+Down"], + "editor.pasteHistory.paste": ["Enter"], + "editor.pasteHistory.remove": ["Delete", "Backspace"], }; diff --git a/src/platform/keymaps/vscode-macos.ts b/src/platform/keymaps/vscode-macos.ts index 567ff5c37..acb8fb42b 100644 --- a/src/platform/keymaps/vscode-macos.ts +++ b/src/platform/keymaps/vscode-macos.ts @@ -323,6 +323,7 @@ export const vscodeMacos: Record = { "code.cyclicExpandWord": [], "code.cyclicExpandWordBackward": [], "editor.copyReference": [], + "editor.copyPathReference": [], "code.pasteAsPlainText": [], "code.expressionTypeInfo": [], "code.implementMethods": [], @@ -394,4 +395,6 @@ export const vscodeMacos: Record = { "find.openInWindow": ["Alt+F7"], "find.field.newLine": ["Meta+Shift+Enter"], "find.field.recentSearches": ["Alt+Down"], + "editor.pasteHistory.paste": ["Enter"], + "editor.pasteHistory.remove": ["Delete", "Backspace"], }; diff --git a/src/platform/locales/text/en.json b/src/platform/locales/text/en.json index 6f2225999..2eaa56dfe 100644 --- a/src/platform/locales/text/en.json +++ b/src/platform/locales/text/en.json @@ -340,11 +340,34 @@ "\nError: {error}": "\nError: {error}", "\n[Log stream stopped]": "\n[Log stream stopped]", "Toggle Log Option": "Toggle Log Option", - "IDE-managed Tomcat runtime directory must not overlap Tomcat home or base directory": "IDE-managed Tomcat runtime directory must not overlap Tomcat home or base directory", - "devcontainer up exited with {status}": "devcontainer up exited with {status}", "Rename Agent Task": "Rename Agent Task", "Couldn't rename the task": "Couldn't rename the task", "Start a Fresh Agent Session": "Start a Fresh Agent Session", "Start Fresh": "Start Fresh", - "This clears the entire conversation and starts a fresh agent session with this message. Files the agent changed stay as they are.": "This clears the entire conversation and starts a fresh agent session with this message. Files the agent changed stay as they are." + "This clears the entire conversation and starts a fresh agent session with this message. Files the agent changed stay as they are.": "This clears the entire conversation and starts a fresh agent session with this message. Files the agent changed stay as they are.", + "Copy Path From Source Root": "Copy Path From Source Root", + "Escape text on paste in string literals": "Escape text on paste in string literals", + "Always": "Always", + "Never": "Never", + "Path From Content Root": "Path From Content Root", + "Copy Path/Reference…": "Copy Path/Reference…", + "(whitespace)": "(whitespace)", + "Copied {text}": "Copied {text}", + "Add Imports": "Add Imports", + "Resolving reference…": "Resolving reference…", + "Insert imports on paste": "Insert imports on paste", + "Path with line number": "Path with line number", + "Clipboard history": "Clipboard history", + "Copy Path with Line Number": "Copy Path with Line Number", + "1 line": "1 line", + "Path From Source Root": "Path From Source Root", + "Paste preview": "Paste preview", + "Add Imports on Paste": "Add Imports on Paste", + "Select imports to add to the pasted code:": "Select imports to add to the pasted code:", + "File Name": "File Name", + "Reference": "Reference", + "Absolute Path": "Absolute Path", + "Nothing to paste: the clipboard history is empty": "Nothing to paste: the clipboard history is empty", + "Filter clipboard history": "Filter clipboard history", + "Ask": "Ask" } diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index aa9e66cc3..e1213a5a0 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -6702,12 +6702,27 @@ "\nError: {error}": "\n错误:{error}", "\n[Log stream stopped]": "\n[日志流已停止]", "Toggle Log Option": "切换日志选项", - "IDE-managed Tomcat runtime directory must not overlap Tomcat home or base directory": "IDE 托管的 Tomcat 运行目录不得与 Tomcat 主目录或基目录重叠", - "Read-only: the table schema could not be identified in the result database.": "只读:无法确定结果所在数据库中的表架构。", - "devcontainer up exited with {status}": "devcontainer up 已退出,状态为 {status}", "Rename Agent Task": "重命名智能体任务", "Couldn't rename the task": "无法重命名任务", "Start a Fresh Agent Session": "启动全新的智能体会话", "Start Fresh": "重新开始", - "This clears the entire conversation and starts a fresh agent session with this message. Files the agent changed stay as they are.": "这会清空整个对话,并使用此消息启动全新的智能体会话。智能体已修改的文件保持不变。" + "This clears the entire conversation and starts a fresh agent session with this message. Files the agent changed stay as they are.": "这会清空整个对话,并使用此消息启动全新的智能体会话。智能体已修改的文件保持不变。", + "Path From Source Root": "相对于源根的路径", + "Paste preview": "粘贴预览", + "Filter clipboard history": "筛选剪贴板历史", + "Add Imports": "添加导入", + "Copy Path with Line Number": "复制带行号的路径", + "Path with line number": "带行号的路径", + "1 line": "1 行", + "Add Imports on Paste": "粘贴时添加导入", + "(whitespace)": "(空白)", + "Nothing to paste: the clipboard history is empty": "没有可粘贴的内容:剪贴板历史为空", + "Escape text on paste in string literals": "粘贴到字符串字面量时转义文本", + "Resolving reference…": "正在解析引用…", + "Clipboard history": "剪贴板历史", + "Select imports to add to the pasted code:": "选择要为粘贴的代码添加的导入:", + "Copy Path From Source Root": "复制相对于源根的路径", + "Copied {text}": "已复制 {text}", + "Reference": "引用", + "Insert imports on paste": "粘贴时插入导入" }