Files
openidea/src/features/code-insight/servers.ts
T
l0ng-ai 357b728f1d Editor: quick fixes, completion, navigation while typing, and split-pane drag (#109)
* feat(editor): Drag tabs and files between split panes

HTML5 drag and drop never reaches the webview while native file drop is
enabled, so tab, project-file and changelist drags did nothing. They now
run on pointer events:

- Drag an editor tab onto another pane, or onto a pane's edge to split it.
- Drag files from the Project view into an editor pane (center opens them
  there, an edge opens them in a new split).
- Drag files between changelists in the Commit view.
- "Drag-and-drop with Alt pressed only" applies to tabs and stripe buttons.

* fix(lsp): Deliver server settings and start servers on a supported JDK

- workspace/configuration answers a server's own section (basedpyright)
  from its settings block; basedpyright runs in "standard" mode and keeps
  auto search paths on, so `src/` layouts resolve from `tests/`.
- jdtls: formatting follows the editor indent, calls complete without
  guessed arguments, and JDK internals stay out of completion.
- `max-java` in a language server config picks an installed JDK no newer
  than that (kotlin-language-server fails on JDK 25).
- Server initialization may take up to 5 minutes (first Gradle import).
- `lsp_resend_diagnostics` re-emits stored diagnostics after a reload.
- Spelling only checks declared names (not called methods like
  `substring`) and accepts plurals of known software terms.

* 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.

* chore(lint): Skip agent worktrees under .claude

* 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`.

* fix(editor): Rank the exact type first in Java constructor completion

jdtls appends the call or body to filterText (`String(${0})`); matching
on the label instead lets typing `String` rank `String` above
`StringBuilder` and the other types sharing its sort text.

* fix(editor): List identical completion items once

kotlin-language-server repeats a class once per classpath entry declaring
it, so `String()` and `String` were listed three times each.

* fix(editor): Keep the internal cancellation message out of the UI text catalog
2026-10-28 09:07:50 +08:00

155 lines
5.1 KiB
TypeScript

/** Language server status: `lsp_servers` snapshot + `languageServer` events. */
import { getAction } from "@/platform/actions";
import { create } from "zustand";
import type { ServerId, ServerInfo, ServerStatus } from "@/bindings";
import { call, commands, isTauri, onIdeEvent } from "@/platform/ipc";
import { useProject } from "@/platform/project";
import { notifications } from "@/workbench/contributions";
export interface ServerEntry {
id: ServerId;
name: string;
status: ServerStatus;
message: string | null;
languages: string[];
root: string | null;
}
interface ServersState {
servers: ReadonlyMap<ServerId, ServerEntry>;
}
export const useServers = create<ServersState>(() => ({ servers: new Map() }));
const notifiedMissing = new Set<string>();
/** Event count at each server's latest event, so a snapshot taken earlier doesn't undo it. */
let eventSeq = 0;
const lastEvent = new Map<ServerId, number>();
function notifyMissing(e: ServerEntry) {
if (notifiedMissing.has(e.name)) return;
notifiedMissing.add(e.name);
// features/tools shows the same notification with an Install button.
if (getAction("tools.install")) return;
notifications().notify({
group: "lsp",
level: "warning",
title: `Language server '${e.name}' is not installed`,
body: e.message ?? "Code insight features are unavailable for these files until it is installed.",
key: `lsp-missing-${e.name}`,
});
}
export function upsertServer(entry: Partial<ServerEntry> & { id: ServerId; name: string; status: ServerStatus }) {
const prev = useServers.getState().servers.get(entry.id);
const next: ServerEntry = {
languages: prev?.languages ?? [],
root: prev?.root ?? null,
message: null,
...prev,
...entry,
};
useServers.setState((s) => ({ servers: new Map(s.servers).set(entry.id, next) }));
if (next.status === "missing") notifyMissing(next);
}
export async function refreshServers() {
if (!useProject.getState().project) {
useServers.setState({ servers: new Map() });
return;
}
if (!isTauri()) return;
try {
const since = eventSeq;
const list: ServerInfo[] = await call(commands.lspServers());
const prev = useServers.getState().servers;
const map = new Map<ServerId, ServerEntry>();
for (const s of list) {
const p = prev.get(s.id);
const newer = p && (lastEvent.get(s.id) ?? 0) > since;
map.set(s.id, {
id: s.id,
name: s.name,
status: newer ? p.status : s.status,
languages: s.languages,
root: s.root,
message: p?.message ?? null,
});
}
useServers.setState({ servers: map });
for (const e of map.values()) if (e.status === "missing") notifyMissing(e);
} catch {
// No project / backend not ready; events will fill in.
}
}
export async function restartServer(id: ServerId) {
try {
await call(commands.lspRestart(id));
} catch (e) {
notifications().notify({
group: "lsp",
level: "error",
title: "Failed to restart language server",
body: e instanceof Error ? e.message : String(e),
});
}
}
export function installServerEvents(): () => void {
const off = onIdeEvent("languageServer", (e) => {
const known = useServers.getState().servers.has(e.server);
lastEvent.set(e.server, ++eventSeq);
upsertServer({ id: e.server, name: e.name, status: e.status, message: e.message });
if (!known) void refreshServers();
});
let root = useProject.getState().project?.root ?? null;
void refreshServers();
const unsub = useProject.subscribe((s) => {
const r = s.project?.root ?? null;
if (r === root) return;
root = r;
notifiedMissing.clear();
void refreshServers();
});
return () => {
off();
unsub();
};
}
export const STATUS_LABEL: Record<ServerStatus, string> = {
starting: "Starting…",
ready: "Running",
busy: "Busy",
crashed: "Crashed",
stopped: "Stopped",
missing: "Not installed",
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 === "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))];
const text = names.length === 1 ? names[0]! : `${names[0]} +${names.length - 1}`;
return { text, tone };
}