diff --git a/src/editor/lsp/rename.ts b/src/editor/lsp/rename.ts index 48fb7ec4..aaeed754 100644 --- a/src/editor/lsp/rename.ts +++ b/src/editor/lsp/rename.ts @@ -15,12 +15,14 @@ import type { EditorView } from "@codemirror/view"; import { commands, isCancelled, + isNoServer, nextRequestId, unavailableText, type Pos, type RequestId, type WorkspaceEdit, } from "@/platform/ipc"; +import { executeAction } from "@/platform/actions"; import { whenSynced, type EditorDocContext } from "@/editor/api"; import { structure } from "@/editor/structure"; import { docOf, incompleteNote, isUnavailable, lspRequest, reportLspError } from "./lsp"; @@ -78,7 +80,10 @@ export async function startRename(view: EditorView) { range = await lspRequest(() => commands.lspPrepareRename(doc.doc, pos)); } catch (e) { if (isCancelled(e)) return; - if (!(await startSyntaxRename(view, doc, pos, e))) reportLspError(view, e, true); + if (await startSyntaxRename(view, doc, pos, e)) return; + // No server covers this file (a plain text file): Shift+F6 renames the file, like IDEA. + if (isNoServer(e) && (await executeAction("project.rename"))) return; + reportLspError(view, e, true); return; } if (!range) { diff --git a/src/editor/lsp/syntaxFallback.test.ts b/src/editor/lsp/syntaxFallback.test.ts index 2b811ad5..366cbb6d 100644 --- a/src/editor/lsp/syntaxFallback.test.ts +++ b/src/editor/lsp/syntaxFallback.test.ts @@ -30,6 +30,13 @@ vi.mock("@/platform/ipc", async (orig) => { return { ...actual, isTauri: () => true, commands: { ...actual.commands, ...cmd } }; }); +const executeAction = vi.hoisted(() => vi.fn()); +vi.mock("@/platform/actions", async (orig) => { + const actual = await orig(); + executeAction.mockImplementation(actual.executeAction); + return { ...actual, executeAction }; +}); + const openFile = vi.fn(async () => {}); const { provideEditorService } = await import("@/workbench/contributions"); provideEditorService({ openFile } as never); @@ -488,6 +495,34 @@ describe("rename wiring", () => { }); }); + it("renames the file itself when no server covers it (a plain text file)", async () => { + cmd.lspPrepareRename.mockReturnValueOnce( + Promise.resolve({ + status: "error", + error: { kind: "unavailable", message: "no running language server supports rename" } as IdeError, + }), + ); + cmd.syntaxSupport.mockReturnValueOnce(ok({ tree: false, locals: false })); + executeAction.mockResolvedValueOnce(true); + const view = editor("# notes", "markdown", 2); + await startRename(view); + expect(executeAction).toHaveBeenCalledWith("project.rename"); + expect(document.querySelector(".cm-lsp-hint")).toBeNull(); + }); + + it("keeps the hint when a server runs but lacks rename (no file fallback)", async () => { + cmd.lspPrepareRename.mockReturnValueOnce( + Promise.resolve({ + status: "error", + error: { kind: "unavailable", message: "jdtls doesn't support rename" } as IdeError, + }), + ); + const view = editor("class A {}", "java", 6); + await startRename(view); + expect(executeAction).not.toHaveBeenCalledWith("project.rename"); + expect(document.querySelector(".cm-lsp-hint")?.textContent).toContain("jdtls doesn't support rename"); + }); + it("respects a server refusal over valid code, but falls back over broken code", async () => { cmd.lspPrepareRename.mockReturnValueOnce(ok(null)); const view = editor("const x = 1;", "typescript", 6); diff --git a/src/platform/ipc.ts b/src/platform/ipc.ts index 9936911d..b9f8c108 100644 --- a/src/platform/ipc.ts +++ b/src/platform/ipc.ts @@ -46,6 +46,11 @@ export function unavailableText(e: unknown, fallback = "No language server avail return e instanceof IpcError && e.kind === "unavailable" && LACKS_FEATURE.test(e.message) ? e.message : fallback; } +/** True when `unavailable` means no server is running for the file, rather than a running server lacking the feature. */ +export function isNoServer(e: unknown): boolean { + return e instanceof IpcError && e.kind === "unavailable" && !LACKS_FEATURE.test(e.message); +} + // Ids are shared by every window of the backend and outlive a reload there (a cancel that // arrives before its request began is remembered), so each page starts at a random point. let nextId = 1 + Math.floor(Math.random() * 0x7000_0000);