From 69fa7f7387e08f04bb6d33b8129d94a7d1f25ed9 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Wed, 28 Oct 2026 08:24:05 +0800 Subject: [PATCH] feat(editor): Quick fixes, completion and navigation while typing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Quick fixes - Errors get a red quick-fix bulb; Apply Quick Fix (⌥⇧↵) fixes the most severe problem at the caret, and the error hover offers the fix directly. - Code generated by a code action is re-indented to the file's style. - Python: Implement abstract methods (⌃I or the bulb) from the bases. - Duplicate code actions are listed once. Completion - Overloads sharing a name, and a name importable from several modules, are no longer merged into one entry. - Auto-import entries show the module they import from. - Call snippets without a stop (`new ArrayList<>()`) leave the caret inside the parentheses with parameter info, or past them when nothing takes arguments; parameter info also opens after server call snippets. - The selected item is resolved ahead of acceptance, so its auto-import is applied (rust-analyzer). Navigation and diagnostics - Language server requests wait for pending edits, so Go to Declaration right after typing answers for the current text; an empty answer right after an edit is retried while the server catches up. - Diagnostics come back after a window reload; error underlines stay visible under typo / unused highlighting. - Implementation gutter markers no longer refresh in a loop while the server reports progress. - A missing alternative server no longer shows as a server error. --- src/editor/api.ts | 6 + src/editor/lsp/Popups.module.css | 4 - src/editor/lsp/Popups.tsx | 2 +- src/editor/lsp/actions.ts | 13 +- src/editor/lsp/availability.ts | 24 +++ src/editor/lsp/completion.test.ts | 49 ++++- src/editor/lsp/completion.ts | 77 ++++++- src/editor/lsp/completionItems.ts | 30 +++ src/editor/lsp/diagnostics.ts | 24 ++- src/editor/lsp/format.test.ts | 10 +- src/editor/lsp/format.ts | 49 ++++- src/editor/lsp/hover.ts | 26 ++- src/editor/lsp/intentions.test.ts | 63 +++++- src/editor/lsp/intentions.ts | 176 ++++++++++++++-- src/editor/lsp/lsp.test.ts | 29 +++ src/editor/lsp/lsp.ts | 4 +- src/editor/lsp/navigation.ts | 21 +- src/editor/lsp/pythonMembers.test.ts | 50 +++++ src/editor/lsp/pythonMembers.ts | 192 ++++++++++++++++++ src/editor/lsp/signature.ts | 19 ++ src/editor/lsp/syntaxFallback.test.ts | 1 + src/editor/lsp/theme.ts | 8 +- src/editor/navigation/implementationGutter.ts | 6 +- src/features/code-insight/ServerStatus.tsx | 7 +- src/features/code-insight/servers.test.ts | 17 +- src/features/code-insight/servers.ts | 15 +- src/platform/ipc.ts | 6 + src/platform/keymaps/intellij-macos.ts | 1 + src/platform/keymaps/intellij-windows.ts | 1 + src/platform/keymaps/vscode-macos.ts | 1 + src/platform/locales/text/zh.json | 4 + src/ui/expui/codeInsight/quickfixBulb.svg | 6 + .../expui/codeInsight/quickfixBulb_dark.svg | 6 + src/ui/icons.tsx | 1 + 34 files changed, 889 insertions(+), 59 deletions(-) create mode 100644 src/editor/lsp/lsp.test.ts create mode 100644 src/editor/lsp/pythonMembers.test.ts create mode 100644 src/editor/lsp/pythonMembers.ts create mode 100644 src/ui/expui/codeInsight/quickfixBulb.svg create mode 100644 src/ui/expui/codeInsight/quickfixBulb_dark.svg diff --git a/src/editor/api.ts b/src/editor/api.ts index 78892027d..83bd11925 100644 --- a/src/editor/api.ts +++ b/src/editor/api.ts @@ -192,6 +192,12 @@ export function whenSynced(view: EditorView): Promise { return view.state.facet(documentSynced)?.() ?? Promise.resolve(); } +/** Every open editor's edits reached the backend (language servers answer from its copy). */ +export function whenAllSynced(): Promise { + const editors = useEditorRegistry.getState().editors; + return editors.size ? Promise.all([...editors].map((e) => whenSynced(e.view))).then(() => {}) : Promise.resolve(); +} + /** True for doc changes that did not originate from typing in *this* view. */ export function isExternalChange(tr: Transaction): boolean { return !!tr.annotation(remoteChange) || !!tr.annotation(mirroredChange); diff --git a/src/editor/lsp/Popups.module.css b/src/editor/lsp/Popups.module.css index c2fd3d95e..1a7f2965e 100644 --- a/src/editor/lsp/Popups.module.css +++ b/src/editor/lsp/Popups.module.css @@ -178,10 +178,6 @@ } .bulb { flex: none; - color: var(--warning); -} -.bulbFix { - color: var(--danger); } .empty { font-size: var(--fs-ui); diff --git a/src/editor/lsp/Popups.tsx b/src/editor/lsp/Popups.tsx index 4873c5463..d302dbada 100644 --- a/src/editor/lsp/Popups.tsx +++ b/src/editor/lsp/Popups.tsx @@ -44,7 +44,7 @@ import { useUsagesStatus } from "./navigation"; import styles from "./Popups.module.css"; export function BulbIcon({ fix }: { fix?: boolean }) { - return ; + return ; } function ItemIcon({ item }: { item: ListPopupItem }) { diff --git a/src/editor/lsp/actions.ts b/src/editor/lsp/actions.ts index 3f84c521e..158247ecc 100644 --- a/src/editor/lsp/actions.ts +++ b/src/editor/lsp/actions.ts @@ -10,7 +10,8 @@ import { quickDefinition, quickDocumentation } from "./hover"; import { expressionTypeInfo } from "./typeInfo"; import { gotoTargets, highlightUsages } from "./navigation"; import { gotoDiagnostic } from "./diagnostics"; -import { applyKindSilently, runKindAction, showIntentions } from "./intentions"; +import { applyKindSilently, applyQuickFix, runKindAction, showIntentions } from "./intentions"; +import { implementPythonAbstractMethods } from "./pythonMembers"; import { workflowSettings, type ActionsOnSaveValid } from "@/platform/workflowSettings"; import { startRename } from "./rename"; import { formatDocument, reformatDialog } from "./format"; @@ -154,6 +155,9 @@ export function registerLspActions(): () => void { editorAction("code.showIntentions", "Show Context Actions", "Code", showIntentions, { keywords: ["intention", "quick fix", "lightbulb"], }), + editorAction("code.applyQuickFix", "Apply Quick Fix", "Code", applyQuickFix, { + keywords: ["fix error", "problem", "lightbulb"], + }), editorAction("code.reformat", "Reformat Code", "Code", reformat, { keywords: ["format", "prettier"], }), @@ -228,7 +232,12 @@ export function registerLspActions(): () => void { // Implement / Override Methods: the server's code actions for them (jdtls "Override/Implement // Methods…", rust-analyzer "Implement missing members", TS "Implement interface"…). editorAction("code.implementMethods", "Implement Methods…", "Code", (v) => - runKindAction(v, "", { title: IMPLEMENT_TITLE, none: "Nothing to implement", label: "Implement Members" }), + runKindAction(v, "", { + title: IMPLEMENT_TITLE, + none: "Nothing to implement", + label: "Implement Members", + fallback: () => implementPythonAbstractMethods(v), + }), ), editorAction("code.overrideMethods", "Override Methods…", "Code", (v) => runKindAction(v, "", { title: OVERRIDE_TITLE, none: "Nothing to override", label: "Override Members" }), diff --git a/src/editor/lsp/availability.ts b/src/editor/lsp/availability.ts index f7ecef2e3..b63caab35 100644 --- a/src/editor/lsp/availability.ts +++ b/src/editor/lsp/availability.ts @@ -46,6 +46,30 @@ export function onLspRefresh(kind: LspRefreshKind, fn: () => void): () => void { }; } +/** A busy spell at least this long is indexing / a build, not the server answering a request. */ +const LONG_BUSY_MS = 1500; + +/** + * Calls `fn` when a server becomes ready after starting or after a long busy + * spell (indexing), when earlier answers may have been incomplete. Short busy + * spells are ignored: jdtls reports progress for the very requests a caller + * sends, and re-asking on each of them would never stop. + */ +export function onServerSettled(fn: () => void): () => void { + if (!isTauri()) return () => {}; + const since = new Map(); + return onIdeEvent("languageServer", (e) => { + if (e.status === "starting") since.set(e.server, -Infinity); + else if (e.status === "busy") { + if (!since.has(e.server)) since.set(e.server, Date.now()); + } else if (e.status === "ready") { + const t = since.get(e.server); + since.delete(e.server); + if (t !== undefined && Date.now() - t >= LONG_BUSY_MS) fn(); + } else since.delete(e.server); + }); +} + export function installAvailabilityEvents(): () => void { return onIdeEvent("languageServer", (e) => { if (e.status === "ready") serverBackoff.clear(); diff --git a/src/editor/lsp/completion.test.ts b/src/editor/lsp/completion.test.ts index 047dcf47b..7a0ce18cc 100644 --- a/src/editor/lsp/completion.test.ts +++ b/src/editor/lsp/completion.test.ts @@ -1,8 +1,10 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { EditorState } from "@codemirror/state"; +import { EditorState, StateEffect } from "@codemirror/state"; import { EditorView } from "@codemirror/view"; import type { CompletionItem } from "@/bindings"; -import { completionSymbolKind, identifierEnd, toCmCompletion, triggerCharacter } from "./completionItems"; +import type { EditorDocContext } from "@/editor/api"; +import { lspDoc } from "./lsp"; +import { completionSymbolKind, identifierEnd, toCmCompletion, triggerCharacter, typeColumnText } from "./completionItems"; import { applyLspCompletion, callParens, @@ -122,12 +124,31 @@ describe("applying completions", () => { expect(view.state.selection.main.head).toBe(14); }); + it("keeps overloads sharing a filterText apart", () => { + const { req } = setup("x.ti|"); + const [a, b] = ["times(other: Int)", "times(other: Long)"].map((label) => + makeCompletion(item({ label, filterText: "times" }), req), + ); + expect([a!.label, b!.label]).toEqual(["times", "times"]); + expect(a!.apply).not.toBe(b!.apply); + }); + it("replaces the rest of the identifier with ⇥", () => { const { view, req } = setup("service.cr|Order()"); applyLspCompletion(view, makeCompletion(item({ insertText: "create" }), req), 8, 10, "replace"); expect(view.state.doc.toString()).toBe("service.create()"); }); + it("leaves the caret inside a call snippet without stops, past `)` once nothing takes arguments", async () => { + const { view, req } = setup("new Arr|"); + view.dispatch({ effects: StateEffect.appendConfig.of(lspDoc.of({ doc: 1 } as EditorDocContext)) }); + apply(view, makeCompletion(item({ kind: "constructor", insertText: "ArrayList<>()", isSnippet: true }), req), 4); + expect(view.state.doc.toString()).toBe("new ArrayList<>()"); + expect(view.state.selection.main.head).toBe(16); + // Signature help fails outside Tauri: nothing to pass. + await vi.waitFor(() => expect(view.state.selection.main.head).toBe(17)); + }); + it("uses the server's ⇥ range when given", () => { const { view, req } = setup("a.cr|Order.x"); const c = makeCompletion( @@ -280,3 +301,27 @@ describe("explicit completion", () => { view.destroy(); }); }); + +describe("typeColumnText", () => { + const edit = (new_text: string) => [{ range: { start: { line: 0, col: 0 }, end: { line: 0, col: 0 } }, new_text }]; + + it("names the module an auto-import comes from", () => { + expect(typeColumnText({ detail: "Auto-import", additionalEdits: edit("\nfrom collections import OrderedDict") })).toBe( + "collections", + ); + expect(typeColumnText({ detail: "Map", additionalEdits: edit("import java.util.HashMap;\n") })).toBe( + "Map (java.util)", + ); + expect(typeColumnText({ detail: null, additionalEdits: edit("use std::collections::HashMap;\n") })).toBe( + "std::collections", + ); + expect(typeColumnText({ detail: null, additionalEdits: edit('import { a } from "./money";\n') })).toBe("./money"); + }); + + it("keeps a detail that already says where", () => { + expect(typeColumnText({ detail: "java.util.ArrayList", additionalEdits: edit("import java.util.ArrayList;\n") })).toBe( + "java.util.ArrayList", + ); + expect(typeColumnText({ detail: "fn() ", additionalEdits: [] })).toBe("fn() "); + }); +}); diff --git a/src/editor/lsp/completion.ts b/src/editor/lsp/completion.ts index 8b0213f7c..c360b5830 100644 --- a/src/editor/lsp/completion.ts +++ b/src/editor/lsp/completion.ts @@ -35,14 +35,14 @@ import { type RequestId, } from "@/platform/ipc"; import { useProject } from "@/platform/project"; -import { completionSymbolKind, identifierEnd, toCmCompletion, triggerCharacter } from "./completionItems"; +import { completionSymbolKind, identifierEnd, toCmCompletion, triggerCharacter, typeColumnText } 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 { parameterInfoOrLeave, triggerSignatureHelp } from "./signature"; import { serverBackoff } from "./availability"; import { pathCompletionSource } from "./fileReferences"; import { editorPrefs } from "@/platform/editorSettings"; @@ -71,17 +71,42 @@ const isLsp = (c: Completion): c is LspCompletion => "lsp" in c; // Resolve (documentation, lazily computed edits) const resolving = new WeakMap>(); +/** Items whose resolve has answered, for accepting them synchronously. */ +const resolvedItems = new WeakMap(); function resolveItem(doc: number, item: CompletionItem): Promise { if (item.data == null && !isEmptyMarkup(item.documentation)) return Promise.resolve(item); let p = resolving.get(item); if (!p) { - p = lspRequest(() => commands.lspResolveCompletion(doc, item)).catch(() => item); + p = lspRequest(() => commands.lspResolveCompletion(doc, item)).then( + (r) => { + resolvedItems.set(item, r); + return r; + }, + () => item, + ); resolving.set(item, p); } return p; } +/** + * Resolves the selected lookup item ahead of acceptance: some servers compute + * its extra edits (rust-analyzer's `use` import) only on resolve, against the + * text as it was, so resolving after the insertion finds nothing. + */ +const prefetchResolve = ViewPlugin.fromClass( + class { + private last: Completion | null = null; + update(u: ViewUpdate) { + const c = selectedCompletion(u.state); + if (c === this.last) return; + this.last = c; + if (c && isLsp(c) && c.lsp.data != null && !c.lsp.additionalEdits.length) void resolveItem(c.req.doc, c.lsp); + } + }, +); + /** * F1 in the lookup (IDEA's documentation popup next to the lookup): docs then * follow the selection until the lookup closes. Without the auto-popup setting @@ -166,7 +191,7 @@ export function applyLspCompletion( ) { if (!isLsp(completion)) return; const { req } = completion; - const item = completion.resolved ?? completion.lsp; + const item = completion.resolved ?? resolvedItems.get(completion.lsp) ?? completion.lsp; const state = view.state; const delta = to - req.pos; const mapOld = (offset: number) => @@ -191,6 +216,7 @@ export function applyLspCompletion( .filter((c) => c.to <= start || c.from >= end); let insertedParens: ReturnType = null; + let bareCall: number | null = null; if (item.isSnippet) { if (extra.length) { const tr = state.update({ changes: extra, userEvent: "input.complete" }); @@ -199,6 +225,19 @@ export function applyLspCompletion( end = tr.changes.mapPos(end, 1); } snippet(lspSnippetToCm(item.insertText, snippetVariables(req.path)))(view, completion, start, end); + // A call snippet without a stop (`ArrayList<>()`): the caret goes inside until + // signature help says there is nothing to pass. + const head = view.state.selection.main.head; + if ( + editorPrefs().completion.insertParentheses && + CALLABLE.has(item.kind ?? "") && + /\(\)$/.test(item.insertText) && + !/\$/.test(item.insertText) && + view.state.sliceDoc(head - 2, head) === "()" + ) { + view.dispatch({ selection: EditorSelection.cursor(head - 1) }); + bareCall = head - 1; + } } else { const parens = editorPrefs().completion.insertParentheses ? callParens(item, state.doc.lineAt(start), start, end) @@ -215,9 +254,15 @@ export function applyLspCompletion( }); } - if (!completion.resolved && !item.additionalEdits.length && item.data != null) + if (item === completion.lsp && !item.additionalEdits.length && item.data != null) void applyLateEdits(view, completion, start); - if (insertedParens === "inside" && !item.command) setTimeout(() => triggerSignatureHelp(view), 0); + // Parameter info for a call left open, plain (`()` added here) or a server snippet (`fly(${1})`). + const callSnippet = item.isSnippet && CALLABLE.has(item.kind ?? "") && /\(\$\{?\d/.test(item.insertText); + // Unless the item's command asks for it itself (other commands, like jdtls's + // `java.completion.onDidSelect`, are bookkeeping). + const hintsCommand = item.command?.command === "editor.action.triggerParameterHints"; + if ((insertedParens === "inside" || callSnippet) && !hintsCommand) setTimeout(() => triggerSignatureHelp(view), 0); + else if (bareCall !== null) void parameterInfoOrLeave(view, bareCall); runCommand(view, item); } @@ -314,7 +359,11 @@ export function makeCompletion(item: CompletionItem, req: RequestContext): LspCo return Object.assign(cm, { lsp: item, req, - apply: applyLsp, + // CodeMirror merges options with the same label, detail and `apply`: overloads + // sharing a filterText (`times(other: Int)`, `times(other: Long)`…) and the same + // name imported from different modules need their own. + apply: + cm.displayLabel || item.additionalEdits.length ? (...a: Parameters) => applyLsp(...a) : applyLsp, info: (c: Completion) => renderInfo(c as LspCompletion), }); } @@ -383,9 +432,16 @@ async function lspCompletionSource(ctx: CompletionContext): Promise matchesCase(it.filterText ?? it.label, typed, prefs.caseSensitivity)) : list.items; + // A server snippet named like a live template (jdtls `sout`, `fori`) would be listed twice; the template wins. + if (items.some((it) => it.kind === "snippet")) { + const own = await contributedSource(ctx); + if (ctx.aborted) return null; + const names = new Set(own?.options.map((o) => o.label)); + if (names.size) items = items.filter((it) => it.kind !== "snippet" || !names.has(it.label)); + } return { from: word ? word.from : ctx.pos, options: items.map((item) => makeCompletion(item, req)), @@ -616,11 +672,11 @@ function kindBadge(c: Completion): Node | null { } function typeColumn(c: Completion): Node | null { - const detail = isLsp(c) ? c.lsp.detail : (c as RichCompletion).typeText; + const detail = isLsp(c) ? typeColumnText(c.lsp) : (c as RichCompletion).typeText?.replace(/\s+/g, " "); if (!detail) return null; const span = document.createElement("span"); span.className = "cm-lsp-type"; - span.textContent = detail.replace(/\s+/g, " "); + span.textContent = detail; return span; } @@ -684,6 +740,7 @@ const completionFooter = ViewPlugin.fromClass( export function completionExtension() { return [ lookupKeys, + prefetchResolve, autocompletion({ override: [gated(lspCompletionSource), gated(contributedSource), gated(pathCompletionSource)], icons: false, diff --git a/src/editor/lsp/completionItems.ts b/src/editor/lsp/completionItems.ts index 1064660b4..36ce274d7 100644 --- a/src/editor/lsp/completionItems.ts +++ b/src/editor/lsp/completionItems.ts @@ -55,6 +55,36 @@ export function toCmCompletion(item: CompletionItem): Completion { return c; } +const IMPORT_SOURCE = [ + /^\s*from\s+([\w.]+)\s+import\b/m, // Python + /^\s*import\s+(?:static\s+)?([\w.]+?)\.\w+;?\s*$/m, // Java, Kotlin + /^\s*use\s+([\w:]+)::/m, // Rust + /\bfrom\s+["']([^"']+)["']/, // JS / TS +]; + +/** + * Where an auto-import item comes from (`collections`, `java.util`): the + * module its import edit names. Servers like pyright put it nowhere else, so + * same-named items (`OrderedDict` from typing / collections) look identical. + */ +export function importSource(item: Pick): string | null { + for (const e of item.additionalEdits) { + for (const re of IMPORT_SOURCE) { + const m = re.exec(e.new_text); + if (m) return m[1]!; + } + } + return null; +} + +/** Text of the right-aligned type column: the detail, with the import source when it lacks one. */ +export function typeColumnText(item: Pick): string | null { + const detail = item.detail?.replace(/\s+/g, " ") || null; + const source = importSource(item); + if (!source || detail?.includes(source)) return detail; + return !detail || /^auto[- ]?import$/i.test(detail) ? source : `${detail} (${source})`; +} + const WORD_CHAR = /[\p{L}\p{N}_$]/u; /** End of the identifier starting at `pos` in `line` (for ⇥ Replace without a server range). */ diff --git a/src/editor/lsp/diagnostics.ts b/src/editor/lsp/diagnostics.ts index a056182d4..320f3e17f 100644 --- a/src/editor/lsp/diagnostics.ts +++ b/src/editor/lsp/diagnostics.ts @@ -9,7 +9,8 @@ import { EditorSelection, StateEffect, StateField } from "@codemirror/state"; import { EditorView, ViewPlugin } from "@codemirror/view"; import { forEachDiagnostic, linter, setDiagnostics, type Diagnostic as LintDiagnostic } from "@codemirror/lint"; import type { Diagnostic, Severity } from "@/bindings"; -import { onIdeEvent } from "@/platform/ipc"; +import { commands, ideEventsReady, isTauri, onIdeEvent } from "@/platform/ipc"; +import { useProject } from "@/platform/project"; import { publishLocalDiagnostics } from "@/platform/localDiagnostics"; import { scrollbarMarks, type ScrollbarMark } from "@/editor/api"; import { rangeToOffsets } from "./positions"; @@ -89,11 +90,20 @@ export function subscribeDiagnostics(path: string, l: Listener): () => void { export function installDiagnosticsEvents(): () => void { const off = onIdeEvent("diagnostics", (e) => storeDiagnostics(e.path, e.source, e.items)); + // Diagnostics published before this window (re)loaded were missed: ask for them again. + const resend = () => { + if (isTauri()) void ideEventsReady().then(() => commands.lspResendDiagnostics()).catch(() => {}); + }; + if (useProject.getState().project) resend(); + const offProject = useProject.subscribe((s, prev) => { + if (s.project?.root !== prev.project?.root && s.project) resend(); + }); // Another inspection profile (or edits to it) re-filters every open file. const offProfile = subscribeInspectionProfile(() => listeners.forEach((set) => set.forEach((l) => l()))); return () => { off(); offProfile(); + offProject(); }; } @@ -118,13 +128,16 @@ export function toLintDiagnostics(doc: Text, items: readonly Diagnostic[]): Lint to, severity: d.severity, message: d.message, - source: [d.source, d.code].filter(Boolean).join(" ") || undefined, + source: [d.source, isInternalCode(d.code) ? null : d.code].filter(Boolean).join(" ") || undefined, markClass: classes.length ? classes.join(" ") : undefined, }; }); return out.sort((a, b) => a.from - b.from || SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity]); } +/** Problem ids that mean nothing to a reader (jdtls: `67109264`); short ones (TS `2322`) stay. */ +const isInternalCode = (code: string | null | undefined) => !!code && /^\d{6,}$/.test(code); + const MARK_COLOR: Partial> = { error: "var(--danger)", warning: "var(--warning)", @@ -218,6 +231,13 @@ const lintTheme = EditorView.theme({ }, ".cm-lintRange.cm-lsp-unnecessary *": { color: "var(--fg-unused, var(--fg-faint))" }, ".cm-lintRange.cm-lsp-deprecated": { textDecoration: "line-through", textDecorationColor: "var(--fg-muted)" }, + // An error or warning on the same range (merged into one mark) keeps its underline. + ".cm-lintRange-error.cm-lsp-typo, .cm-lintRange-error.cm-lsp-unnecessary": { + textDecoration: "var(--editor-error-decoration, underline wavy var(--danger))", + }, + ".cm-lintRange-warning.cm-lsp-typo, .cm-lintRange-warning.cm-lsp-unnecessary": { + textDecoration: "var(--editor-warning-decoration, underline wavy var(--warning))", + }, ".cm-lintRange-active": { backgroundColor: "transparent" }, ".cm-lintRange-warning.cm-lintRange-active": { backgroundColor: "var(--editor-warning-background, transparent)" }, ".cm-lintPoint:after": { display: "none" }, diff --git a/src/editor/lsp/format.test.ts b/src/editor/lsp/format.test.ts index eb66b954a..75c31b429 100644 --- a/src/editor/lsp/format.test.ts +++ b/src/editor/lsp/format.test.ts @@ -7,7 +7,7 @@ import { protectedRegions, type FormatGuardOptions, } from "@/platform/formatGuard"; -import { formattedLines } from "./format"; +import { dedentsToColumnZero, formattedLines } from "./format"; describe("formattedLines", () => { const doc = Text.of(["fn main(){", "let x=1;", "}", "", "fn other() {}"]); @@ -97,3 +97,11 @@ describe("formatter markers and Do not format", () => { expect(formatExcluded("/p/other/docs.md", "/p", globs)).toBe(false); }); }); + +describe("dedentsToColumnZero", () => { + it("spots range formatting done without the surrounding code", () => { + expect(dedentsToColumnZero("\n override fun f() { }\n", "\noverride fun f() {}\n")).toBe(true); + expect(dedentsToColumnZero("\n\tfun f() {\n\t}\n", "\n fun f() {\n }\n")).toBe(false); + expect(dedentsToColumnZero("fun f() {}\n", "fun f() { }\n")).toBe(false); + }); +}); diff --git a/src/editor/lsp/format.ts b/src/editor/lsp/format.ts index 013e94e89..64d299699 100644 --- a/src/editor/lsp/format.ts +++ b/src/editor/lsp/format.ts @@ -7,7 +7,7 @@ */ import { indentUnit } from "@codemirror/language"; import { isolateHistory } from "@codemirror/commands"; -import type { Text } from "@codemirror/state"; +import type { EditorState, Text } from "@codemirror/state"; import type { EditorView } from "@codemirror/view"; import { commands } from "@/platform/ipc"; import { shortcutLabel } from "@/platform/keymap"; @@ -63,6 +63,12 @@ function formattedHint(view: EditorView, lines: number) { }); } +/** LSP `FormattingOptions`: with spaces the indent width is the indent unit, not the tab size. */ +export function formatOptionsOf(state: EditorState): { tabSize: number; insertSpaces: boolean } { + const unit = state.facet(indentUnit); + return unit.includes("\t") ? { tabSize: state.tabSize, insertSpaces: false } : { tabSize: unit.length || state.tabSize, insertSpaces: true }; +} + export async function formatDocument( view: EditorView, opts: { explicit: boolean; selectionOnly?: boolean } = { explicit: true }, @@ -90,7 +96,7 @@ async function format(view: EditorView, opts: { explicit: boolean; selectionOnly const sel = state.selection.main; const useSelection = (opts.selectionOnly ?? true) && !sel.empty; const range = useSelection ? { start: offsetToPos(state.doc, sel.from), end: offsetToPos(state.doc, sel.to) } : null; - const options = { tabSize: state.tabSize, insertSpaces: !state.facet(indentUnit).includes("\t") }; + const options = formatOptionsOf(state); let edits; try { edits = await lspRequest(() => commands.lspFormat(doc.doc, range, options)); @@ -116,6 +122,45 @@ async function format(view: EditorView, opts: { explicit: boolean; selectionOnly return { applied: true, changes: changes.length, lines: formattedLines(state.doc, changes) }; } +/** Whether `after` has a non-blank line at column 0 where all of `before` was indented. */ +export function dedentsToColumnZero(before: string, after: string): boolean { + const minIndent = (t: string) => + Math.min(...t.split("\n").filter((l) => l.trim()).map((l) => l.length - l.trimStart().length)); + return minIndent(before) > 0 && minIndent(after) === 0; +} + +/** + * Reformats lines `from..to` silently (generated code: quick fixes, Generate), + * merged into the edit's undo step. False when the server can't format ranges. + */ +export async function formatLines(view: EditorView, from: number, to: number): Promise { + const doc = docOf(view); + if (!doc || doc.readOnly) return false; + const { project, settings } = useProject.getState(); + if (formatExcluded(doc.path, project?.root ?? null, formatGuardOptions(settings).doNotFormat)) return false; + await whenSynced(view); + const state = view.state; + const range = { start: offsetToPos(state.doc, from), end: offsetToPos(state.doc, to) }; + const options = formatOptionsOf(state); + let edits; + try { + edits = await lspRequest(() => commands.lspFormat(doc.doc, range, options)); + } catch { + return false; + } + if (view.state.doc !== state.doc) return false; + // Only inside the range: some servers answer a range request with whole-file edits. + const changes = textEditsToChanges(state.doc, edits).filter((c) => c.from >= from && c.to <= to && state.sliceDoc(c.from, c.to) !== c.insert); + if (!changes.length) return true; + // A server formatting the range out of context (kotlin-language-server) moves + // indented code to column 0: its answer is ignored then. + const delta = changes.reduce((d, c) => d + c.insert.length - (c.to - c.from), 0); + const next = state.update({ changes }).state.doc; + if (dedentsToColumnZero(state.sliceDoc(from, to), next.sliceString(from, to + delta))) return false; + view.dispatch({ changes, userEvent: "format", scrollIntoView: false }); + return true; +} + function applyContributedFormat( view: EditorView, format: ContributedFormatter, diff --git a/src/editor/lsp/hover.ts b/src/editor/lsp/hover.ts index 955efc28e..30855551e 100644 --- a/src/editor/lsp/hover.ts +++ b/src/editor/lsp/hover.ts @@ -31,6 +31,7 @@ import { docWindowVisible, moveDocPopupToWindow, showInDocWindow } from "./docs" import { structure } from "@/editor/structure"; import { basename } from "@/platform/project"; import { iconNode } from "./iconMarkup"; +import { applyCodeAction, quickFixesAt } from "./intentions"; import { editorPrefs } from "@/platform/editorSettings"; import { CONTRIBUTED_HOVER, contributedAt, type ContributedHover } from "@/editor/api"; import { navigationModifierHeld, onNavigationModifier, pointerTarget } from "@/platform/navigationModifier"; @@ -60,7 +61,7 @@ export async function requestHover(view: EditorView, pos: number): Promise e.preventDefault()); fix.addEventListener("click", () => { view.dispatch({ selection: EditorSelection.cursor(from) }); @@ -100,6 +102,28 @@ function renderDiagnostics(view: EditorView, diags: PlacedDiagnostic[]): HTMLEle return box; } +/** IDEA's hover link to the problem's preferred quick fix, shown once the server named one. */ +function offerQuickFix(view: EditorView, meta: HTMLElement, before: HTMLElement, from: number, to: number) { + const doc = view.state.doc; + void quickFixesAt(view, from, to) + .then(([action]) => { + if (!action || view.state.doc !== doc || !before.isConnected) return; + const link = document.createElement("button"); + link.type = "button"; + link.className = "cm-lsp-link-button"; + const keys = shortcutLabel("code.applyQuickFix"); + link.textContent = keys ? `${action.title} ${keys}` : action.title; + link.addEventListener("mousedown", (e) => e.preventDefault()); + link.addEventListener("click", () => { + view.dispatch({ effects: closeHoverTooltips }); + view.focus(); + void applyCodeAction(view, action); + }); + meta.insertBefore(link, before); + }) + .catch(() => {}); +} + /** * Whether the plain hover (problems + quick documentation) stays out: while * ⌘/Ctrl is held the ⌘-hover link and its brief info (`navigation.ts`) own the diff --git a/src/editor/lsp/intentions.test.ts b/src/editor/lsp/intentions.test.ts index 6c83df20b..17feebb28 100644 --- a/src/editor/lsp/intentions.test.ts +++ b/src/editor/lsp/intentions.test.ts @@ -28,7 +28,8 @@ vi.mock("@/platform/ipc", async (orig) => { return { ...actual, isTauri: () => true, commands: { ...actual.commands, lspResolveCodeAction } }; }); -const { intentionPreview, withInspectionControls } = await import("./intentions"); +const { bulbLine, changedLines, indentUnderBrace, intentionPreview, lightsBulb, listedCodeActions, withInspectionControls } = + await import("./intentions"); const { setFileDiagnostics } = await import("./diagnostics"); const { lspDoc } = await import("./lsp"); @@ -109,3 +110,63 @@ describe("intention preview", () => { expect(lspResolveCodeAction).toHaveBeenCalledOnce(); }); }); + +describe("listedCodeActions", () => { + const action = (title: string, kind: string): CodeAction => ({ ...resolved, title, kind, edit: null }); + it("lists one action per title, quick fixes first", () => { + const list = listedCodeActions([ + action("Generate toString()", "quickassist"), + action("Add unimplemented methods", "quickfix"), + action("Generate toString()", "source.generate.toString"), + ]); + expect(list.map((a) => [a.title, a.kind])).toEqual([ + ["Add unimplemented methods", "quickfix"], + ["Generate toString()", "quickassist"], + ]); + }); +}); + +describe("changedLines", () => { + const text = (s: string) => EditorState.create({ doc: s }).doc; + it("spans the lines an edit inserted", () => { + expect(changedLines(text("class A {\n}\n"), text("class A {\n f() {\n }\n}\n"))).toEqual({ from: 2, to: 3 }); + }); + it("is null for equal texts", () => { + expect(changedLines(text("a\nb"), text("a\nb"))).toBeNull(); + }); +}); + +describe("bulbLine", () => { + const state = (s: string) => EditorState.create({ doc: s }); + it("prefers an indented caret line, then the lines around it", () => { + expect(bulbLine(state(" x\ny"), 1)).toEqual({ line: 1, gutter: false }); + expect(bulbLine(state("x\n\ny"), 1)).toEqual({ line: 2, gutter: false }); + }); + it("falls back to the gutter when no nearby line has room", () => { + expect(bulbLine(state("x\n y\nz"), 1)).toEqual({ line: 1, gutter: true }); + }); +}); + +describe("lightsBulb", () => { + const kinds = (k: string | null) => lightsBulb({ ...resolved, kind: k }); + it("lights for fixes and refactorings only", () => { + expect([null, "quickfix", "refactor.extract", "quickassist"].map(kinds)).toEqual([true, true, true, true]); + expect(["source.organizeImports", "source.doc", "gopls.doc.features"].map(kinds)).toEqual([false, false, false]); + }); +}); + +describe("indentUnderBrace", () => { + const text = (s: string) => EditorState.create({ doc: s }).doc; + it("moves code generated at the brace's column one level in", () => { + const doc = text("class A : B {\noverride fun f() {\n g()\n}\n}"); + expect(indentUnderBrace(doc, 2, 4, " ").map((c) => [doc.lineAt(c.from).number, c.insert])).toEqual([ + [2, " "], + [3, " "], + [4, " "], + ]); + }); + it("leaves code already indented, or not under a brace", () => { + expect(indentUnderBrace(text("class A {\n f()\n}"), 2, 2, " ")).toEqual([]); + expect(indentUnderBrace(text("class A:\npass\nx"), 2, 3, " ")).toEqual([]); + }); +}); diff --git a/src/editor/lsp/intentions.ts b/src/editor/lsp/intentions.ts index 7894f3c40..1c44c3e20 100644 --- a/src/editor/lsp/intentions.ts +++ b/src/editor/lsp/intentions.ts @@ -3,15 +3,18 @@ * intentions popup (quick fixes first, preferred in bold), Optimize Imports and * Generate. Refactor This and the Refactor menu live in `@/editor/refactor`. */ -import { StateEffect, StateField, type Extension } from "@codemirror/state"; +import { StateEffect, StateField, type Extension, type Text } from "@codemirror/state"; import { EditorView, ViewPlugin, type ViewUpdate } from "@codemirror/view"; import { forEachDiagnostic } from "@codemirror/lint"; +import { indentUnit } from "@codemirror/language"; import { commands, isCancelled, type CodeAction } from "@/platform/ipc"; -import { contributedIntentions, whenSynced } from "@/editor/api"; +import { contributedIntentions, intentionProviders, whenSynced } from "@/editor/api"; import { shortcutLabel } from "@/platform/keymap"; import { debounce, docOf, isUnavailable, lspRequest, reportLspError } from "./lsp"; import { offsetsToRange } from "./positions"; import { showHint } from "./hint"; +import { formatLines } from "./format"; +import { pythonMemberIntentions } from "./pythonMembers"; import { caretAnchor, openPopup, type ListPopupItem, type ListPreview } from "./popupStore"; import { editPreviewLines } from "./workspaceEdit"; import { serverBackoff } from "./availability"; @@ -88,6 +91,43 @@ async function fetchIntentions(view: EditorView): Promise { return actions; } +/** Enabled quick fixes of the problem at `from..to`, preferred first. */ +export async function quickFixesAt(view: EditorView, from: number, to: number): Promise { + const doc = docOf(view); + if (!doc) return []; + await whenSynced(view); + const actions = await lspRequest(() => commands.lspCodeActions(doc.doc, offsetsToRange(view.state.doc, from, to), ["quickfix"])); + return sortCodeActions(actions.filter((a) => isQuickFix(a) && !a.disabledReason)); +} + +/** + * ⌥⇧↵: applies the preferred (else first) quick fix of the most severe problem + * at the caret, or of one elsewhere on the caret line. + */ +export async function applyQuickFix(view: EditorView) { + const state = view.state; + const caret = state.selection.main.head; + const line = state.doc.lineAt(caret); + const problems: Array = []; + forEachDiagnostic(state, (d, from, to) => { + if (d.severity === "hint" || to < line.from || from > line.to) return; + const rank = (d.severity === "error" ? 0 : d.severity === "warning" ? 1 : 2) + (from <= caret && caret <= to ? 0 : 3); + problems.push({ from, to, severity: d.severity, rank }); + }); + problems.sort((a, b) => a.rank - b.rank); + try { + for (const p of problems) { + const fix = (await quickFixesAt(view, p.from, p.to))[0]; + if (view.state.doc !== state.doc) return; + if (fix) return applyCodeAction(view, fix); + } + } catch (e) { + reportLspError(view, e, true); + return; + } + showHint(view, "No quick fixes available"); +} + export async function applyCodeAction(view: EditorView, action: CodeAction) { const doc = docOf(view); if (!doc) return; @@ -95,11 +135,72 @@ export async function applyCodeAction(view: EditorView, action: CodeAction) { showHint(view, action.disabledReason); return; } + const before = view.state.doc; try { await lspRequest(() => commands.lspApplyCodeAction(doc.doc, action)); } catch (e) { reportLspError(view, e, true); + return; } + if (!action.kind?.startsWith("source.organizeImports") && !action.kind?.startsWith("source.fixAll")) + await reformatGenerated(view, before); +} + +/** + * Code the server generated follows its own indentation (jdtls tabs, + * tsserver 4 spaces); like IDEA, the lines an action inserted are reformatted + * to the file's style. + */ +async function reformatGenerated(view: EditorView, before: Text) { + for (let i = 0; i < 100 && view.state.doc === before; i++) await new Promise((r) => setTimeout(r, 20)); + const after = view.state.doc; + const lines = changedLines(before, after); + if (!lines || lines.to - lines.from < 1) return; + const shift = indentUnderBrace(after, lines.from, lines.to, view.state.facet(indentUnit)); + // A server that generated code without its context formats ranges the same + // way (back to column 0): its range formatting is skipped then. + if (shift.length) view.dispatch({ changes: shift, userEvent: "format", scrollIntoView: false }); + else await formatLines(view, after.line(lines.from).from, after.line(lines.to).to); +} + +/** + * Generated lines right under a `{` line but not indented past it (servers + * that format the snippet on its own, kotlin-language-server): the block moves + * one level in, keeping its own nesting. Never applies to indentation-based + * languages, whose blocks don't open with `{`. + */ +export function indentUnderBrace(doc: Text, fromLine: number, toLine: number, unit: string): Array<{ from: number; insert: string }> { + if (fromLine < 2) return []; + const prev = doc.line(fromLine - 1).text; + if (!prev.trimEnd().endsWith("{")) return []; + const lead = (t: string) => t.slice(0, t.length - t.trimStart().length); + const target = lead(prev) + unit; + let first: string | null = null; + for (let n = fromLine; n <= toLine && first === null; n++) if (doc.line(n).text.trim()) first = lead(doc.line(n).text); + if (first === null || first.length > lead(prev).length || !target.startsWith(first)) return []; + const extra = target.slice(first.length); + const out: Array<{ from: number; insert: string }> = []; + for (let n = fromLine; n <= toLine; n++) { + const line = doc.line(n); + if (line.text.trim()) out.push({ from: line.from, insert: extra }); + } + return out; +} + +/** 1-based lines of `after` that differ from `before` (common head and tail lines excluded); null if equal. */ +export function changedLines(before: Text, after: Text): { from: number; to: number } | null { + if (before.eq(after)) return null; + let head = 0; + while (head < before.lines && head < after.lines && before.line(head + 1).text === after.line(head + 1).text) head++; + let tail = 0; + while ( + tail < before.lines - head && + tail < after.lines - head && + before.line(before.lines - tail).text === after.line(after.lines - tail).text + ) + tail++; + const from = Math.min(head + 1, after.lines); + return { from, to: Math.max(from, after.lines - tail) }; } /** @@ -127,8 +228,17 @@ export async function applyKindSilently(view: EditorView, kind: string): Promise return true; } +/** + * Sorted, one action per title: servers can report the same action under two + * kinds (jdtls: "Generate toString()" as quickassist and source.generate). + */ +export function listedCodeActions(actions: readonly CodeAction[]): CodeAction[] { + const seen = new Set(); + return sortCodeActions(actions).filter((a) => !seen.has(a.title) && !!seen.add(a.title)); +} + function actionItems(view: EditorView, actions: CodeAction[], resolved?: Map>): ListPopupItem[] { - return sortCodeActions(actions).map((a, i) => ({ + return listedCodeActions(actions).map((a, i) => ({ id: `${i}`, label: a.title, icon: isQuickFix(a) ? "bulb-fix" : "bulb", @@ -217,7 +327,7 @@ export async function showIntentions(view: EditorView) { } catch (e) { failure = { error: e }; } - const sorted = sortCodeActions(actions); + const sorted = listedCodeActions(actions); const resolved = new Map>(); const items = withInspectionControls(view, [...local, ...actionItems(view, sorted, resolved)]); if (!items.length) { @@ -305,6 +415,16 @@ function noActionReason(view: EditorView, none: string): string { // --------------------------------------------------------------------------- // Lightbulb +/** + * Fixes and refactorings light the bulb; source actions (organize imports, + * generate…) and server extras (gopls "Browse documentation") are offered on + * almost every line and only appear in the ⌥↵ list. + */ +export function lightsBulb(a: CodeAction): boolean { + const kind = a.kind ?? ""; + return !kind || kind === "quickassist" || /^(quickfix|refactor)(\.|$)/.test(kind); +} + interface Bulb { line: number; quickFix: boolean; @@ -331,11 +451,19 @@ const bulbPlugin = ViewPlugin.fromClass( this.lookup(); } update(u: ViewUpdate) { - if (u.docChanged || u.selectionSet) this.lookup(); + if (u.docChanged || u.selectionSet || (u.focusChanged && u.view.hasFocus)) this.lookup(); + else if (u.focusChanged) { + this.lookup.cancel(); + // A blur dispatched from inside the update is not allowed. + queueMicrotask(() => { + if (!this.view.hasFocus && this.view.state.field(bulbField, false)) this.view.dispatch({ effects: setBulb.of(null) }); + }); + } } async run() { const doc = docOf(this.view); - if (!doc || doc.readOnly || !this.view.dom.isConnected) return; + // As in IDEA, only the focused editor shows the bulb. + if (!doc || doc.readOnly || !this.view.dom.isConnected || !this.view.hasFocus) return; const state = this.view.state; const typoFix = spellingIntentions(this.view).length > 0; const local = typoFix || contributedIntentions(this.view).length > 0; @@ -350,9 +478,9 @@ const bulbPlugin = ViewPlugin.fromClass( if (!local) return; } } - if (this.view.state.doc !== state.doc || !this.view.state.selection.eq(state.selection)) return; + if (this.view.state.doc !== state.doc || !this.view.state.selection.eq(state.selection) || !this.view.hasFocus) return; const line = state.doc.lineAt(state.selection.main.head).number; - const any = actions.length > 0 || local; + const any = actions.some(lightsBulb) || local; this.view.dispatch({ effects: setBulb.of(any ? { line, quickFix: typoFix || actions.some(isQuickFix) } : null), }); @@ -365,21 +493,20 @@ const bulbPlugin = ViewPlugin.fromClass( /** * New-UI bulb: floats at the start of the text on the caret line, or on the - * line below / above when the caret line's text starts at the left edge. + * line below / above when the caret line's text starts at the left edge; in + * the gutter when none of them has room. */ const BULB_COLUMNS = 3; -function bulbLine(state: EditorView["state"], line: number): number { +export function bulbLine(state: EditorView["state"], line: number): { line: number; gutter: boolean } { const doc = state.doc; const free = (n: number) => { if (n < 1 || n > doc.lines) return false; const text = doc.line(n).text; return text.length - text.trimStart().length >= BULB_COLUMNS || !text.trim(); }; - if (free(line)) return line; - if (free(line + 1)) return line + 1; - if (free(line - 1)) return line - 1; - return line; + for (const n of [line, line + 1, line - 1]) if (free(n)) return { line: n, gutter: false }; + return { line, gutter: true }; } class FloatingBulb { @@ -393,6 +520,7 @@ class FloatingBulb { this.dom.setAttribute("aria-label", "Show intentions"); this.dom.style.display = "none"; this.dom.appendChild(iconNode("bulb", 16)); + this.dom.appendChild(iconNode("bulb-fix", 16, "cm-lsp-bulb-fix-icon")); this.dom.addEventListener("mousedown", (e) => { e.preventDefault(); e.stopPropagation(); @@ -420,18 +548,24 @@ class FloatingBulb { this.view.requestMeasure({ key: this, read: (view) => { - const line = view.state.doc.line(bulbLine(view.state, bulb.line)); - const block = view.lineBlockAt(line.from); + const at = bulbLine(view.state, bulb.line); + const line = view.state.doc.line(at.line); const content = view.contentDOM.getBoundingClientRect(); const scroller = view.scrollDOM.getBoundingClientRect(); + // The text row: the line's block also holds inline widgets above it (code vision). + const coords = view.coordsAtPos(line.from, 1); + const block = view.lineBlockAt(line.from); + const textTop = coords ? coords.top - scroller.top : block.top + view.documentTop - scroller.top; return { - top: block.top + (view.documentTop - scroller.top) + view.scrollDOM.scrollTop, - left: content.left - scroller.left + view.scrollDOM.scrollLeft + 4, - height: block.height, + top: textTop + view.scrollDOM.scrollTop, + left: content.left - scroller.left + view.scrollDOM.scrollLeft + (at.gutter ? -20 : 4), + height: coords ? coords.bottom - coords.top : block.height, + gutter: at.gutter, }; }, - write: ({ top, left, height }) => { + write: ({ top, left, height, gutter }) => { this.dom.style.display = ""; + this.dom.classList.toggle("cm-lsp-bulb-gutter", gutter); this.dom.style.top = `${top}px`; this.dom.style.left = `${left}px`; this.dom.style.height = `${Math.min(height, this.view.defaultLineHeight)}px`; @@ -447,5 +581,5 @@ class FloatingBulb { const floatingBulb = ViewPlugin.fromClass(FloatingBulb); export function intentionsExtension(): Extension { - return [bulbField, bulbPlugin, floatingBulb]; + return [bulbField, bulbPlugin, floatingBulb, intentionProviders.of(pythonMemberIntentions)]; } diff --git a/src/editor/lsp/lsp.test.ts b/src/editor/lsp/lsp.test.ts new file mode 100644 index 000000000..057735b85 --- /dev/null +++ b/src/editor/lsp/lsp.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it, vi } from "vitest"; +import { EditorState } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +import { _addEditor, _removeEditor, documentSynced, type EditorDocContext } from "@/editor/api"; + +vi.mock("@/platform/ipc", async (orig) => { + const actual = await orig(); + return { ...actual, isTauri: () => true }; +}); + +const { lspRequest } = await import("./lsp"); + +describe("lspRequest", () => { + it("waits until open editors' edits reached the backend", async () => { + let synced!: () => void; + const pending = new Promise((r) => (synced = r)); + const view = new EditorView({ state: EditorState.create({ extensions: documentSynced.of(() => pending) }) }); + const handle = { view, ctx: {} as EditorDocContext }; + _addEditor(handle); + const run = vi.fn(async () => ({ status: "ok" as const, data: 1 })); + const result = lspRequest(run); + await Promise.resolve(); + expect(run).not.toHaveBeenCalled(); + synced(); + expect(await result).toBe(1); + _removeEditor(handle); + view.destroy(); + }); +}); diff --git a/src/editor/lsp/lsp.ts b/src/editor/lsp/lsp.ts index 63287a87f..1d3dc2726 100644 --- a/src/editor/lsp/lsp.ts +++ b/src/editor/lsp/lsp.ts @@ -1,7 +1,7 @@ /** Shared plumbing: per-editor document facet, IPC wrapper and error policy. */ import { Facet } from "@codemirror/state"; import type { EditorView } from "@codemirror/view"; -import type { EditorDocContext } from "@/editor/api"; +import { whenAllSynced, type EditorDocContext } from "@/editor/api"; import { IpcError, call, isCancelled, isTauri } from "@/platform/ipc"; import type { IdeError } from "@/bindings"; import { showHint } from "./hint"; @@ -24,6 +24,8 @@ type Result = { status: "ok"; data: T } | { status: "error"; error: IdeError export async function lspRequest(run: () => Promise>): Promise { if (!isTauri()) throw new IpcError({ kind: "unavailable", message: "No language server available" }); try { + // Edits typed just before (⌘B right after typing) must reach the server first. + await whenAllSynced(); return await call(run()); } catch (e) { if (e instanceof IpcError) throw e; diff --git a/src/editor/lsp/navigation.ts b/src/editor/lsp/navigation.ts index 2136f9631..c920b17a2 100644 --- a/src/editor/lsp/navigation.ts +++ b/src/editor/lsp/navigation.ts @@ -128,6 +128,22 @@ export function pinToFind(symbol: string, locations: readonly Location[]) { }; } +/** When each editor's document last changed (`performance.now()`). */ +const editedAt = new WeakMap(); + +/** + * Some servers analyse an edit asynchronously and answer from the previous + * version meanwhile (kotlin-language-server: nothing for ~300 ms after typing). + * An empty answer right after an edit is asked again a few times. + */ +async function gotoAfterEdits(view: EditorView, request: () => Promise) { + for (let attempt = 0; ; attempt++) { + const locs = await request(); + if (locs.length || attempt >= 3 || performance.now() - (editedAt.get(view) ?? -Infinity) > 1500) return locs; + await new Promise((r) => setTimeout(r, 250)); + } +} + export async function gotoTargets(view: EditorView, kind: GotoKind, at?: number) { const offset = at ?? view.state.selection.main.head; if (kind === "definition" || kind === "declaration") { @@ -142,7 +158,7 @@ export async function gotoTargets(view: EditorView, kind: GotoKind, at?: number) const declaration = kind === "definition" || kind === "declaration"; let serverDown = false; try { - locs = dedupeLocations(await lspRequest(() => commands.lspGoto(doc.doc, pos, kind))); + locs = dedupeLocations(await gotoAfterEdits(view, () => lspRequest(() => commands.lspGoto(doc.doc, pos, kind)))); } catch (e) { if (isCancelled(e) || !declaration) { reportLspError(view, e, true); @@ -630,6 +646,9 @@ const highlightPlugin = ViewPlugin.fromClass( export function navigationExtension(): Extension { return [ + EditorView.updateListener.of((u) => { + if (u.docChanged) editedAt.set(u.view, performance.now()); + }), linkField, linkPlugin, highlightField, diff --git a/src/editor/lsp/pythonMembers.test.ts b/src/editor/lsp/pythonMembers.test.ts new file mode 100644 index 000000000..3385f3ac7 --- /dev/null +++ b/src/editor/lsp/pythonMembers.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import { classMembers, enclosingClass, memberStubs } from "./pythonMembers"; + +const src = `from abc import ABC, abstractmethod + + +class Shape(ABC): + @property + @abstractmethod + def name(self) -> str: ... + + @abstractmethod + def area( + self, scale: float = 1.0 + ) -> float: + """Area.""" + + def describe(self) -> str: + return self.name + + +class Square(Shape): + def __init__(self, side: float): + self.side = side +`.split("\n"); + +describe("python members", () => { + it("finds the class enclosing a line", () => { + expect(enclosingClass(src, 4)).toBe(4); + expect(enclosingClass(src, 13)).toBe(4); + expect(enclosingClass(src, 21)).toBe(19); + expect(enclosingClass(src, 1)).toBeNull(); + }); + + it("lists methods with their decorators and signatures", () => { + const members = classMembers(src, 4); + expect(members.map((m) => [m.name, m.abstract])).toEqual([ + ["name", true], + ["area", true], + ["describe", false], + ]); + expect(members[0]!.lines).toEqual(["@property", "def name(self) -> str:"]); + expect(members[1]!.lines).toEqual(["def area(", " self, scale: float = 1.0", ") -> float:"]); + }); + + it("writes stubs at the body's indentation", () => { + const [name] = classMembers(src, 4); + expect(memberStubs([name!], " ", " ")).toBe(" @property\n def name(self) -> str:\n pass"); + }); +}); diff --git a/src/editor/lsp/pythonMembers.ts b/src/editor/lsp/pythonMembers.ts new file mode 100644 index 000000000..4d3173c54 --- /dev/null +++ b/src/editor/lsp/pythonMembers.ts @@ -0,0 +1,192 @@ +/** + * Python "Implement abstract methods": Python servers (pyright, basedpyright) + * offer no such code action. The bases of the class at the caret are resolved + * with Go to Declaration; their `@abstractmethod`s the class lacks get stubs + * at the end of its body. + */ +import { indentUnit } from "@codemirror/language"; +import type { EditorView } from "@codemirror/view"; +import { commands } from "@/platform/ipc"; +import type { ContributedIntention } from "@/editor/api"; +import { docOf, lspRequest, reportLspError } from "./lsp"; +import { offsetToPos } from "./positions"; +import { readFileLines } from "./files"; +import { showHint } from "./hint"; + +const CLASS_HEADER = /^(\s*)class\s+\w+\s*\((.*)\)\s*:/; +const DEF = /^\s*(?:async\s+)?def\s+(\w+)/; + +const indentOf = (line: string) => line.length - line.trimStart().length; +const isPython = (view: EditorView) => docOf(view)?.uiMode === "Python"; + +/** 1-based number of the class header line enclosing `line` (or on it), with bases. */ +export function enclosingClass(lines: readonly string[], line: number): number | null { + let limit = Infinity; + for (let n = line; n >= 1; n--) { + const text = lines[n - 1]!; + if (!text.trim()) continue; + const indent = indentOf(text); + if (n !== line && indent >= limit) continue; + if (CLASS_HEADER.test(text)) return n; + limit = Math.min(limit, indent); + if (indent === 0 && n !== line) return null; + } + return null; +} + +/** Last 1-based line of the body of the class headed at `header`. */ +function bodyEnd(lines: readonly string[], header: number): number { + const indent = indentOf(lines[header - 1]!); + let last = header; + for (let n = header + 1; n <= lines.length; n++) { + const text = lines[n - 1]!; + if (!text.trim()) continue; + if (indentOf(text) <= indent) break; + last = n; + } + return last; +} + +export interface PythonMember { + name: string; + abstract: boolean; + /** Decorators (without `@abstractmethod`) and the signature, dedented. */ + lines: string[]; +} + +/** Methods declared directly in the class headed at `header` (1-based). */ +export function classMembers(lines: readonly string[], header: number): PythonMember[] { + const end = bodyEnd(lines, header); + const out: PythonMember[] = []; + let memberIndent = -1; + let decorators: string[] = []; + for (let n = header + 1; n <= end; n++) { + const text = lines[n - 1]!; + if (!text.trim()) continue; + const indent = indentOf(text); + if (memberIndent < 0) memberIndent = indent; + if (indent !== memberIndent) continue; + const trimmed = text.trim(); + if (trimmed.startsWith("@")) { + decorators.push(trimmed); + continue; + } + const def = DEF.exec(text); + if (def) { + // The signature may span lines: up to the `:` closing it. + const sig: string[] = []; + let depth = 0; + for (let m = n; m <= end; m++) { + const part = lines[m - 1]!; + sig.push(part.slice(Math.min(memberIndent, indentOf(part)))); + for (const ch of part) depth += ch === "(" || ch === "[" ? 1 : ch === ")" || ch === "]" ? -1 : 0; + if (depth <= 0 && /:\s*(#.*)?$|:\s*\.\.\.\s*$|:\s*\S/.test(part)) break; + } + // `def f(self) -> int: ...` keeps only the signature. + sig[sig.length - 1] = sig[sig.length - 1]!.replace(/:\s*(\.\.\.|pass)?\s*(#.*)?$/, ":"); + const abstract = decorators.some((d) => /^@(abc\.)?abstract(method|property)\b/.test(d)); + out.push({ + name: def[1]!, + abstract, + lines: [...decorators.filter((d) => !/^@(abc\.)?abstractmethod\b/.test(d)), ...sig], + }); + } + decorators = []; + } + return out; +} + +/** Base class names of a header and where each starts in the line. */ +function bases(header: string): Array<{ name: string; col: number }> { + const m = CLASS_HEADER.exec(header); + if (!m) return []; + const start = header.indexOf("(") + 1; + const out: Array<{ name: string; col: number }> = []; + for (const b of m[2]!.matchAll(/(?:^|,)\s*([A-Za-z_][\w.]*)\s*(?=,|$|\[)/g)) { + const name = b[1]!; + if (name === "object" || name.includes("=")) continue; + // The last segment of `abc.ABC`: that's what Go to Declaration resolves. + const col = start + b.index! + b[0].indexOf(name) + name.lastIndexOf(".") + 1; + out.push({ name, col }); + } + return out; +} + +/** Stub text for `members`, indented by `indent`. */ +export function memberStubs(members: readonly PythonMember[], indent: string, unit: string): string { + return members.map((m) => [...m.lines.map((l) => indent + l), `${indent}${unit}pass`].join("\n")).join("\n\n"); +} + +async function abstractMembersOfBases(view: EditorView, header: number): Promise { + const doc = docOf(view)!; + const state = view.state; + const line = state.doc.line(header); + const out = new Map(); + for (const base of bases(line.text)) { + const pos = offsetToPos(state.doc, line.from + base.col); + const [loc] = await lspRequest(() => commands.lspGoto(doc.doc, pos, "definition")); + if (!loc) continue; + const lines = await readFileLines(loc.path); + if (!lines) continue; + const target = enclosingClass(lines, loc.range.start.line + 1); + if (!target) continue; + for (const m of classMembers(lines, target)) if (m.abstract && !out.has(m.name)) out.set(m.name, m); + } + return [...out.values()]; +} + +/** ⌃I / the intention: stubs for the abstract methods of the bases the class lacks. */ +export async function implementPythonAbstractMethods(view: EditorView): Promise { + if (!isPython(view)) return false; + const lines = view.state.doc.toJSON(); + const header = enclosingClass(lines, view.state.doc.lineAt(view.state.selection.main.head).number); + if (!header) return false; + let abstract: PythonMember[]; + try { + abstract = await abstractMembersOfBases(view, header); + } catch (e) { + reportLspError(view, e, true); + return true; + } + const state = view.state; + if (state.doc.toJSON().join("\n") !== lines.join("\n")) return true; + const own = new Set(classMembers(lines, header).map((m) => m.name)); + const missing = abstract.filter((m) => !own.has(m.name)); + if (!missing.length) { + showHint(view, "No abstract methods to implement"); + return true; + } + const end = bodyEnd(lines, header); + const unit = state.facet(indentUnit) || " "; + const bodyLines = []; + for (let n = header + 1; n <= end; n++) if (lines[n - 1]!.trim()) bodyLines.push(n); + const first = bodyLines[0] ? lines[bodyLines[0] - 1]! : null; + const indent = first + ? first.slice(0, indentOf(first)) + : lines[header - 1]!.slice(0, indentOf(lines[header - 1]!)) + unit; + const stubs = memberStubs(missing, indent, unit); + // A body that is only `pass` / `...` is replaced. + const placeholder = bodyLines.length === 1 && /^\s*(pass|\.\.\.)\s*$/.test(first!); + const changes = !bodyLines.length + ? { from: state.doc.line(header).to, insert: `\n${stubs}` } + : placeholder + ? { from: state.doc.line(bodyLines[0]!).from, to: state.doc.line(end).to, insert: stubs } + : { from: state.doc.line(end).to, insert: `\n\n${stubs}` }; + view.dispatch({ changes, userEvent: "input.complete", scrollIntoView: true }); + return true; +} + +/** The bulb on a Python class header with bases. */ +export function pythonMemberIntentions(view: EditorView): ContributedIntention[] { + if (!isPython(view)) return []; + const line = view.state.doc.lineAt(view.state.selection.main.head); + if (!bases(line.text).length) return []; + return [ + { + id: "python.implementAbstract", + label: "Implement abstract methods", + icon: "bulb", + run: () => void implementPythonAbstractMethods(view), + }, + ]; +} diff --git a/src/editor/lsp/signature.ts b/src/editor/lsp/signature.ts index e4313328b..061da0bbe 100644 --- a/src/editor/lsp/signature.ts +++ b/src/editor/lsp/signature.ts @@ -107,6 +107,25 @@ async function fetchSignature(view: EditorView, explicit: boolean) { } } +/** + * The caret was left inside the `()` of a completed call whose snippet doesn't + * say whether it takes arguments (jdtls `new ArrayList<>()`): parameter info + * when some signature has parameters, else the caret moves past `)`. + */ +export async function parameterInfoOrLeave(view: EditorView, inside: number) { + const doc = docOf(view); + const state = view.state; + let help: SignatureHelp | null = null; + try { + if (doc) help = await lspRequest(() => commands.lspSignatureHelp(doc.doc, offsetToPos(state.doc, inside))); + } catch { + // No signature help: treated as nothing to fill in. + } + if (view.state.doc !== state.doc || view.state.selection.main.head !== inside) return; + if (help?.signatures.some((sig) => sig.parameters.length)) view.dispatch({ effects: setSignature.of({ pos: inside, help }) }); + else view.dispatch({ selection: EditorSelection.cursor(inside + 1) }); +} + /** ⌘P or the completion command `editor.action.triggerParameterHints`. */ export function triggerSignatureHelp(view: EditorView, explicit = false) { void fetchSignature(view, explicit); diff --git a/src/editor/lsp/syntaxFallback.test.ts b/src/editor/lsp/syntaxFallback.test.ts index 903e5438b..d43144fa0 100644 --- a/src/editor/lsp/syntaxFallback.test.ts +++ b/src/editor/lsp/syntaxFallback.test.ts @@ -447,6 +447,7 @@ describe("modifier-hover navigation recovery", () => { await vi.advanceTimersByTimeAsync(3001); cmd.lspGoto.mockReturnValueOnce(ok([target])); move(view); + await vi.advanceTimersByTimeAsync(0); expect(cmd.lspGoto).toHaveBeenCalledTimes(2); } finally { vi.useRealTimers(); diff --git a/src/editor/lsp/theme.ts b/src/editor/lsp/theme.ts index 2ddc2cdfb..38072f31d 100644 --- a/src/editor/lsp/theme.ts +++ b/src/editor/lsp/theme.ts @@ -202,7 +202,8 @@ export const lspTheme = EditorView.theme({ ".cm-lsp-diag-warning .cm-lsp-diag-icon": { color: "var(--warning)" }, ".cm-lsp-diag-info .cm-lsp-diag-icon, .cm-lsp-diag-hint .cm-lsp-diag-icon": { color: "var(--info)" }, ".cm-lsp-diag-message": { color: "var(--fg-strong)", whiteSpace: "pre-wrap", lineHeight: "18px" }, - ".cm-lsp-diag-meta": { display: "flex", gap: "12px", marginTop: "6px", alignItems: "baseline" }, + ".cm-lsp-diag-meta": { display: "flex", flexWrap: "wrap", columnGap: "12px", rowGap: "2px", marginTop: "6px", alignItems: "baseline" }, + ".cm-lsp-diag-meta > *": { whiteSpace: "nowrap" }, ".cm-lsp-diag-source": { color: "var(--fg-subtle)", fontSize: "var(--fs-small)" }, ".cm-lsp-link-button": { border: "none", @@ -311,9 +312,12 @@ export const lspTheme = EditorView.theme({ color: "var(--fg-bulb, var(--warning))", cursor: "pointer", }, + // Above the sticky gutters (z-index 200). + ".cm-lsp-bulb-gutter": { zIndex: "201", backgroundColor: "var(--bg-editor-gutter, var(--bg-editor))" }, ".cm-lsp-bulb .cm-openidea-icon": { display: "inline-flex", borderRadius: "var(--radius-small)", padding: "1px" }, ".cm-lsp-bulb:hover .cm-openidea-icon": { backgroundColor: "var(--bg-toolbar-hover)" }, - ".cm-lsp-bulb-fix": { color: "var(--danger)" }, + ".cm-lsp-bulb .cm-lsp-bulb-fix-icon, .cm-lsp-bulb-fix .cm-openidea-icon:first-child": { display: "none" }, + ".cm-lsp-bulb-fix .cm-lsp-bulb-fix-icon": { display: "inline-flex" }, // --- inlay hints (rounded grey chips) ------------------------------------- ".cm-lsp-inlay": { diff --git a/src/editor/navigation/implementationGutter.ts b/src/editor/navigation/implementationGutter.ts index b21bec478..a4898cbe1 100644 --- a/src/editor/navigation/implementationGutter.ts +++ b/src/editor/navigation/implementationGutter.ts @@ -14,7 +14,7 @@ import { RangeSet, StateEffect, StateField, type Extension, type Text } from "@c import { EditorView, GutterMarker, ViewPlugin, gutter, type ViewUpdate } from "@codemirror/view"; import type { EditorDocContext } from "@/editor/api"; import { docOf, debounce, isUnavailable, lspRequest } from "@/editor/lsp/lsp"; -import { serverBackoff } from "@/editor/lsp/availability"; +import { onServerSettled, serverBackoff } from "@/editor/lsp/availability"; import { readManyFileLines, withDocument } from "@/editor/lsp/files"; import { locationItems, navigateTo, pinToFind } from "@/editor/lsp/navigation"; import { openPopup } from "@/editor/lsp/popupStore"; @@ -22,7 +22,6 @@ import { commands, isCancelled, nextRequestId, - onIdeEvent, type HierarchyItem, type Location, type OutlineItem, @@ -241,8 +240,7 @@ class MarkersPlugin { if (s.settings !== prev.settings) this.refresh(); }); // Answers given while a server was starting or indexing may be incomplete. - this.offServers = onIdeEvent("languageServer", (e) => { - if (e.status !== "ready") return; + this.offServers = onServerSettled(() => { this.invalidate(); this.refresh(); }); diff --git a/src/features/code-insight/ServerStatus.tsx b/src/features/code-insight/ServerStatus.tsx index eb433ae5e..ab9ea7117 100644 --- a/src/features/code-insight/ServerStatus.tsx +++ b/src/features/code-insight/ServerStatus.tsx @@ -5,7 +5,7 @@ import { getAction, executeAction } from "@/platform/actions"; import { BarButton, Button, IconButton, Popup, T } from "@/ui"; import { downloadJdkActions } from "@/platform/jdk"; import { relativePath, useProject } from "@/platform/project"; -import { STATUS_LABEL, restartServer, serversSummary, useServers } from "./servers"; +import { STATUS_LABEL, restartServer, servedByOther, serversSummary, useServers } from "./servers"; import styles from "./ServerStatus.module.css"; import { tx } from "@/platform/i18n"; @@ -49,7 +49,10 @@ export function ServerStatus() { return (
- + {s.name} {STATUS_LABEL[s.status]} diff --git a/src/features/code-insight/servers.test.ts b/src/features/code-insight/servers.test.ts index 53ecc29f5..4ad01d45c 100644 --- a/src/features/code-insight/servers.test.ts +++ b/src/features/code-insight/servers.test.ts @@ -20,7 +20,7 @@ vi.mock("@/platform/ipc", async (importOriginal) => { const { emitLocalIdeEvent } = await import("@/platform/ipc"); const { useProject } = await import("@/platform/project"); -const { installServerEvents, useServers } = await import("./servers"); +const { installServerEvents, serversSummary, useServers } = await import("./servers"); describe("server status", () => { let dispose = () => {}; @@ -40,3 +40,18 @@ describe("server status", () => { expect(useServers.getState().servers.get(id)).toMatchObject({ status: "missing", message: "install jdtls" }); }); }); + +describe("serversSummary", () => { + const entry = (id: number, name: string, status: ServerInfo["status"]) => ({ + id: id as ServerId, + name, + status, + message: null, + languages: ["typescript"], + root: "/p", + }); + it("doesn't flag a missing server whose languages another one serves", () => { + expect(serversSummary([entry(1, "vtsls", "missing"), entry(2, "typescript-language-server", "ready")])?.tone).toBe("ok"); + expect(serversSummary([entry(1, "vtsls", "missing")])?.tone).toBe("error"); + }); +}); diff --git a/src/features/code-insight/servers.ts b/src/features/code-insight/servers.ts index 38f3ede09..4cbf940aa 100644 --- a/src/features/code-insight/servers.ts +++ b/src/features/code-insight/servers.ts @@ -129,10 +129,23 @@ export const STATUS_LABEL: Record = { misconfigured: "Not started", }; +/** + * A missing server whose languages another installed one serves (vtsls missing, + * typescript-language-server running as its fallback) is not a problem. + */ +export function servedByOther(server: ServerEntry, servers: readonly ServerEntry[]): boolean { + return ( + server.languages.length > 0 && + server.languages.every((l) => servers.some((o) => o !== server && o.status !== "missing" && o.languages.includes(l))) + ); +} + /** One-line summary for the status bar. */ export function serversSummary(servers: readonly ServerEntry[]): { text: string; tone: "ok" | "busy" | "error" | "idle" } | null { if (!servers.length) return null; - const bad = servers.filter((s) => s.status === "crashed" || s.status === "missing" || s.status === "misconfigured"); + const bad = servers.filter( + (s) => s.status === "crashed" || s.status === "misconfigured" || (s.status === "missing" && !servedByOther(s, servers)), + ); const busy = servers.filter((s) => s.status === "starting" || s.status === "busy"); const tone = bad.length ? "error" : busy.length ? "busy" : servers.some((s) => s.status === "ready") ? "ok" : "idle"; const names = [...new Set(servers.map((s) => s.name))]; diff --git a/src/platform/ipc.ts b/src/platform/ipc.ts index f9723f272..47cab688b 100644 --- a/src/platform/ipc.ts +++ b/src/platform/ipc.ts @@ -57,6 +57,12 @@ function ensureListening() { }); } +/** Resolves once backend events reach this window's handlers. */ +export async function ideEventsReady(): Promise { + ensureListening(); + await unlisten; +} + /** Subscribes to one backend event type (or `"*"`); returns an unsubscribe function. */ export function onIdeEvent(type: K | "*", handler: (event: EventOf) => void): () => void { ensureListening(); diff --git a/src/platform/keymaps/intellij-macos.ts b/src/platform/keymaps/intellij-macos.ts index 9290b8650..41d8c1db1 100644 --- a/src/platform/keymaps/intellij-macos.ts +++ b/src/platform/keymaps/intellij-macos.ts @@ -82,6 +82,7 @@ export const intellijMacos: Record = { "code.quickDocumentation": ["F1", "Ctrl+J"], "code.quickDefinition": ["Alt+Space", "Meta+Y"], "code.showIntentions": ["Alt+Enter"], + "code.applyQuickFix": ["Alt+Shift+Enter"], "code.reformat": ["Meta+Alt+L"], "code.reformatDialog": ["Meta+Alt+Shift+L"], "code.optimizeImports": ["Ctrl+Alt+O"], diff --git a/src/platform/keymaps/intellij-windows.ts b/src/platform/keymaps/intellij-windows.ts index f2ca0ee3d..341450d8f 100644 --- a/src/platform/keymaps/intellij-windows.ts +++ b/src/platform/keymaps/intellij-windows.ts @@ -83,6 +83,7 @@ export const intellijWindows: Record = { "code.quickDocumentation": ["Ctrl+Q"], "code.quickDefinition": ["Ctrl+Shift+I"], "code.showIntentions": ["Alt+Enter"], + "code.applyQuickFix": ["Alt+Shift+Enter"], "code.reformat": ["Ctrl+Alt+L"], "code.reformatDialog": ["Ctrl+Alt+Shift+L"], "code.optimizeImports": ["Ctrl+Alt+O"], diff --git a/src/platform/keymaps/vscode-macos.ts b/src/platform/keymaps/vscode-macos.ts index 28a3d7dfb..f1f2b861a 100644 --- a/src/platform/keymaps/vscode-macos.ts +++ b/src/platform/keymaps/vscode-macos.ts @@ -83,6 +83,7 @@ export const vscodeMacos: Record = { "code.quickDocumentation": ["Meta+K Meta+I"], "code.quickDefinition": ["Alt+F12"], "code.showIntentions": ["Meta+."], + "code.applyQuickFix": [], "code.generate": [], "code.reformat": ["Alt+Shift+F"], "code.reformatDialog": [], diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index d09f15885..c2ffd4185 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -3141,6 +3141,10 @@ "The file changed; rename cancelled": "文件已更改,重命名已取消", "No suggestions": "无建议", "No intentions available": "没有可用的意图操作", + "No quick fixes available": "没有可用的快速修复", + "No abstract methods to implement": "没有需要实现的抽象方法", + "Implement abstract methods": "实现抽象方法", + "Apply Quick Fix": "应用快速修复", "The document changed; invoke the refactoring again": "文档已更改,请重新调用重构", "The document changed; the name was not applied": "文档已更改,名称未应用", "The file changed; Safe Delete cancelled": "文件已更改,安全删除已取消", diff --git a/src/ui/expui/codeInsight/quickfixBulb.svg b/src/ui/expui/codeInsight/quickfixBulb.svg new file mode 100644 index 000000000..fab341ba4 --- /dev/null +++ b/src/ui/expui/codeInsight/quickfixBulb.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/ui/expui/codeInsight/quickfixBulb_dark.svg b/src/ui/expui/codeInsight/quickfixBulb_dark.svg new file mode 100644 index 000000000..58a32769c --- /dev/null +++ b/src/ui/expui/codeInsight/quickfixBulb_dark.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/ui/icons.tsx b/src/ui/icons.tsx index 0f43b3cc8..fdb5e88b3 100644 --- a/src/ui/icons.tsx +++ b/src/ui/icons.tsx @@ -247,6 +247,7 @@ const ICONS = { list: "general/listFiles", keyboard: "general/keyboard", bulb: "codeInsight/intentionBulb", + "bulb-fix": "codeInsight/quickfixBulb", package: "nodes/package", plugin: "nodes/plugin", "soft-wrap": "general/softWrap",