feat(editor): Quick fixes, completion and navigation while typing

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.
This commit is contained in:
l0ng-ai committed 2026-10-28 08:28:00 +08:00
1 parent 1122d67f7d
commit 69fa7f7387
34 files changed
+889 -59

No files matched your search

+6
View File
@@ -192,6 +192,12 @@ export function whenSynced(view: EditorView): Promise<void> {
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<void> {
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);
-4
View File
@@ -178,10 +178,6 @@
}
.bulb {
flex: none;
color: var(--warning);
}
.bulbFix {
color: var(--danger);
}
.empty {
font-size: var(--fs-ui);
+1 -1
View File
@@ -44,7 +44,7 @@ import { useUsagesStatus } from "./navigation";
import styles from "./Popups.module.css";
export function BulbIcon({ fix }: { fix?: boolean }) {
return <Icon name="bulb" size={16} className={clsx(styles.bulb, fix && styles.bulbFix)} />;
return <Icon name={fix ? "bulb-fix" : "bulb"} size={16} className={styles.bulb} />;
}
function ItemIcon({ item }: { item: ListPopupItem }) {
+11 -2
View File
@@ -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" }),
+24
View File
@@ -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<number, number>();
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();
+47 -2
View File
@@ -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<K, V>", additionalEdits: edit("import java.util.HashMap;\n") })).toBe(
"Map<K, V> (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() ");
});
});
+67 -10
View File
@@ -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<CompletionItem, Promise<CompletionItem>>();
/** Items whose resolve has answered, for accepting them synchronously. */
const resolvedItems = new WeakMap<CompletionItem, CompletionItem>();
function resolveItem(doc: number, item: CompletionItem): Promise<CompletionItem> {
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<typeof callParens> = 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<typeof applyLsp>) => applyLsp(...a) : applyLsp,
info: (c: Completion) => renderInfo(c as LspCompletion),
});
}
@@ -383,9 +432,16 @@ async function lspCompletionSource(ctx: CompletionContext): Promise<CompletionRe
const req: RequestContext = { doc: doc.doc, path: doc.path, language: doc.uiMode, pos: ctx.pos, text: ctx.state.doc };
const prefs = editorPrefs().completion;
const typed = word?.text ?? "";
const items = prefs.matchCase
let items = prefs.matchCase
? list.items.filter((it) => 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,
+30
View File
@@ -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<CompletionItem, "additionalEdits">): 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<CompletionItem, "detail" | "additionalEdits">): 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). */
+22 -2
View File
@@ -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<Record<Severity, string>> = {
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" },
+9 -1
View File
@@ -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);
});
});
+47 -2
View File
@@ -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<boolean> {
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,
+25 -1
View File
@@ -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<Hover
function renderDiagnostics(view: EditorView, diags: PlacedDiagnostic[]): HTMLElement {
const box = document.createElement("div");
box.className = "cm-lsp-hover-diagnostics";
for (const { diagnostic: d, from } of diags) {
for (const { diagnostic: d, from, to } of diags) {
const row = document.createElement("div");
row.className = `cm-lsp-diag cm-lsp-diag-${d.severity}`;
row.appendChild(
@@ -87,6 +88,7 @@ function renderDiagnostics(view: EditorView, diags: PlacedDiagnostic[]): HTMLEle
fix.className = "cm-lsp-link-button";
const keys = shortcutLabel("code.showIntentions");
fix.textContent = keys ? `More actions… ${keys}` : "More actions…";
if (d.severity === "error" || d.severity === "warning") offerQuickFix(view, meta, fix, from, to);
fix.addEventListener("mousedown", (e) => 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
+62 -1
View File
@@ -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([]);
});
});
+155 -21
View File
@@ -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<CodeAction[]> {
return actions;
}
/** Enabled quick fixes of the problem at `from..to`, preferred first. */
export async function quickFixesAt(view: EditorView, from: number, to: number): Promise<CodeAction[]> {
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<PlacedProblem & { rank: number }> = [];
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<string>();
return sortCodeActions(actions).filter((a) => !seen.has(a.title) && !!seen.add(a.title));
}
function actionItems(view: EditorView, actions: CodeAction[], resolved?: Map<CodeAction, Promise<CodeAction>>): 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<CodeAction, Promise<CodeAction>>();
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)];
}
+29
View File
@@ -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<typeof import("@/platform/ipc")>();
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<void>((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();
});
});
+3 -1
View File
@@ -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<T> = { status: "ok"; data: T } | { status: "error"; error: IdeError
export async function lspRequest<T>(run: () => Promise<Result<T>>): Promise<T> {
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;
+20 -1
View File
@@ -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<EditorView, number>();
/**
* 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<DeclarationTarget[]>) {
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,
+50
View File
@@ -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");
});
});
+192
View File
@@ -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<PythonMember[]> {
const doc = docOf(view)!;
const state = view.state;
const line = state.doc.line(header);
const out = new Map<string, PythonMember>();
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<boolean> {
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),
},
];
}
+19
View File
@@ -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);
+1
View File
@@ -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();
+6 -2
View File
@@ -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": {
@@ -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();
});
+5 -2
View File
@@ -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 (
<div key={s.id} className={styles.server}>
<div className={styles.line}>
<span className={clsx(styles.dot, styles[toneOf(s.status)])} aria-hidden />
<span
className={clsx(styles.dot, styles[s.status === "missing" && servedByOther(s, servers) ? "idle" : toneOf(s.status)])}
aria-hidden
/>
<span className={styles.name}>{s.name}</span>
<span className={styles.status}>{STATUS_LABEL[s.status]}</span>
<span className={styles.spacer} />
+16 -1
View File
@@ -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");
});
});
+14 -1
View File
@@ -129,10 +129,23 @@ export const STATUS_LABEL: Record<ServerStatus, string> = {
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))];
+6
View File
@@ -57,6 +57,12 @@ function ensureListening() {
});
}
/** Resolves once backend events reach this window's handlers. */
export async function ideEventsReady(): Promise<void> {
ensureListening();
await unlisten;
}
/** Subscribes to one backend event type (or `"*"`); returns an unsubscribe function. */
export function onIdeEvent<K extends IdeEvent["type"]>(type: K | "*", handler: (event: EventOf<K>) => void): () => void {
ensureListening();
+1
View File
@@ -82,6 +82,7 @@ export const intellijMacos: Record<string, string[]> = {
"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"],
+1
View File
@@ -83,6 +83,7 @@ export const intellijWindows: Record<string, string[]> = {
"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"],
+1
View File
@@ -83,6 +83,7 @@ export const vscodeMacos: Record<string, string[]> = {
"code.quickDocumentation": ["Meta+K Meta+I"],
"code.quickDefinition": ["Alt+F12"],
"code.showIntentions": ["Meta+."],
"code.applyQuickFix": [],
"code.generate": [],
"code.reformat": ["Alt+Shift+F"],
"code.reformatDialog": [],
+4
View File
@@ -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": "文件已更改,安全删除已取消",
@@ -0,0 +1,6 @@
<!-- Copyright 2000-2023 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="5.70142" y="12" width="4.6" height="1" fill="#6C707E"/>
<path d="M6 14H10C10 14.5523 9.55228 15 9 15H7C6.44772 15 6 14.5523 6 14Z" fill="#6C707E"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.8704 9.14748C12.0417 8.27221 12.8 6.87465 12.8 5.3C12.8 2.64903 10.6509 0.5 7.99995 0.5C5.34898 0.5 3.19995 2.64903 3.19995 5.3C3.19995 6.87464 3.95817 8.27218 5.12943 9.14746L5.49994 11H10.4999L10.8704 9.14748Z" fill="#E55765"/>
</svg>

After

Width:  |  Height:  |  Size: 670 B

@@ -0,0 +1,6 @@
<!-- Copyright 2000-2023 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="5.70142" y="12" width="4.6" height="1" fill="#CED0D6"/>
<path d="M6 14H10C10 14.5523 9.55228 15 9 15H7C6.44772 15 6 14.5523 6 14Z" fill="#CED0D6"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.8704 9.14748C12.0417 8.27221 12.8 6.87465 12.8 5.3C12.8 2.64903 10.6509 0.5 7.99995 0.5C5.34898 0.5 3.19995 2.64903 3.19995 5.3C3.19995 6.87464 3.95817 8.27218 5.12943 9.14746L5.49994 11H10.4999L10.8704 9.14748Z" fill="#DB5C5C"/>
</svg>

After

Width:  |  Height:  |  Size: 670 B

+1
View File
@@ -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",