fix(editor): Skip cancelled LSP requests and limit generated-code reformat

- Requests cancelled while waiting for pending edits are not sent.
- Code generated by an action is reformatted per changed hunk, so an added
  import doesn't reformat the code between it and the generated lines.
- A long busy spell caused by a settle refresh re-runs it only once.
- Files dropped on a file row of another changelist move to that changelist.
- Python Implement methods: a nested class without bases ends the lookup,
  class bodies continue through unindented string lines, and
  `@abstractproperty` stubs become `@property`.
- Resend diagnostics goes through the host ops, and custom servers get
  `max_java`.
Цей коміт міститься в:
l0ng-ai committed 2026-10-28 08:45:21 +08:00
1 джерело a645632b23
коміт aaf4d12ba6
15 files changed
+204 -69

No files matched your search

+6
Переглянути файл
@@ -241,6 +241,11 @@ pub fn lsp_servers(host: &Host, window: &str) -> IdeResult<Vec<ServerInfo>> {
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 {
+2 -12
Переглянути файл
@@ -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,
+1
Переглянути файл
@@ -342,6 +342,7 @@ fn custom_server(name: &str, ls: &LanguageServerSettings) -> LanguageServerConfi
}),
connect: None,
fallback: false,
max_java: None,
launch_error: None,
}
}
+1 -2
Переглянути файл
@@ -313,8 +313,7 @@ pub fn lsp_servers(window: Window, state: State<'_, AppState>) -> IdeResult<Vec<
#[tauri::command]
#[specta::specta]
pub fn lsp_resend_diagnostics(window: Window, state: State<'_, AppState>) -> IdeResult<()> {
project!(window, state).lsp.resend_diagnostics();
Ok(())
ide_host::ops::lsp::lsp_resend_diagnostics(&state.host, window.label())
}
#[tauri::command]
+59
Переглянути файл
@@ -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<typeof import("@/platform/ipc")>();
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();
});
});
+17 -4
Переглянути файл
@@ -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<number, number>();
const fired = new Map<number, { at: number; echoed: boolean }>();
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);
});
}
+4 -1
Переглянути файл
@@ -418,7 +418,10 @@ async function lspCompletionSource(ctx: CompletionContext): Promise<CompletionRe
});
let list;
try {
list = await lspRequest(() => 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);
+4 -1
Переглянути файл
@@ -51,7 +51,10 @@ export async function requestHover(view: EditorView, pos: number): Promise<Hover
if (hoverRequest !== null && isTauri()) void commands.cancelRequest(hoverRequest);
const id = (hoverRequest = nextRequestId());
try {
const h = await lspRequest(() => 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;
+3 -2
Переглянути файл
@@ -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);
+11 -3
Переглянути файл
@@ -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<String> 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([]);
});
});
+33 -22
Переглянути файл
@@ -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;
}
/**
+4 -1
Переглянути файл
@@ -20,12 +20,15 @@ type Result<T> = { 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<T>(run: () => Promise<Result<T>>): Promise<T> {
export async function lspRequest<T>(run: () => Promise<Result<T>>, wanted?: () => boolean): 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();
if (wanted && !wanted()) throw new IpcError({ kind: "cancelled", message: "Cancelled" });
return await call(run());
} catch (e) {
if (e instanceof IpcError) throw e;
+22
Переглянути файл
@@ -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");
+20 -3
Переглянути файл
@@ -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 = [];
+17 -18
Переглянути файл
@@ -427,27 +427,25 @@ function ChangesPanel() {
</span>
);
// 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<HTMLSpanElement>) => {
const paths =
selected.has(n.key) && selected.size > 1
@@ -458,7 +456,8 @@ function ChangesPanel() {
payload: paths.map(stripSlash).join("\n"),
});
},
};
}),
};
return (
<span
className={clsx(