fix(editor): Show the ⌘/Ctrl-hover link without waiting for documentation (#71)

## Problem
When you hold ⌘ (Ctrl on Windows/Linux) and hover a symbol, the navigation link had three problems:
- **Slow to appear.** The underline and pointer cursor only showed after the hover (documentation) request came back. A slow or stuck documentation request held the link back.
- **Errors were remembered.** If the definition request was cancelled or the server returned an error, the word was cached as having no target. Hovering it again never retried.
- **Stuck requests blocked the word.** A definition request that never answered blocked any new request for that word.

## Changes (`src/editor/lsp/navigation.ts`)
- The link is underlined as soon as a definition is found. The short summary is filled in when the hover request returns.
- Cancellation and server errors count as temporary failures. They are no longer cached, so the next hover retries.
- A definition request expires after 3 seconds and can be retried. If its response arrives later, it is ignored.
- Responses for a word the pointer has already left, or that arrive after the editor plugin is destroyed, are dropped.

## Tests
- Four new tests in `src/editor/lsp/syntaxFallback.test.ts`: the link shows without waiting for documentation, a temporary error is retried, a stuck request is retried and its late answer ignored, and a late answer for the previous word is ignored. The first three fail without this change.
- `pnpm vitest run src/editor/lsp`: 179 passed. `pnpm typecheck` and eslint report no errors.
- Checked in the running app (`fixtures/ts-app`, `cart.ts`): ⌘-hover over `Cents` showed the underline and pointer cursor after 312 ms and the type summary after 356 ms. ⌘-click opened `money.ts` at the definition.
Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-10-27 14:23:23 +08:00
1 джерело 7f0fd870e8
коміт 12418e0184
2 files changed
+143 -13

No files matched your search

+58 -12
Переглянути файл
@@ -320,15 +320,14 @@ async function probeNavigation(view: EditorView, offset: number): Promise<{ info
try {
navigable = (await lspRequest(() => commands.lspGoto(doc.doc, pos, "definition"))).length > 0;
} catch (e) {
if (isCancelled(e)) return null;
if (isUnavailable(e)) navigable = !!(await gotoFallback(view, doc, pos))?.length;
if (!isUnavailable(e)) throw e;
navigable = !!(await gotoFallback(view, doc, pos))?.length;
}
if (!navigable && WEB_LANGUAGES.has(doc.language) && !doc.virtual)
navigable = !!(await lspRequest(() => commands.webGoto(doc.doc, pos)).catch(() => null))?.targets.length;
}
if (!navigable) return null;
const hover = await requestHover(view, offset).catch(() => null);
return { info: hover ? declarationSummary(hover.contents) : null };
return { info: null };
}
const linkPlugin = ViewPlugin.fromClass(
@@ -339,6 +338,10 @@ const linkPlugin = ViewPlugin.fromClass(
private probedDoc: Text | null = null;
private probing: string | null = null;
private off: () => void;
private disposed = false;
private hovered: string | null = null;
private probeVersion = 0;
private probeTimer: ReturnType<typeof setTimeout> | undefined;
constructor(readonly view: EditorView) {
// Window-wide, so ⌘/Ctrl over an unfocused editor underlines too; released anywhere clears it.
this.off = onNavigationModifier((held) => {
@@ -347,10 +350,16 @@ const linkPlugin = ViewPlugin.fromClass(
}
destroy() {
this.off();
this.disposed = true;
this.probeVersion++;
clearTimeout(this.probeTimer);
}
private set(value: LinkState | null) {
const current = this.view.state.field(linkField);
if (current === value || (current && value && current.from === value.from && current.to === value.to && current.info === value.info))
if (
current === value ||
(current && value && current.from === value.from && current.to === value.to && current.info === value.info)
)
return;
if (!current && !value) return;
this.view.dispatch({ effects: setLink.of(value) });
@@ -361,10 +370,19 @@ const linkPlugin = ViewPlugin.fromClass(
// Underline whole links once the server's links are in.
if (doc && !doc.virtual && !knownLinks(view))
void documentLinks(view, doc).then(() => {
if (view.state.field(linkField)) this.show(true);
if (!this.disposed && view.state.field(linkField)) this.show(true);
});
const target = meta && this.last ? linkAt(view, this.last.x, this.last.y) : null;
const hovered = target ? `${target.from}:${target.to}` : null;
if (hovered !== this.hovered) {
this.hovered = hovered;
this.probeVersion++;
clearTimeout(this.probeTimer);
this.probing = null;
}
if (!target) {
this.probeVersion++;
clearTimeout(this.probeTimer);
this.probing = null;
this.set(null);
return;
@@ -388,13 +406,41 @@ const linkPlugin = ViewPlugin.fromClass(
if (current && (current.from !== target.from || current.to !== target.to)) this.set(null);
if (this.probing === key) return;
this.probing = key;
const version = ++this.probeVersion;
clearTimeout(this.probeTimer);
// A timed-out probe must be retryable, rather than cached as "no target".
this.probeTimer = setTimeout(() => {
if (this.probeVersion === version) {
this.probeVersion++;
this.probing = null;
}
}, 3000);
const docAt = view.state.doc;
void probeNavigation(view, target.from).then((result) => {
if (view.state.doc !== docAt) return;
this.probed.set(key, result);
if (this.probing !== key) return;
this.probing = null;
if (result) this.set({ from: target.from, to: target.to, info: result.info });
void probeNavigation(view, target.from)
.then((result) => {
if (this.disposed || view.state.doc !== docAt || this.probeVersion !== version) return;
clearTimeout(this.probeTimer);
this.probed.set(key, result);
if (this.probing !== key) return;
this.probing = null;
if (result) {
this.set({ from: target.from, to: target.to, info: null });
// Documentation must never delay the link cursor or underline.
void requestHover(view, target.from)
.then((hover) => {
if (this.disposed || view.state.doc !== docAt || this.probeVersion !== version || !hover) return;
const info = declarationSummary(hover.contents);
this.probed.set(key, { info });
this.set({ from: target.from, to: target.to, info });
})
.catch(() => {});
}
})
.catch(() => {
if (this.disposed || this.probeVersion !== version) return;
clearTimeout(this.probeTimer);
this.probing = null;
// Cancellation and server errors are transient, not negative results.
});
}
},
+85 -1
Переглянути файл
@@ -1,4 +1,4 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { EditorState, Text } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import type { IdeError, Locals, NodeAt, Range, SymbolMatch, SyntaxNode } from "@/bindings";
@@ -8,6 +8,9 @@ const ok = <T>(data: T) => Promise.resolve({ status: "ok" as const, data });
const unavailable = () => Promise.resolve({ status: "error" as const, error: { kind: "unavailable", message: "no server" } as IdeError });
const cmd = {
lspHover: vi.fn((): R<unknown> => ok(null)),
lspDocumentLinks: vi.fn(() => ok([])),
cancelRequest: vi.fn(() => ok(null)),
lspGoto: vi.fn((): R<unknown> => unavailable()),
lspDocumentHighlights: vi.fn((): R<unknown> => unavailable()),
lspPrepareRename: vi.fn((): R<unknown> => unavailable()),
@@ -390,3 +393,84 @@ describe("rename wiring", () => {
expect(useLspPopup.getState().popup?.kind).toBe("rename");
});
});
describe("modifier-hover navigation recovery", () => {
const target = { path: "/p/target.ts", range: r(0, 0, 1), uri: null };
let mac = false;
beforeAll(async () => {
mac = (await import("@/platform/keymap")).isMacHost();
});
function move(view: EditorView, pos = 0, primary = true) {
vi.spyOn(view, "posAtCoords").mockReturnValue(pos);
view.contentDOM.dispatchEvent(
new MouseEvent("mousemove", {
bubbles: true,
metaKey: primary && mac,
ctrlKey: primary && !mac,
}),
);
}
it("shows the link without waiting for documentation", async () => {
cmd.lspGoto.mockReturnValueOnce(ok([target]));
cmd.lspHover.mockReturnValueOnce(new Promise(() => {}));
const view = editor("foo");
move(view);
await vi.waitFor(() => expect(view.dom.querySelector(".cm-lsp-link")?.textContent).toBe("foo"));
expect(cmd.lspHover).toHaveBeenCalled();
});
it("retries a transient server error instead of caching no target", async () => {
cmd.lspGoto.mockReturnValueOnce(
Promise.resolve({ status: "error", error: { kind: "internal", message: "timeout" } }),
);
const view = editor("foo");
move(view);
await new Promise((resolve) => setTimeout(resolve, 20));
cmd.lspGoto.mockReturnValueOnce(ok([target]));
move(view);
await vi.waitFor(() => expect(view.dom.querySelector(".cm-lsp-link")?.textContent).toBe("foo"));
expect(cmd.lspGoto).toHaveBeenCalledTimes(2);
});
it("retries a stuck probe and ignores its eventual response", async () => {
let finish!: (value: Awaited<R<unknown>>) => void;
cmd.lspGoto.mockReturnValueOnce(
new Promise((resolve) => {
finish = resolve;
}),
);
const view = editor("foo");
vi.useFakeTimers();
try {
move(view);
await vi.advanceTimersByTimeAsync(3001);
cmd.lspGoto.mockReturnValueOnce(ok([target]));
move(view);
expect(cmd.lspGoto).toHaveBeenCalledTimes(2);
} finally {
vi.useRealTimers();
}
await vi.waitFor(() => expect(view.dom.querySelector(".cm-lsp-link")?.textContent).toBe("foo"));
finish({ status: "ok", data: [] });
await new Promise((resolve) => setTimeout(resolve, 20));
expect(view.dom.querySelector(".cm-lsp-link")?.textContent).toBe("foo");
});
it("ignores a late probe after moving to a different word", async () => {
let finish!: (value: Awaited<R<unknown>>) => void;
cmd.lspGoto.mockReturnValueOnce(
new Promise((resolve) => {
finish = resolve;
}),
);
cmd.lspGoto.mockReturnValueOnce(ok([target]));
const view = editor("foo bar");
move(view);
move(view, 4);
await vi.waitFor(() => expect(view.dom.querySelector(".cm-lsp-link")?.textContent).toBe("bar"));
finish({ status: "ok", data: [target] });
await new Promise((resolve) => setTimeout(resolve, 20));
expect(view.dom.querySelector(".cm-lsp-link")?.textContent).toBe("bar");
});
});