diff --git a/src/editor/lsp/Popups.tsx b/src/editor/lsp/Popups.tsx index bc6aa24a..3db73a6c 100644 --- a/src/editor/lsp/Popups.tsx +++ b/src/editor/lsp/Popups.tsx @@ -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 && ( renderMarkup(entry.markup, entry.language, docLinkHandler(host))} /> )} @@ -352,7 +353,13 @@ function DocView({ host, history, themeClasses }: { host: DocHost; history: DocH /> {entry?.source && ( - void editDocSource(host)} /> + void editDocSource(host)} + /> )} {host === "popup" && ( }) { const scope = useRef(null); const opened = useRef(focusSeq); useEffect(() => { - if (focusSeq !== opened.current) scope.current?.querySelector("[data-lsp-doc]")?.focus({ preventScroll: true }); + if (focusSeq !== opened.current) + scope.current?.querySelector("[data-lsp-doc]")?.focus({ preventScroll: true }); }, [focusSeq]); return ( renderCode(target.text, target.language, "cm-lsp-code")} /> diff --git a/src/editor/lsp/completion.ts b/src/editor/lsp/completion.ts index 325a138d..8b0213f7 100644 --- a/src/editor/lsp/completion.ts +++ b/src/editor/lsp/completion.ts @@ -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 { 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 { - const sources = ctx.state.languageDataAt("autocomplete", ctx.pos); + const sources = ctx.state.languageDataAt( + "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 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(); 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", }), diff --git a/src/editor/lsp/hover.ts b/src/editor/lsp/hover.ts index 227ad85a..e9706558 100644 --- a/src/editor/lsp/hover.ts +++ b/src/editor/lsp/hover.ts @@ -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 { 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); } diff --git a/src/editor/lsp/markdown.ts b/src/editor/lsp/markdown.ts index fbae9e53..f280a27b 100644 --- a/src/editor/lsp/markdown.ts +++ b/src/editor/lsp/markdown.ts @@ -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); diff --git a/src/features/analysis/InspectionView.tsx b/src/features/analysis/InspectionView.tsx index 88673f49..4b35509e 100644 --- a/src/features/analysis/InspectionView.tsx +++ b/src/features/analysis/InspectionView.tsx @@ -25,6 +25,7 @@ import { type TreeHandle, type TreeRowState, T, + selectableText, } from "@/ui"; import { cancelInspection, @@ -438,7 +439,7 @@ function ProblemDetails({ node, root }: { node: Node | null; root: string }) { {p.d.source ? `${p.d.source}${p.d.code ? ` (${p.d.code})` : ""}` : Language server} -

{p.d.message}

+

{p.d.message}

{relativeDir(root, p.path) ? `${relativeDir(root, p.path)}/` : ""} {basename(p.path)}:{p.d.range.start.line + 1}:{p.d.range.start.col + 1} diff --git a/src/features/app/AboutDialog.tsx b/src/features/app/AboutDialog.tsx index 742ca749..9de80574 100644 --- a/src/features/app/AboutDialog.tsx +++ b/src/features/app/AboutDialog.tsx @@ -3,7 +3,8 @@ import { create } from "zustand"; import { executeAction } from "@/platform/actions"; import { t as translate, useT } from "@/platform/i18n"; import { commands, isTauri, type AppInfo, type BuildInfo } from "@/platform/ipc"; -import { Dialog, IconButton } from "@/ui"; +import clsx from "clsx"; +import { Dialog, IconButton, selectableText } from "@/ui"; import logo from "../../../src-tauri/icons/128x128.png"; import { buildInfo, guessOs } from "./host"; import styles from "./app.module.css"; @@ -82,13 +83,13 @@ function AboutDialog({ onClose }: { onClose: () => void }) {
-
OpenIDEA {build?.version ?? ""}
+
OpenIDEA {build?.version ?? ""}
{build && ( -
+
{t("app.about.build")} #{build.build} ({build.profile})
)} -
{t("app.about.tagline")}
+
{t("app.about.tagline")}
{rows .filter(([k]) => k !== t("app.about.version") && k !== t("app.about.build")) @@ -116,12 +117,12 @@ function AboutDialog({ onClose }: { onClose: () => void }) { )}
-
+
{t("app.about.copyright")}
{t("app.about.license")}
-
+
{t("app.about.thirdParty")} {t("app.about.disclaimer")}
diff --git a/src/features/debugger/editor/valueTooltip.ts b/src/features/debugger/editor/valueTooltip.ts index a8af94ad..6bca2847 100644 --- a/src/features/debugger/editor/valueTooltip.ts +++ b/src/features/debugger/editor/valueTooltip.ts @@ -9,7 +9,7 @@ import { workflowSettings } from "@/platform/workflowSettings"; import { activeSession, evaluate } from "../state"; import { inlineValue } from "../values"; import { tx } from "@/platform/i18n"; -import { iconMarkup } from "@/ui"; +import { iconMarkup, selectableText } from "@/ui"; import { mountValueTree } from "../ui/ValueHintTree"; import styles from "./editor.module.css"; @@ -59,7 +59,7 @@ export function valueTooltip(): Extension { above: true, create: () => { const dom = document.createElement("div"); - dom.className = styles.valueTooltip!; + dom.className = `${styles.valueTooltip} ${selectableText}`; let tree: { unmount(): void } | null = null; if (value.variablesReference > 0) { // IDEA's value hint: the "+" expands the object into a variables tree in place. @@ -73,7 +73,7 @@ export function valueTooltip(): Extension { expand.addEventListener("click", () => { if (tree) return; expand.remove(); - dom.classList.add(styles.valueTooltipTree!); + dom.classList.replace(selectableText, styles.valueTooltipTree!); dom.replaceChildren(); tree = mountValueTree(dom, expr.text, value, session.id); }); diff --git a/src/features/viewers/markdown/MarkdownEditor.tsx b/src/features/viewers/markdown/MarkdownEditor.tsx index 699c0f2f..5876f919 100644 --- a/src/features/viewers/markdown/MarkdownEditor.tsx +++ b/src/features/viewers/markdown/MarkdownEditor.tsx @@ -8,7 +8,7 @@ import clsx from "clsx"; import { call, commands, isTauri } from "@/platform/ipc"; import { maybeEditorService } from "@/workbench/contributions"; import { highlightInto } from "@/editor/lsp/markdown"; -import { IconButton } from "@/ui"; +import { IconButton, selectableText } from "@/ui"; import { useLiveMarkdown } from "./live"; import { modeOf, useMarkdownModes } from "./modes"; import { ANCHOR_PREFIX, previewOffsetForLine, renderMarkdown, resolveLink, slugify } from "./render"; @@ -140,7 +140,7 @@ function MarkdownPreview({ path, sync }: { path: string; sync: boolean }) { return (
) => { - if (popup || e.button !== 0 || (e.target as Element).closest("button, input, label, a, select, textarea")) return; + if ( + popup || + e.button !== 0 || + (e.target as Element).closest(`button, input, label, a, select, textarea, .${selectableText}`) + ) + return; const el = ref.current; if (!el) return; e.preventDefault(); @@ -355,8 +361,10 @@ function DialogInner({
- {title != null &&
{title}
} -
{typeof children === "string" ? tx(children) : children}
+ {title != null &&
{title}
} +
+ {typeof children === "string" ? tx(children) : children} +
{footerStart &&
{footerStart}
}
diff --git a/src/ui/Feedback.tsx b/src/ui/Feedback.tsx index a1d4ec31..05add2b2 100644 --- a/src/ui/Feedback.tsx +++ b/src/ui/Feedback.tsx @@ -3,6 +3,7 @@ import clsx from "clsx"; import { Icon, type IconName } from "./icons"; import { IconButton } from "./Button"; import { T } from "./T"; +import { selectableText } from "./selectableText"; import styles from "./Feedback.module.css"; export interface ProgressBarProps { @@ -135,7 +136,9 @@ export function Toast({
- {typeof title === "string" ? {title} : title} + + {typeof title === "string" ? {title} : title} + {meta && {meta}}
{body &&
{typeof body === "string" ? {body} : body}
} diff --git a/src/ui/SelectableText.module.css b/src/ui/SelectableText.module.css new file mode 100644 index 00000000..9beea150 --- /dev/null +++ b/src/ui/SelectableText.module.css @@ -0,0 +1,6 @@ +/* Read-only content the user reads and copies (docs, previews, messages), as opposed to chrome. */ +.selectable { + -webkit-user-select: text; + user-select: text; + cursor: auto; +} diff --git a/src/ui/index.ts b/src/ui/index.ts index 4604e292..c17fd51e 100644 --- a/src/ui/index.ts +++ b/src/ui/index.ts @@ -22,3 +22,4 @@ export * from "./BarButton"; export * from "./AppLogo"; export { lazyComponent, prefetchLazyComponents } from "./lazy"; export { T } from "./T"; +export { selectableText, copyMenuFor, copyText } from "./selectableText"; diff --git a/src/ui/selectableSurfaces.test.tsx b/src/ui/selectableSurfaces.test.tsx new file mode 100644 index 00000000..4b9658d9 --- /dev/null +++ b/src/ui/selectableSurfaces.test.tsx @@ -0,0 +1,60 @@ +/** + * Read-only content surfaces stay selectable/copyable (issue #36): IDEA lets + * the user select and copy text in the Markdown preview, documentation popups, + * message dialogs and notifications, while chrome stays unselectable. + */ +import { afterEach, describe, expect, it } from "vitest"; +import { act, cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { renderCode, renderMarkup } from "@/editor/lsp/markdown"; +import { MarkdownEditor } from "@/features/viewers/markdown/MarkdownEditor"; +import { useLiveMarkdown } from "@/features/viewers/markdown/live"; +import { useMarkdownModes } from "@/features/viewers/markdown/modes"; +import { Dialog } from "./Dialog"; +import { Toast } from "./Feedback"; +import { selectableText } from "./selectableText"; + +afterEach(() => { + cleanup(); + useLiveMarkdown.setState({}, true); +}); + +describe("selectable read-only surfaces", () => { + it("Markdown preview (the reported case)", () => { + act(() => + useLiveMarkdown.setState({ "/p/README.md": { text: "# Title\n\nSome *text*", topLine: 0, scrollSeq: 0 } }), + ); + act(() => useMarkdownModes.setState((s) => ({ byPath: { ...s.byPath, "/p/README.md": "preview" } }))); + render( + +
+ , + ); + const preview = screen.getByRole("document", { name: "Markdown preview" }); + expect(preview).toHaveClass(selectableText); + expect(screen.getByRole("toolbar", { name: "Markdown editor layout" }).closest(`.${selectableText}`)).toBeNull(); + }); + + it("documentation markup and code (Quick Documentation, hover, completion docs)", () => { + expect(renderMarkup({ kind: "plaintext", value: "docs" })).toHaveClass(selectableText); + expect(renderCode("fn f()", null)).toHaveClass(selectableText); + }); + + it("message dialog title and body; dragging does not start on them", () => { + render( + {}} icon="warning" title="Delete?" buttons={[{ label: "OK", role: "default" }]}> + Message text + , + ); + const body = screen.getByText("Message text"); + expect(body).toHaveClass(selectableText); + expect(screen.getByText("Delete?")).toHaveClass(selectableText); + expect(screen.getByRole("button", { name: "OK" }).closest(`.${selectableText}`)).toBeNull(); + // Not prevented: the press starts a text selection instead of moving the dialog. + expect(fireEvent.pointerDown(body, { button: 0 })).toBe(true); + }); + + it("notification title (the body opts in through its own style)", () => { + render( {}} />); + expect(screen.getByText("Build failed").closest(`.${selectableText}`)).not.toBeNull(); + }); +}); diff --git a/src/ui/selectableText.test.ts b/src/ui/selectableText.test.ts new file mode 100644 index 00000000..8e50f566 --- /dev/null +++ b/src/ui/selectableText.test.ts @@ -0,0 +1,93 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { copyMenuFor, copyText, selectableText } from "./selectableText"; + +function mount(html: string) { + document.body.innerHTML = html; + return document.body; +} + +function select(node: Node) { + const range = document.createRange(); + range.selectNodeContents(node); + const sel = window.getSelection()!; + sel.removeAllRanges(); + sel.addRange(range); +} + +afterEach(() => { + window.getSelection()?.removeAllRanges(); + document.body.innerHTML = ""; + vi.restoreAllMocks(); +}); + +describe("selectableText", () => { + it("is a real class name", () => { + expect(selectableText).toBeTruthy(); + }); +}); + +describe("copyMenuFor", () => { + it("offers Copy of the selection in a selectable pane (issue #36: Markdown preview)", () => { + mount(`

Hello world

`); + select(document.getElementById("p")!); + const items = copyMenuFor(document.getElementById("b")); + expect(items).toHaveLength(1); + expect(items![0]).toMatchObject({ label: "Copy", disabled: false }); + }); + + it("offers a disabled Copy in a selectable pane with nothing selected", () => { + mount(`

Hello

`); + expect(copyMenuFor(document.getElementById("p"))![0]).toMatchObject({ label: "Copy", disabled: true }); + }); + + it("resolves text-node targets", () => { + mount(`

Hello

`); + expect(copyMenuFor(document.getElementById("p")!.firstChild)).not.toBeNull(); + }); + + it("offers Copy on a selection in any pane that opted in with its own CSS", () => { + mount(`
line 1
`); + select(document.getElementById("console")!); + expect(copyMenuFor(document.getElementById("console"))![0]).toMatchObject({ disabled: false }); + }); + + it("stays out of chrome without a selection", () => { + mount(`
tree row
`); + expect(copyMenuFor(document.getElementById("b"))).toBeNull(); + expect(copyMenuFor(document.getElementById("row"))).toBeNull(); + }); + + it("ignores a selection elsewhere when right-clicking chrome", () => { + mount(`

Hello

tree row
`); + select(document.getElementById("p")!); + expect(copyMenuFor(document.getElementById("row"))).toBeNull(); + }); + + it.each(["", "", "
x
"])( + "leaves editable targets alone: %s", + (html) => { + mount(`
${html}
`); + expect(copyMenuFor(document.getElementById("t"))).toBeNull(); + }, + ); + + it("copies the text captured when the menu opened", async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, "clipboard", { value: { writeText }, configurable: true }); + mount(`

Hello

`); + select(document.getElementById("p")!); + const items = copyMenuFor(document.getElementById("p"))!; + window.getSelection()!.removeAllRanges(); + (items[0] as { run: () => void }).run(); + await Promise.resolve(); + expect(writeText).toHaveBeenCalledWith("Hello"); + }); + + it("falls back to execCommand when the clipboard API is missing", async () => { + Object.defineProperty(navigator, "clipboard", { value: undefined, configurable: true }); + const exec = vi.fn().mockReturnValue(true); + document.execCommand = exec; + await copyText("x"); + expect(exec).toHaveBeenCalledWith("copy"); + }); +}); diff --git a/src/ui/selectableText.ts b/src/ui/selectableText.ts new file mode 100644 index 00000000..3d8c53ad --- /dev/null +++ b/src/ui/selectableText.ts @@ -0,0 +1,54 @@ +/** + * Read-only text the user can select and copy, like IDEA's Markdown preview, + * Quick Documentation and message panes. The workbench body is unselectable + * (chrome); content opts back in with `selectableText`. A right-click on a + * selection, or anywhere in such content, offers Copy (`copyMenuFor`). + */ +import { formatBinding } from "@/platform/keymap"; +import type { MenuItem } from "./Menu"; +import styles from "./SelectableText.module.css"; + +export const selectableText = styles.selectable!; + +const EDITABLE = "input, textarea, [contenteditable=''], [contenteditable='true'], [contenteditable='plaintext-only']"; + +function selectionCovers(sel: Selection | null, target: Node): boolean { + if (!sel || sel.isCollapsed || !sel.toString()) return false; + for (let i = 0; i < sel.rangeCount; i++) { + const range = sel.getRangeAt(i); + if (range.intersectsNode(target) || target.contains(range.commonAncestorContainer)) return true; + } + return false; +} + +/** The Copy menu for a right-click on `target`, or null when it is not on readable text. */ +export function copyMenuFor(target: EventTarget | null): MenuItem[] | null { + const el = target instanceof Element ? target : target instanceof Node ? target.parentElement : null; + if (!el || el.closest(EDITABLE)) return null; + const sel = window.getSelection(); + const text = selectionCovers(sel, el) ? sel!.toString() : ""; + if (!text && !el.closest(`.${selectableText}`)) return null; + return [ + { + id: "copy", + label: "Copy", + icon: "copy", + shortcut: formatBinding("Meta+C"), + disabled: !text, + run: () => void copyText(text), + }, + ]; +} + +/** Writes `text` to the clipboard; falls back to copying the live selection. */ +export async function copyText(text: string): Promise { + try { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(text); + return; + } + } catch { + // Clipboard API refused (permissions): the selection is still there. + } + document.execCommand("copy"); +} diff --git a/src/workbench/browserGuards.test.ts b/src/workbench/browserGuards.test.ts new file mode 100644 index 00000000..d76c38d8 --- /dev/null +++ b/src/workbench/browserGuards.test.ts @@ -0,0 +1,59 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { hideContextMenu, selectableText, showContextMenu, useContextMenuStore } from "@/ui"; +import { installBrowserGuards } from "./browserGuards"; + +let dispose: () => void; + +beforeEach(() => { + dispose = installBrowserGuards({ blockReload: false }); +}); + +afterEach(() => { + dispose(); + hideContextMenu(); + window.getSelection()?.removeAllRanges(); + document.body.innerHTML = ""; +}); + +function rightClick(el: Element) { + const e = new MouseEvent("contextmenu", { bubbles: true, cancelable: true, clientX: 5, clientY: 6 }); + el.dispatchEvent(e); + return e; +} + +describe("browser guards: context menu", () => { + it("never shows the native menu, and nothing on chrome", () => { + document.body.innerHTML = ``; + const e = rightClick(document.getElementById("b")!); + expect(e.defaultPrevented).toBe(true); + expect(useContextMenuStore.getState().menu).toBeNull(); + }); + + it("shows Copy on selectable read-only text (issue #36)", () => { + document.body.innerHTML = `

Hello

`; + const range = document.createRange(); + range.selectNodeContents(document.getElementById("p")!); + window.getSelection()!.addRange(range); + const e = rightClick(document.getElementById("p")!); + expect(e.defaultPrevented).toBe(true); + const menu = useContextMenuStore.getState().menu; + expect(menu).toMatchObject({ x: 5, y: 6 }); + expect(menu!.items[0]).toMatchObject({ label: "Copy", disabled: false }); + }); + + it("keeps a component's own context menu", () => { + document.body.innerHTML = `

Hello

`; + const p = document.getElementById("p")!; + p.addEventListener("contextmenu", (e) => showContextMenu(e, [{ label: "Own" }])); + rightClick(p); + expect(useContextMenuStore.getState().menu!.items[0]).toMatchObject({ label: "Own" }); + }); + + it("keeps out when a handler already prevented the default", () => { + document.body.innerHTML = `

Hello

`; + const p = document.getElementById("p")!; + p.addEventListener("contextmenu", (e) => e.preventDefault()); + rightClick(p); + expect(useContextMenuStore.getState().menu).toBeNull(); + }); +}); diff --git a/src/workbench/browserGuards.ts b/src/workbench/browserGuards.ts index 43d4f046..167cb1c8 100644 --- a/src/workbench/browserGuards.ts +++ b/src/workbench/browserGuards.ts @@ -2,8 +2,10 @@ * Makes the webview behave like a desktop app: never the browser context menu * (its Reload/AutoFill items would act on the webview), no reload/print/ * save-page/history shortcuts, no navigation on file drop. Copy/paste/undo - * shortcuts stay untouched. + * shortcuts stay untouched. A right-click no component handled on selected or + * selectable read-only text gets IDEA's Copy menu instead of nothing. */ +import { copyMenuFor, showContextMenu, useContextMenuStore } from "@/ui"; const BLOCKED = new Set([ "Meta+P", @@ -35,7 +37,17 @@ function keyId(e: KeyboardEvent) { export function installBrowserGuards(opts: { blockReload?: boolean } = {}): () => void { const blockReload = opts.blockReload ?? import.meta.env.PROD; - const onContextMenu = (e: MouseEvent) => e.preventDefault(); + let menuBefore: unknown = null; + const beforeContextMenu = () => { + menuBefore = useContextMenuStore.getState().menu; + }; + const onContextMenu = (e: MouseEvent) => { + const handled = e.defaultPrevented || useContextMenuStore.getState().menu !== menuBefore; + e.preventDefault(); + if (handled) return; + const items = copyMenuFor(e.target); + if (items) showContextMenu(e, items); + }; const onKeyDown = (e: KeyboardEvent) => { const id = keyId(e); if (BLOCKED.has(id) || (blockReload && RELOAD.has(id))) e.preventDefault(); @@ -43,11 +55,13 @@ export function installBrowserGuards(opts: { blockReload?: boolean } = {}): () = const onDrag = (e: DragEvent) => { if (!(e.target as Element | null)?.closest?.("[data-drop-target]")) e.preventDefault(); }; + window.addEventListener("contextmenu", beforeContextMenu, true); window.addEventListener("contextmenu", onContextMenu); window.addEventListener("keydown", onKeyDown); window.addEventListener("dragover", onDrag); window.addEventListener("drop", onDrag); return () => { + window.removeEventListener("contextmenu", beforeContextMenu, true); window.removeEventListener("contextmenu", onContextMenu); window.removeEventListener("keydown", onKeyDown); window.removeEventListener("dragover", onDrag);