diff --git a/crates/ide-host/src/ops/lsp.rs b/crates/ide-host/src/ops/lsp.rs index 6be7e2028..1d398e82c 100644 --- a/crates/ide-host/src/ops/lsp.rs +++ b/crates/ide-host/src/ops/lsp.rs @@ -241,6 +241,11 @@ pub fn lsp_servers(host: &Host, window: &str) -> IdeResult> { Ok(host.project(window)?.lsp.servers()) } +pub fn lsp_resend_diagnostics(host: &Host, window: &str) -> IdeResult<()> { + host.project(window)?.lsp.resend_diagnostics(); + Ok(()) +} + pub async fn lsp_restart(host: &Host, window: &str, server: ServerId) -> IdeResult<()> { host.project(window)?.lsp.restart(server).await } @@ -349,6 +354,7 @@ pub(crate) fn register(d: &mut Dispatcher) { lsp_type_hierarchy(&c.host, &c.window, c.arg("doc")?, c.arg("pos")?, c.arg("direction")?).await }); d.add("lsp_servers", |c| async move { lsp_servers(&c.host, &c.window) }); + d.add("lsp_resend_diagnostics", |c| async move { lsp_resend_diagnostics(&c.host, &c.window) }); d.add("lsp_restart", |c| async move { lsp_restart(&c.host, &c.window, c.arg("server")?).await }); d.add("apply_workspace_edit", |c| async move { apply_workspace_edit(&c.host, &c.window, c.arg("edit")?) }); d.add("lsp_execute_command", |c| async move { diff --git a/crates/ide-language/src/syntax/literals.rs b/crates/ide-language/src/syntax/literals.rs index 99438d97f..29d0d2ece 100644 --- a/crates/ide-language/src/syntax/literals.rs +++ b/crates/ide-language/src/syntax/literals.rs @@ -80,18 +80,8 @@ fn is_name_leaf(kind: &str) -> bool { } /// Node kinds whose `name` field is a use, not a declaration. -const USE_PARENTS: &[&str] = &[ - "invocation", - "call", - "access", - "scoped", - "reference", - "expression", - "selector", - "qualified", - "use_", - "import", -]; +const USE_PARENTS: &[&str] = + &["invocation", "call", "access", "scoped", "reference", "expression", "selector", "qualified", "use_", "import"]; /// Whether the identifier leaf under `cursor` is the name a declaration /// introduces (`fn name`, `let pattern`, `int *declarator`, parameters, diff --git a/crates/ide-sdk/src/apply.rs b/crates/ide-sdk/src/apply.rs index 54329bf35..5d3c70cb1 100644 --- a/crates/ide-sdk/src/apply.rs +++ b/crates/ide-sdk/src/apply.rs @@ -342,6 +342,7 @@ fn custom_server(name: &str, ls: &LanguageServerSettings) -> LanguageServerConfi }), connect: None, fallback: false, + max_java: None, launch_error: None, } } diff --git a/src-tauri/src/commands/lsp.rs b/src-tauri/src/commands/lsp.rs index dc218ef4d..40294164b 100644 --- a/src-tauri/src/commands/lsp.rs +++ b/src-tauri/src/commands/lsp.rs @@ -313,8 +313,7 @@ pub fn lsp_servers(window: Window, state: State<'_, AppState>) -> IdeResult) -> IdeResult<()> { - project!(window, state).lsp.resend_diagnostics(); - Ok(()) + ide_host::ops::lsp::lsp_resend_diagnostics(&state.host, window.label()) } #[tauri::command] diff --git a/src/editor/lsp/availability.test.ts b/src/editor/lsp/availability.test.ts new file mode 100644 index 000000000..d408ccf4e --- /dev/null +++ b/src/editor/lsp/availability.test.ts @@ -0,0 +1,59 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { IdeEvent } from "@/bindings"; + +const listeners = vi.hoisted(() => new Set<(e: IdeEvent) => void>()); +vi.mock("@/platform/ipc", async (orig) => { + const real = await orig(); + return { + ...real, + isTauri: () => true, + onIdeEvent: (_type: string, fn: (e: IdeEvent) => void) => { + listeners.add(fn); + return () => listeners.delete(fn); + }, + }; +}); + +const { onServerSettled } = await import("./availability"); + +const status = (s: string) => { + for (const fn of listeners) fn({ type: "languageServer", server: 1, status: s } as unknown as IdeEvent); +}; + +describe("onServerSettled", () => { + afterEach(() => { + vi.useRealTimers(); + listeners.clear(); + }); + + it("re-runs a callback that keeps the server busy only once", () => { + vi.useFakeTimers(); + const fn = vi.fn(); + onServerSettled(fn); + status("starting"); + status("ready"); + expect(fn).toHaveBeenCalledTimes(1); + for (let i = 0; i < 2; i++) { + status("busy"); + vi.advanceTimersByTime(2000); + status("ready"); + } + expect(fn).toHaveBeenCalledTimes(2); + // Indexing that starts later is a new settle. + vi.advanceTimersByTime(10_000); + status("busy"); + vi.advanceTimersByTime(5000); + status("ready"); + expect(fn).toHaveBeenCalledTimes(3); + }); + + it("ignores short busy spells", () => { + vi.useFakeTimers(); + const fn = vi.fn(); + onServerSettled(fn); + status("busy"); + vi.advanceTimersByTime(100); + status("ready"); + expect(fn).not.toHaveBeenCalled(); + }); +}); diff --git a/src/editor/lsp/availability.ts b/src/editor/lsp/availability.ts index b63caab35..0c28cef5a 100644 --- a/src/editor/lsp/availability.ts +++ b/src/editor/lsp/availability.ts @@ -49,23 +49,36 @@ 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; +/** A busy spell starting this soon after `fn` ran may be `fn`'s own requests. */ +const ECHO_MS = 2000; + /** * 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. + * sends, and re-asking on each of them would never stop. For the same reason + * a long spell that began right after `fn` ran (slow requests `fn` sent, or + * indexing right after start) re-runs it only once in a row. */ export function onServerSettled(fn: () => void): () => void { if (!isTauri()) return () => {}; const since = new Map(); + const fired = new Map(); return onIdeEvent("languageServer", (e) => { - if (e.status === "starting") since.set(e.server, -Infinity); - else if (e.status === "busy") { + if (e.status === "starting") { + since.set(e.server, -Infinity); + fired.delete(e.server); + } 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(); + if (t === undefined || Date.now() - t < LONG_BUSY_MS) return; + const last = fired.get(e.server); + const echo = !!last && t - last.at < ECHO_MS; + if (echo && last.echoed) return; + fired.set(e.server, { at: Date.now(), echoed: echo }); + fn(); } else since.delete(e.server); }); } diff --git a/src/editor/lsp/completion.ts b/src/editor/lsp/completion.ts index c360b5830..5452d34d0 100644 --- a/src/editor/lsp/completion.ts +++ b/src/editor/lsp/completion.ts @@ -418,7 +418,10 @@ async function lspCompletionSource(ctx: CompletionContext): Promise commands.lspCompletion(id, doc.doc, offsetToPos(ctx.state.doc, ctx.pos), trigger)); + list = await lspRequest( + () => commands.lspCompletion(id, doc.doc, offsetToPos(ctx.state.doc, ctx.pos), trigger), + () => inflight === id, + ); } catch (e) { if (isUnavailable(e)) { serverBackoff.mark(doc.doc); diff --git a/src/editor/lsp/hover.ts b/src/editor/lsp/hover.ts index 30855551e..fefbdf97a 100644 --- a/src/editor/lsp/hover.ts +++ b/src/editor/lsp/hover.ts @@ -51,7 +51,10 @@ export async function requestHover(view: EditorView, pos: number): Promise commands.lspHover(id, doc.doc, offsetToPos(view.state.doc, pos))); + const h = await lspRequest( + () => commands.lspHover(id, doc.doc, offsetToPos(view.state.doc, pos)), + () => hoverRequest === id, + ); return h && !isEmptyMarkup(h.contents) ? h : null; } finally { if (hoverRequest === id) hoverRequest = null; diff --git a/src/editor/lsp/inlineCompletion.ts b/src/editor/lsp/inlineCompletion.ts index 29d265481..65dde708e 100644 --- a/src/editor/lsp/inlineCompletion.ts +++ b/src/editor/lsp/inlineCompletion.ts @@ -163,8 +163,9 @@ export async function requestInline(view: EditorView, automatic: boolean): Promi const id = (inflight = nextRequestId()); const caret = sel.main.head; try { - const items = await lspRequest(() => - commands.lspInlineCompletion(id, doc.doc, offsetToPos(state.doc, caret), automatic), + const items = await lspRequest( + () => commands.lspInlineCompletion(id, doc.doc, offsetToPos(state.doc, caret), automatic), + () => inflight === id, ); if (view.state.doc !== state.doc || !view.state.selection.eq(sel)) return false; const suggestions = toSuggestions(view.state, caret, items); diff --git a/src/editor/lsp/intentions.test.ts b/src/editor/lsp/intentions.test.ts index 17feebb28..53b89ba16 100644 --- a/src/editor/lsp/intentions.test.ts +++ b/src/editor/lsp/intentions.test.ts @@ -129,10 +129,18 @@ describe("listedCodeActions", () => { 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 }); + 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(); + it("keeps separate edits apart", () => { + const before = text("package p;\n\nclass A {\n int a;\n int b;\n void f() {\n }\n}\n"); + const after = text("package p;\n\nimport java.util.List;\n\nclass A {\n int a;\n int b;\n void f() {\n List x = null;\n x.size();\n }\n}\n"); + expect(changedLines(before, after)).toEqual([ + { from: 3, to: 4 }, + { from: 9, to: 10 }, + ]); + }); + it("is empty for equal texts", () => { + expect(changedLines(text("a\nb"), text("a\nb"))).toEqual([]); }); }); diff --git a/src/editor/lsp/intentions.ts b/src/editor/lsp/intentions.ts index 1c44c3e20..07b11dd6a 100644 --- a/src/editor/lsp/intentions.ts +++ b/src/editor/lsp/intentions.ts @@ -7,6 +7,7 @@ import { StateEffect, StateField, type Extension, type Text } from "@codemirror/ import { EditorView, ViewPlugin, type ViewUpdate } from "@codemirror/view"; import { forEachDiagnostic } from "@codemirror/lint"; import { indentUnit } from "@codemirror/language"; +import { diff } from "@codemirror/merge"; import { commands, isCancelled, type CodeAction } from "@/platform/ipc"; import { contributedIntentions, intentionProviders, whenSynced } from "@/editor/api"; import { shortcutLabel } from "@/platform/keymap"; @@ -153,14 +154,16 @@ export async function applyCodeAction(view: EditorView, action: CodeAction) { */ 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); + // Bottom up, so the line numbers of the hunks above stay valid. + for (const lines of changedLines(before, view.state.doc).reverse()) { + if (lines.to - lines.from < 1) continue; + const doc = view.state.doc; + const shift = indentUnderBrace(doc, 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 if (!(await formatLines(view, doc.line(lines.from).from, doc.line(lines.to).to)) && view.state.doc !== doc) return; + } } /** @@ -187,20 +190,28 @@ export function indentUnderBrace(doc: Text, fromLine: number, toLine: number, un 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) }; +/** + * 1-based line spans of `after` that an edit changed, top to bottom: an added + * import and the code using it are separate spans, so the user's code between + * them is left alone. + */ +export function changedLines(before: Text, after: Text): Array<{ from: number; to: number }> { + if (before.eq(after)) return []; + const spans: Array<{ from: number; to: number }> = []; + const a = before.toString(); + const b = after.toString(); + for (const c of diff(a, b)) { + // Text inserted at a line's end or start (`{⏎ added`, `added⏎`) leaves that line as it was. + const lead = b[c.fromB] === "\n" && (c.toA === c.fromA || a[c.fromA] === "\n"); + const trail = c.toB > c.fromB && b[c.toB - 1] === "\n" && (c.toA === c.fromA || a[c.toA - 1] === "\n"); + const from = after.lineAt(lead ? c.fromB + 1 : c.fromB).number; + const end = trail ? c.toB - 1 : c.toB; + const to = Math.max(from, after.lineAt(end).number); + const last = spans[spans.length - 1]; + if (last && from <= last.to + 1) last.to = Math.max(last.to, to); + else spans.push({ from, to }); + } + return spans; } /** diff --git a/src/editor/lsp/lsp.ts b/src/editor/lsp/lsp.ts index 1d3dc2726..82100b557 100644 --- a/src/editor/lsp/lsp.ts +++ b/src/editor/lsp/lsp.ts @@ -20,12 +20,15 @@ type Result = { status: "ok"; data: T } | { status: "error"; error: IdeError /** * Runs an LSP command. Synchronous throws (no Tauri runtime in a plain browser) * become `unavailable` rejections so callers have a single error path. + * `wanted` is checked once pending edits are synced: a request cancelled + * meanwhile is not sent (the backend ignores cancelling an id it hasn't seen). */ -export async function lspRequest(run: () => Promise>): Promise { +export async function lspRequest(run: () => Promise>, wanted?: () => boolean): 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(); + if (wanted && !wanted()) throw new IpcError({ kind: "cancelled", message: "Cancelled" }); return await call(run()); } catch (e) { if (e instanceof IpcError) throw e; diff --git a/src/editor/lsp/pythonMembers.test.ts b/src/editor/lsp/pythonMembers.test.ts index 3385f3ac7..df4a8f11f 100644 --- a/src/editor/lsp/pythonMembers.test.ts +++ b/src/editor/lsp/pythonMembers.test.ts @@ -43,6 +43,28 @@ describe("python members", () => { expect(members[1]!.lines).toEqual(["def area(", " self, scale: float = 1.0", ") -> float:"]); }); + it("stops at a nested class without bases", () => { + const nested = ["class Outer(Base):", " class Inner:", " x = 1", ""]; + expect(enclosingClass(nested, 3)).toBeNull(); + }); + + it("keeps a body going through an unindented string", () => { + const lines = [ + "class A(ABC):", + " @abc.abstractproperty", + " def doc(self):", + ' return """', + "text", + '"""', + " @abstractmethod", + " def run(self): ...", + "x = 1", + ]; + const members = classMembers(lines, 1); + expect(members.map((m) => m.lines)).toEqual([["@property", "def doc(self):"], ["def run(self):"]]); + expect(members.every((m) => m.abstract)).toBe(true); + }); + 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 index 4d3173c54..1402c2d02 100644 --- a/src/editor/lsp/pythonMembers.ts +++ b/src/editor/lsp/pythonMembers.ts @@ -14,12 +14,13 @@ import { readFileLines } from "./files"; import { showHint } from "./hint"; const CLASS_HEADER = /^(\s*)class\s+\w+\s*\((.*)\)\s*:/; +const CLASS = /^\s*class\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. */ +/** 1-based number of the class header line enclosing `line` (or on it); null unless it has bases. */ export function enclosingClass(lines: readonly string[], line: number): number | null { let limit = Infinity; for (let n = line; n >= 1; n--) { @@ -27,7 +28,7 @@ export function enclosingClass(lines: readonly string[], line: number): number | if (!text.trim()) continue; const indent = indentOf(text); if (n !== line && indent >= limit) continue; - if (CLASS_HEADER.test(text)) return n; + if (CLASS.test(text)) return CLASS_HEADER.test(text) ? n : null; limit = Math.min(limit, indent); if (indent === 0 && n !== line) return null; } @@ -38,8 +39,19 @@ export function enclosingClass(lines: readonly string[], line: number): number | function bodyEnd(lines: readonly string[], header: number): number { const indent = indentOf(lines[header - 1]!); let last = header; + let quote: string | null = null; for (let n = header + 1; n <= lines.length; n++) { const text = lines[n - 1]!; + // Lines inside a triple-quoted string belong to it, however indented. + const inString = quote !== null; + for (const q of text.match(/"""|'''/g) ?? []) { + if (quote === null) quote = q; + else if (q === quote) quote = null; + } + if (inString) { + last = n; + continue; + } if (!text.trim()) continue; if (indentOf(text) <= indent) break; last = n; @@ -88,7 +100,12 @@ export function classMembers(lines: readonly string[], header: number): PythonMe out.push({ name: def[1]!, abstract, - lines: [...decorators.filter((d) => !/^@(abc\.)?abstractmethod\b/.test(d)), ...sig], + lines: [ + ...decorators + .filter((d) => !/^@(abc\.)?abstractmethod\b/.test(d)) + .map((d) => d.replace(/^@(abc\.)?abstractproperty\b/, "@property")), + ...sig, + ], }); } decorators = []; diff --git a/src/features/vcs/CommitView.tsx b/src/features/vcs/CommitView.tsx index 4115974e7..282d4f0d3 100644 --- a/src/features/vcs/CommitView.tsx +++ b/src/features/vcs/CommitView.tsx @@ -427,27 +427,25 @@ function ChangesPanel() { ); // Pointer-driven (HTML5 drag and drop never reaches the Tauri webview): - // files and folders drag, changelist rows take them. + // files and folders drag; any row takes them into its changelist. const list = n.changelist; const dnd = list === undefined ? {} - : n.type === "group" - ? { - ref: (el: HTMLSpanElement | null) => - el - ? registerDropTarget(el, { - accepts: (type) => type === CHANGES_MIME, - over: () => setDropTarget(list), - leave: () => setDropTarget(null), - drop: (data) => { - setDropTarget(null); - moveToChangelist(data.payload.split("\n"), list); - }, - }) - : undefined, - } - : { + : { + ref: (el: HTMLSpanElement | null) => + el + ? registerDropTarget(el, { + accepts: (type) => type === CHANGES_MIME, + over: () => setDropTarget(list), + leave: () => setDropTarget(null), + drop: (data) => { + setDropTarget(null); + moveToChangelist(data.payload.split("\n"), list); + }, + }) + : undefined, + ...(n.type !== "group" && { onPointerDown: (e: PointerEvent) => { const paths = selected.has(n.key) && selected.size > 1 @@ -458,7 +456,8 @@ function ChangesPanel() { payload: paths.map(stripSlash).join("\n"), }); }, - }; + }), + }; return (