fix(ui): Make read-only content selectable with a Copy context menu (#48)

Root cause: body is user-select: none and each feature had to opt back in; the Markdown preview (and other read-only views) never did, and the app-wide contextmenu guard left no Copy item.
Fix: shared selectableText class (src/ui) for read-only content, and a fallback Copy context menu from browserGuards when right-clicking a selection or a selectable area (component menus still win).
Siblings covered: Quick Documentation / hover / completion docs, hover errors, Quick Definition, debugger value tooltip, inspection messages, message dialogs (no drag on body text), notification titles, About dialog; existing selectable consoles now get Copy too.
Verified in-app on macOS: select in Markdown preview, right-click Copy puts the text on the clipboard; tab context menu unchanged.

Claude-Session: https://claude.ai/code/session_01VUYsdXA2nkF5v6zsC93dpn
Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-10-27 08:56:13 +08:00
1 джерело 6dfd82de4c
коміт de84a643aa
17 files changed
+453 -55

No files matched your search

+12 -4
Переглянути файл
@@ -14,6 +14,7 @@ import {
SymbolBadge,
fileIconName,
fuzzyMatch,
selectableText,
T,
} from "@/ui";
import { basename, relativePath, useProject } from "@/platform/project";
@@ -329,7 +330,7 @@ function DocView({ host, history, themeClasses }: { host: DocHost; history: DocH
{entry && (
<DomContent
key={`${history.index}:${history.entries.length}`}
className={styles.docBody}
className={clsx(styles.docBody, selectableText)}
build={() => renderMarkup(entry.markup, entry.language, docLinkHandler(host))}
/>
)}
@@ -352,7 +353,13 @@ function DocView({ host, history, themeClasses }: { host: DocHost; history: DocH
/>
<span className={styles.spacer} />
{entry?.source && (
<IconButton icon="edit" size="small" title="Edit Source" shortcut="F4" onClick={() => void editDocSource(host)} />
<IconButton
icon="edit"
size="small"
title="Edit Source"
shortcut="F4"
onClick={() => void editDocSource(host)}
/>
)}
{host === "popup" && (
<IconButton
@@ -373,7 +380,8 @@ function DocPopup({ popup }: { popup: Extract<LspPopup, { kind: "doc" }> }) {
const scope = useRef<HTMLDivElement>(null);
const opened = useRef(focusSeq);
useEffect(() => {
if (focusSeq !== opened.current) scope.current?.querySelector<HTMLElement>("[data-lsp-doc]")?.focus({ preventScroll: true });
if (focusSeq !== opened.current)
scope.current?.querySelector<HTMLElement>("[data-lsp-doc]")?.focus({ preventScroll: true });
}, [focusSeq]);
return (
<Popup
@@ -463,7 +471,7 @@ function DefinitionPopup({ popup }: { popup: Extract<LspPopup, { kind: "definiti
<div className={clsx(popup.themeClasses, styles.docScope)}>
<DomContent
key={popup.index}
className={styles.defBody}
className={clsx(styles.defBody, selectableText)}
build={() => renderCode(target.text, target.language, "cm-lsp-code")}
/>
</div>
+68 -19
Переглянути файл
@@ -25,13 +25,22 @@ import {
} from "@codemirror/autocomplete";
import { EditorSelection, Prec, type Text } from "@codemirror/state";
import { EditorView, ViewPlugin, keymap, type ViewUpdate } from "@codemirror/view";
import { call, commands, isTauri, nextRequestId, type CompletionItem, type Markup, type RequestId } from "@/platform/ipc";
import {
call,
commands,
isTauri,
nextRequestId,
type CompletionItem,
type Markup,
type RequestId,
} from "@/platform/ipc";
import { useProject } from "@/platform/project";
import { completionSymbolKind, identifierEnd, toCmCompletion, triggerCharacter } from "./completionItems";
import { lspSnippetToCm, snippetVariables } from "./snippet";
import { docOf, isUnavailable, lspDoc, lspRequest, reportLspError } from "./lsp";
import { offsetToPos, posToOffset, textEditsToChanges } from "./positions";
import { isEmptyMarkup, renderCode, renderMarkup } from "./markdown";
import { selectableText } from "@/ui/selectableText";
import { iconNode } from "./iconMarkup";
import { triggerSignatureHelp } from "./signature";
import { serverBackoff } from "./availability";
@@ -101,7 +110,7 @@ function buildInfo(c: LspCompletion, delay: number): Promise<CompletionInfo> {
const hasDoc = !isEmptyMarkup(item.documentation);
if (!hasDoc && !item.detail) return null;
const dom = document.createElement("div");
dom.className = "cm-lsp-completion-doc";
dom.className = `cm-lsp-completion-doc ${selectableText}`;
if (item.detail) dom.appendChild(renderCode(item.detail, c.req.language, "cm-lsp-code cm-lsp-doc-signature"));
if (hasDoc) dom.appendChild(renderMarkup(item.documentation!, c.req.language));
return dom;
@@ -160,7 +169,8 @@ export function applyLspCompletion(
const item = completion.resolved ?? completion.lsp;
const state = view.state;
const delta = to - req.pos;
const mapOld = (offset: number) => Math.max(0, Math.min(state.doc.length, offset < req.pos ? offset : offset + delta));
const mapOld = (offset: number) =>
Math.max(0, Math.min(state.doc.length, offset < req.pos ? offset : offset + delta));
let start = from;
let end = to;
@@ -190,7 +200,9 @@ export function applyLspCompletion(
}
snippet(lspSnippetToCm(item.insertText, snippetVariables(req.path)))(view, completion, start, end);
} else {
const parens = editorPrefs().completion.insertParentheses ? callParens(item, state.doc.lineAt(start), start, end) : null;
const parens = editorPrefs().completion.insertParentheses
? callParens(item, state.doc.lineAt(start), start, end)
: null;
insertedParens = parens;
const changes = state.changes([...extra, { from: start, to: end, insert: item.insertText + (parens ? "()" : "") }]);
const caret = changes.mapPos(end, 1) - (parens === "inside" ? 1 : 0);
@@ -203,7 +215,8 @@ export function applyLspCompletion(
});
}
if (!completion.resolved && !item.additionalEdits.length && item.data != null) void applyLateEdits(view, completion, start);
if (!completion.resolved && !item.additionalEdits.length && item.data != null)
void applyLateEdits(view, completion, start);
if (insertedParens === "inside" && !item.command) setTimeout(() => triggerSignatureHelp(view), 0);
runCommand(view, item);
}
@@ -240,7 +253,9 @@ export function callParens(
async function applyLateEdits(view: EditorView, c: LspCompletion, insertedAt: number) {
const item = await resolveItem(c.req.doc, c.lsp);
if (!item.additionalEdits.length || !view.dom.isConnected) return;
const edits = textEditsToChanges(c.req.text, item.additionalEdits).filter((e) => e.to <= Math.min(insertedAt, c.req.pos));
const edits = textEditsToChanges(c.req.text, item.additionalEdits).filter(
(e) => e.to <= Math.min(insertedAt, c.req.pos),
);
if (!edits.length) return;
const limit = Math.max(...edits.map((e) => e.to));
if (view.state.doc.sliceString(0, limit) !== c.req.text.sliceString(0, limit)) return;
@@ -259,7 +274,10 @@ function runCommand(view: EditorView, item: CompletionItem) {
else if (!CLIENT_COMMANDS.has(cmd) && !cmd.startsWith("editor.")) {
const doc = docOf(view);
// Server commands (auto-import, postfix…); resulting edits come back as workspace/applyEdit.
if (doc) void lspRequest(() => commands.lspExecuteCommand(doc.doc, command)).catch((e: unknown) => reportLspError(view, e, false));
if (doc)
void lspRequest(() => commands.lspExecuteCommand(doc.doc, command)).catch((e: unknown) =>
reportLspError(view, e, false),
);
}
}
@@ -278,7 +296,10 @@ function cancelInflight() {
/** Language-data completion sources (keywords etc.), used when no server answers. */
async function fallback(ctx: CompletionContext): Promise<CompletionResult | null> {
const sources = ctx.state.languageDataAt<CompletionSource | readonly (string | Completion)[]>("autocomplete", ctx.pos);
const sources = ctx.state.languageDataAt<CompletionSource | readonly (string | Completion)[]>(
"autocomplete",
ctx.pos,
);
for (const s of sources) {
const source = typeof s === "function" ? s : completeFromList(s);
const r = await source(ctx);
@@ -317,7 +338,9 @@ export function mergeResults(results: readonly CompletionResult[]): CompletionRe
const [first] = results;
if (!first || results.length === 1) return first ?? null;
const same = results.filter((r) => r.from === first.from);
const validFor = same.every((r) => r.validFor instanceof RegExp && r.validFor.source === (first.validFor as RegExp | undefined)?.source)
const validFor = same.every(
(r) => r.validFor instanceof RegExp && r.validFor.source === (first.validFor as RegExp | undefined)?.source,
)
? first.validFor
: undefined;
return { from: first.from, to: first.to, options: same.flatMap((r) => r.options), validFor };
@@ -360,7 +383,9 @@ async function lspCompletionSource(ctx: CompletionContext): Promise<CompletionRe
const req: RequestContext = { doc: doc.doc, path: doc.path, language: doc.uiMode, pos: ctx.pos, text: ctx.state.doc };
const prefs = editorPrefs().completion;
const typed = word?.text ?? "";
const items = prefs.matchCase ? list.items.filter((it) => matchesCase(it.filterText ?? it.label, typed, prefs.caseSensitivity)) : list.items;
const items = prefs.matchCase
? list.items.filter((it) => matchesCase(it.filterText ?? it.label, typed, prefs.caseSensitivity))
: list.items;
return {
from: word ? word.from : ctx.pos,
options: items.map((item) => makeCompletion(item, req)),
@@ -396,7 +421,10 @@ export function sortAlphabetically(result: CompletionResult, typed: string): Com
update: validFor
? (_cur, from, to, ctx) => {
const text = ctx.state.sliceDoc(from, to);
const ok = typeof validFor === "function" ? validFor(text, from, to, ctx.state) : new RegExp(`^(?:${validFor.source})$`, validFor.flags).test(text);
const ok =
typeof validFor === "function"
? validFor(text, from, to, ctx.state)
: new RegExp(`^(?:${validFor.source})$`, validFor.flags).test(text);
return ok ? sortAlphabetically({ ...result, from }, text) : null;
}
: undefined,
@@ -455,7 +483,9 @@ function gated(source: CompletionSource): CompletionSource {
if (only) return source === contributedSource ? only(ctx) : null;
if (!ctx.explicit && (!editorPrefs().completion.autoPopup || inplaceRenameActive(ctx.state))) return null;
if (!editorPrefs().completion.sortAlphabetically) return source(ctx);
return Promise.resolve(source(ctx)).then((r) => r && sortAlphabetically(r, ctx.state.sliceDoc(r.from, r.to ?? ctx.pos)));
return Promise.resolve(source(ctx)).then(
(r) => r && sortAlphabetically(r, ctx.state.sliceDoc(r.from, r.to ?? ctx.pos)),
);
};
}
@@ -478,7 +508,12 @@ const explicitPending = new WeakSet<EditorView>();
const NAVIGATION_KEYS = new Set(["ArrowUp", "ArrowDown", "PageUp", "PageDown"]);
/** Whether typing `ch` finishes the open lookup with its selected item. */
export function commitsLookup(ch: string, focused: boolean, insertOnSpecialKeys: boolean, itemChars: readonly string[] = []): boolean {
export function commitsLookup(
ch: string,
focused: boolean,
insertOnSpecialKeys: boolean,
itemChars: readonly string[] = [],
): boolean {
if (!focused && !insertOnSpecialKeys) return false;
return COMMIT_CHARS.includes(ch) || itemChars.includes(ch);
}
@@ -495,11 +530,15 @@ const lookupKeys = Prec.highest(
const selected = selectedCompletion(view.state);
if (!selected) return false;
const itemChars = isLsp(selected) ? selected.lsp.commitCharacters : (selected.commitCharacters ?? []);
if (!commitsLookup(e.key, focusedLookups.has(view), editorPrefs().completion.insertOnSpecialKeys, itemChars)) return false;
if (!commitsLookup(e.key, focusedLookups.has(view), editorPrefs().completion.insertOnSpecialKeys, itemChars))
return false;
if (!acceptCompletion(view)) return false;
// A call snippet or inserted parentheses already opened the call: the typed one is consumed.
const head = view.state.selection.main.head;
if (e.key === "(" && (view.state.sliceDoc(head - 1, head) === "(" || view.state.sliceDoc(head - 2, head) === "()")) {
if (
e.key === "(" &&
(view.state.sliceDoc(head - 1, head) === "(" || view.state.sliceDoc(head - 2, head) === "()")
) {
e.preventDefault();
return true;
}
@@ -526,7 +565,8 @@ const lookupTracker = ViewPlugin.fromClass(
resetLookupDocs();
if (explicitPending.has(view)) {
explicitPending.delete(view);
if (prev === "pending" && !u.docChanged) queueMicrotask(() => showHint(view, "No suggestions", { timeout: 2000 }));
if (prev === "pending" && !u.docChanged)
queueMicrotask(() => showHint(view, "No suggestions", { timeout: 2000 }));
}
} else if (status === "active" && explicitPending.has(view)) {
explicitPending.delete(view);
@@ -535,7 +575,12 @@ const lookupTracker = ViewPlugin.fromClass(
const doc = u.state.doc;
setTimeout(() => {
const now = currentCompletions(view.state);
if (view.state.doc === doc && completionStatus(view.state) === "active" && now.length === 1 && now[0] === options[0])
if (
view.state.doc === doc &&
completionStatus(view.state) === "active" &&
now.length === 1 &&
now[0] === options[0]
)
acceptCompletion(view);
}, AUTO_INSERT_DELAY);
}
@@ -583,7 +628,9 @@ function typeColumn(c: Completion): Node | null {
async function toggleSortByName(view: EditorView) {
const on = !editorPrefs().completion.sortAlphabetically;
if (isTauri()) {
const settings = await call(commands.settingsSet("global", "editor.completion.sortAlphabetically", JSON.stringify(on)));
const settings = await call(
commands.settingsSet("global", "editor.completion.sortAlphabetically", JSON.stringify(on)),
);
useProject.setState({ settings });
}
if (!view.dom.isConnected) return;
@@ -647,7 +694,9 @@ export function completionExtension() {
optionClass: (c) => {
// IDEA shows keywords in bold.
const keyword = (isLsp(c) ? c.lsp.kind : c.type) === "keyword" ? " cm-lsp-keyword-option" : "";
return (isLsp(c) ? (c.lsp.deprecated ? "cm-lsp-option cm-lsp-deprecated-option" : "cm-lsp-option") : "") + keyword;
return (
(isLsp(c) ? (c.lsp.deprecated ? "cm-lsp-option cm-lsp-deprecated-option" : "cm-lsp-option") : "") + keyword
);
},
tooltipClass: () => "cm-lsp-completion",
}),
+40 -9
Переглянути файл
@@ -5,11 +5,21 @@
*/
import { EditorSelection } from "@codemirror/state";
import { hoverTooltip, type EditorView } from "@codemirror/view";
import { commands, isTauri, nextRequestId, type Hover, type Location, type Pos, type Range, type RequestId } from "@/platform/ipc";
import {
commands,
isTauri,
nextRequestId,
type Hover,
type Location,
type Pos,
type Range,
type RequestId,
} from "@/platform/ipc";
import { executeAction } from "@/platform/actions";
import { shortcutLabel } from "@/platform/keymap";
import { diagnosticsAt, type PlacedDiagnostic } from "./diagnostics";
import { isEmptyMarkup, languageForPath, renderMarkup } from "./markdown";
import { selectableText } from "@/ui/selectableText";
import { docOf, lspRequest, reportLspError } from "./lsp";
import { offsetToPos, rangeToOffsets } from "./positions";
import { showHint } from "./hint";
@@ -51,7 +61,13 @@ function renderDiagnostics(view: EditorView, diags: PlacedDiagnostic[]): HTMLEle
for (const { diagnostic: d, from } of diags) {
const row = document.createElement("div");
row.className = `cm-lsp-diag cm-lsp-diag-${d.severity}`;
row.appendChild(iconNode(d.severity === "error" ? "error" : d.severity === "warning" ? "warning" : "info", 16, "cm-lsp-diag-icon"));
row.appendChild(
iconNode(
d.severity === "error" ? "error" : d.severity === "warning" ? "warning" : "info",
16,
"cm-lsp-diag-icon",
),
);
const msg = document.createElement("div");
msg.className = "cm-lsp-diag-message";
msg.textContent = d.message;
@@ -110,7 +126,7 @@ export function hoverExtension() {
above: true,
create: () => {
const dom = document.createElement("div");
dom.className = "cm-lsp-hover";
dom.className = `cm-lsp-hover ${selectableText}`;
if (diags.length) dom.appendChild(renderDiagnostics(view, diags));
if (hover) dom.appendChild(renderMarkup(hover.contents, lang));
return { dom };
@@ -141,7 +157,13 @@ function showDoc(view: EditorView, entry: DocEntry, at: number) {
showInDocWindow(entry, view.themeClasses);
return;
}
openPopup({ kind: "doc", anchor: caretAnchor(view, at), entries: [entry], index: 0, themeClasses: view.themeClasses });
openPopup({
kind: "doc",
anchor: caretAnchor(view, at),
entries: [entry],
index: 0,
themeClasses: view.themeClasses,
});
}
/**
@@ -210,7 +232,10 @@ export function definitionSpan(
async function declarationRange(loc: Location): Promise<Range | null> {
try {
return await withDocument(loc.path, async (doc) => (await structure.structuralRanges(doc, loc.range.start)).declaration?.range ?? null);
return await withDocument(
loc.path,
async (doc) => (await structure.structuralRanges(doc, loc.range.start)).declaration?.range ?? null,
);
} catch {
return null;
}
@@ -249,14 +274,20 @@ export async function quickDefinition(view: EditorView, kind: "definition" | "ty
showHint(view, kind === "typeDefinition" ? "No type definition found" : "No definition found");
return;
}
const targets = (await Promise.all(locations.slice(0, MAX_TARGETS).map((l) => definitionTarget(l, doc.uiMode)))).filter(
(t): t is DefinitionTarget => !!t,
);
const targets = (
await Promise.all(locations.slice(0, MAX_TARGETS).map((l) => definitionTarget(l, doc.uiMode)))
).filter((t): t is DefinitionTarget => !!t);
if (!targets.length) {
showHint(view, "Cannot read the definition's file");
return;
}
openPopup({ kind: "definition", anchor: caretAnchor(view, pos), targets, index: 0, themeClasses: view.themeClasses });
openPopup({
kind: "definition",
anchor: caretAnchor(view, pos),
targets,
index: 0,
themeClasses: view.themeClasses,
});
} catch (e) {
reportLspError(view, e, true);
}
+15 -5
Переглянути файл
@@ -14,6 +14,7 @@ import type { Markup } from "@/bindings";
import { maybeEditorService } from "@/workbench/contributions";
import { isTauri } from "@/platform/ipc";
import { isVirtualUri } from "@/platform/virtualDocs";
import { selectableText } from "@/ui/selectableText";
type Parser = typeof import("./markdownParser");
@@ -71,7 +72,10 @@ export function isEmptyMarkup(m: Markup | null | undefined): boolean {
/**
* The declaration line(s) of hover documentation, for the brief ⌘-hover info: the first fenced code block, else the first paragraph.
*/
export function declarationSummary(m: Markup, maxLines = 6): { code: string; language: string | null } | { text: string } | null {
export function declarationSummary(
m: Markup,
maxLines = 6,
): { code: string; language: string | null } | { text: string } | null {
if (m.kind === "markdown") {
const fence = /^\s*(```|~~~)[ \t]*([\w+#.-]*)[^\n]*\n([\s\S]*?)\n\s*\1/m.exec(m.value);
if (fence && fence[3]!.trim()) {
@@ -80,7 +84,10 @@ export function declarationSummary(m: Markup, maxLines = 6): { code: string; lan
return { code, language: fence[2] || null };
}
}
const para = m.value.trim().split(/\n\s*\n/)[0]?.trim();
const para = m.value
.trim()
.split(/\n\s*\n/)[0]
?.trim();
if (!para) return null;
return { text: para.length > 200 ? `${para.slice(0, 199)}…` : para };
}
@@ -187,7 +194,10 @@ export function sourceLink(href: string): { path: string; line: number; col: num
export function openDocLink(href: string) {
const target = sourceLink(href);
if (target) {
void maybeEditorService()?.openFile(target.path, { selection: { line: target.line, col: target.col }, focus: true });
void maybeEditorService()?.openFile(target.path, {
selection: { line: target.line, col: target.col },
focus: true,
});
} else if (/^https?:\/\//.test(href) && isTauri()) {
void import("@tauri-apps/plugin-opener").then((m) => m.openUrl(href)).catch(() => {});
}
@@ -210,7 +220,7 @@ function linkClickHandler(onLink?: DocLinkHandler) {
/** Renders markup into a `.cm-lsp-markdown` element. */
export function renderMarkup(m: Markup, fallbackLanguage: string | null = null, onLink?: DocLinkHandler): HTMLElement {
const dom = document.createElement("div");
dom.className = "cm-lsp-markdown";
dom.className = `cm-lsp-markdown ${selectableText}`;
dom.innerHTML = markupToHtml(m);
highlightBlocks(dom, fallbackLanguage);
if (m.kind === "markdown" && !parser) {
@@ -228,7 +238,7 @@ export function renderMarkup(m: Markup, fallbackLanguage: string | null = null,
/** Renders a code snippet (e.g. a signature or definition) as highlighted mono text. */
export function renderCode(text: string, language: string | null, className = "cm-lsp-code"): HTMLElement {
const pre = document.createElement("pre");
pre.className = className;
pre.className = `${className} ${selectableText}`;
const code = document.createElement("code");
code.textContent = text;
pre.appendChild(code);