дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-08 19:02:17 +02:00
feat(editor): clipboard: whole-line paste, paste history dialog, copy path chooser, escape and imports on paste (#2014)
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).
Цей коміт міститься в:
1 джерело
b2d30e0c95
коміт
0630ed0729
40 files changed
+2950
-93
No files matched your search
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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<str
|
||||
return reference;
|
||||
}
|
||||
|
||||
function copyReference(view: EditorView, slot: EditorSlot) {
|
||||
const reference = resolveReference(view, slot);
|
||||
function copyReference(reference: Promise<string>) {
|
||||
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<LspPopup, { kind: "list" }> = {
|
||||
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}`,
|
||||
),
|
||||
)();
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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<string | undefined> = [];
|
||||
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({
|
||||
|
||||
@@ -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<string, string>({ 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();
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ export function interpolationParser<S>(base: StreamParser<S>, 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<S>(base: StreamParser<S>, 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<S>(base: StreamParser<S>, 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 ?? "}")) {
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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<typeof EditorSelection.cursor>[] = [];
|
||||
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<string, string>();
|
||||
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 = ""');
|
||||
});
|
||||
});
|
||||
@@ -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<string, string> = { 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);
|
||||
}),
|
||||
];
|
||||
}
|
||||
@@ -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" },
|
||||
|
||||
@@ -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(<LspPopups />);
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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%;
|
||||
}
|
||||
@@ -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<typeof vi.fn<(text: string, linewise: boolean) => 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(<PasteHistoryDialogHost />);
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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 (
|
||||
<PasteHistoryDialog
|
||||
key={seq}
|
||||
{...request}
|
||||
onClose={() => {
|
||||
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<HTMLInputElement>(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 (
|
||||
<Dialog
|
||||
open
|
||||
title="Choose Content to Paste"
|
||||
onClose={onClose}
|
||||
className={styles.dialog}
|
||||
width={680}
|
||||
resizable="both"
|
||||
minWidth={360}
|
||||
minHeight={320}
|
||||
buttons={[
|
||||
{
|
||||
label: "Paste",
|
||||
role: "default",
|
||||
onClick: () => runPaste(),
|
||||
disabled: !selected.length || view.state.readOnly,
|
||||
},
|
||||
{ label: "Cancel", role: "cancel" },
|
||||
]}
|
||||
footerStart={
|
||||
<Button disabled={!selected.length} onClick={runRemove}>
|
||||
<T>Delete</T>
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={styles.body}
|
||||
onKeyDown={onKeyDown}
|
||||
onFocusCapture={(e) => setContext("pasteHistoryListFocus", !!e.target.closest('[role="listbox"]'))}
|
||||
onBlurCapture={() => setContext("pasteHistoryListFocus", false)}
|
||||
>
|
||||
<SearchField
|
||||
ref={search}
|
||||
data-autofocus=""
|
||||
aria-label="Filter clipboard history"
|
||||
placeholder="Filter clipboard history"
|
||||
value={query}
|
||||
onChange={filter}
|
||||
/>
|
||||
<List
|
||||
items={rows}
|
||||
getKey={(row) => 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={<T>{entries.length ? "No matches" : "Nothing to paste: the clipboard history is empty"}</T>}
|
||||
renderItem={(row) => (
|
||||
<>
|
||||
<span className={styles.label}>{row.label}</span>
|
||||
<span className={styles.detail}>{row.detail ?? tx("1 line")}</span>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
<PastePreview source={view} text={preview} />
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function PastePreview({ source, text }: { source: EditorView; text: string }) {
|
||||
const ref = useRef<HTMLDivElement>(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 <div ref={ref} className={styles.preview} />;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(<LspPopups />);
|
||||
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(<LspPopups />);
|
||||
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();
|
||||
|
||||
@@ -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 <Icon name={fix ? "bulb-fix" : "bulb"} size={16} className={styles.bulb} />;
|
||||
@@ -221,7 +222,11 @@ function ListPopup({ popup }: { popup: Extract<LspPopup, { kind: "list" }> }) {
|
||||
{popup.numbered && <span className={styles.mnemonic}>{n > 0 && n <= 9 ? n : ""}</span>}
|
||||
<ItemIcon item={item} />
|
||||
<HighlightedText className={styles.label} text={item.label} indices={match} tone="accent" />
|
||||
{item.preview && <span className={styles.preview}>{item.preview}</span>}
|
||||
{item.preview && (
|
||||
<span className={styles.preview} title={item.preview}>
|
||||
{item.preview}
|
||||
</span>
|
||||
)}
|
||||
<span className={styles.spacer} />
|
||||
{item.detail && <span className={styles.detail}>{item.detail}</span>}
|
||||
{item.shortcut && <span className={styles.shortcut}>{item.shortcut}</span>}
|
||||
@@ -662,6 +667,45 @@ function ReformatDialog({ popup }: { popup: Extract<LspPopup, { kind: "reformat"
|
||||
);
|
||||
}
|
||||
|
||||
function ImportsOnPasteDialog({ popup }: { popup: Extract<LspPopup, { kind: "importsOnPaste" }> }) {
|
||||
const [selected, setSelected] = useState(() => popup.imports.map((_item, i) => i));
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
title={tx("Add Imports on Paste")}
|
||||
onClose={closePopup}
|
||||
width={460}
|
||||
buttons={[
|
||||
{
|
||||
label: "Add Imports",
|
||||
role: "default",
|
||||
disabled: !selected.length,
|
||||
onClick: () => void executeAction("code.applyPastedImports", selected),
|
||||
},
|
||||
{ label: "Cancel", role: "cancel" },
|
||||
]}
|
||||
>
|
||||
<div className={styles.pasteImports}>
|
||||
<div>
|
||||
<T>Select imports to add to the pasted code:</T>
|
||||
</div>
|
||||
<div className={styles.pasteImportList}>
|
||||
{popup.imports.map((label, i) => (
|
||||
<Checkbox
|
||||
key={i}
|
||||
label={label}
|
||||
checked={selected.includes(i)}
|
||||
onChange={(checked) =>
|
||||
setSelected((current) => (checked ? [...current, i] : current.filter((n) => n !== i)))
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 <ImportsOnPasteDialog key={seq} popup={popup} />;
|
||||
case "list":
|
||||
return <ListPopup key={seq} popup={popup} />;
|
||||
case "rename":
|
||||
|
||||
@@ -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++);
|
||||
|
||||
@@ -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<string, Map<string, Diagnostic[]>>();
|
||||
const listeners = new Map<string, Set<Listener>>();
|
||||
@@ -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())
|
||||
|
||||
@@ -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<typeof import("@/platform/ipc")>();
|
||||
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> = {}): 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> = {}): 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 = <T>(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<String> 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<void>((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<typeof ok<CodeAction>>) => 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<typeof ok<{ actions: CodeAction[] }>>) => 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();
|
||||
});
|
||||
});
|
||||
@@ -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<string, LspTextEdit[]>;
|
||||
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<string, string>();
|
||||
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<ReturnType<typeof docOf>>;
|
||||
doc: Text;
|
||||
ranges: PastedRange[];
|
||||
diagnostics: Map<string, Diagnostic[]>;
|
||||
abort: AbortController;
|
||||
deadline: ReturnType<typeof setTimeout>;
|
||||
settle?: ReturnType<typeof setTimeout>;
|
||||
running: boolean;
|
||||
synced: boolean;
|
||||
popup?: Extract<LspPopup, { kind: "importsOnPaste" }>;
|
||||
}
|
||||
|
||||
function cancelled(): IpcError {
|
||||
return new IpcError({ kind: "cancelled", message: "cancelled" });
|
||||
}
|
||||
|
||||
/** Resolution has no request id; abandon its result immediately on cancellation. */
|
||||
function abortable<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
|
||||
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<string>();
|
||||
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));
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()}`,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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)) || ".";
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -195,7 +195,8 @@ export const intellijMacos: Record<string, string[]> = {
|
||||
"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<string, string[]> = {
|
||||
"find.openInWindow": ["Alt+F7"],
|
||||
"find.field.newLine": ["Meta+Shift+Enter"],
|
||||
"find.field.recentSearches": ["Alt+Down"],
|
||||
"editor.pasteHistory.paste": ["Enter"],
|
||||
"editor.pasteHistory.remove": ["Delete", "Backspace"],
|
||||
};
|
||||
@@ -196,7 +196,8 @@ export const intellijWindows: Record<string, string[]> = {
|
||||
"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<string, string[]> = {
|
||||
"find.openInWindow": ["Alt+F7"],
|
||||
"find.field.newLine": ["Ctrl+Shift+Enter"],
|
||||
"find.field.recentSearches": ["Alt+Down"],
|
||||
"editor.pasteHistory.paste": ["Enter"],
|
||||
"editor.pasteHistory.remove": ["Delete", "Backspace"],
|
||||
};
|
||||
@@ -323,6 +323,7 @@ export const vscodeMacos: Record<string, string[]> = {
|
||||
"code.cyclicExpandWord": [],
|
||||
"code.cyclicExpandWordBackward": [],
|
||||
"editor.copyReference": [],
|
||||
"editor.copyPathReference": [],
|
||||
"code.pasteAsPlainText": [],
|
||||
"code.expressionTypeInfo": [],
|
||||
"code.implementMethods": [],
|
||||
@@ -394,4 +395,6 @@ export const vscodeMacos: Record<string, string[]> = {
|
||||
"find.openInWindow": ["Alt+F7"],
|
||||
"find.field.newLine": ["Meta+Shift+Enter"],
|
||||
"find.field.recentSearches": ["Alt+Down"],
|
||||
"editor.pasteHistory.paste": ["Enter"],
|
||||
"editor.pasteHistory.remove": ["Delete", "Backspace"],
|
||||
};
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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": "粘贴时插入导入"
|
||||
}
|
||||
Reference in new issue
Заблокувати користувача