From fb55847afa0c54de4854b4c8a558447adfb2d9a4 Mon Sep 17 00:00:00 2001 From: Bl0ck <36800583+Bl0ck154@users.noreply.github.com> Date: Mon, 2 Nov 2026 03:01:04 +0100 Subject: [PATCH 01/11] feat(editor): recognize AsciiDoc files with basic highlighting (#731) Register .adoc/.asciidoc as AsciiDoc in the language catalog (backend registry and in-memory backend) with // line comments, and add a lightweight CodeMirror stream mode for headings, attributes, delimited blocks, emphasis, inline code, URLs and comments. Refs #708. Co-authored-by: Bl0ck154 <36800583+Bl0ck154@users.noreply.github.com> --- crates/ide-language/src/registry.rs | 8 +++++ languages/asciidoc.toml | 6 ++++ src/editor/core/backend.ts | 11 ++++++ src/editor/core/cm/asciidoc.ts | 50 ++++++++++++++++++++++++++++ src/editor/core/cm/languages.test.ts | 48 ++++++++++++++++++++++++++ src/editor/core/cm/languages.ts | 7 +++- 6 files changed, 129 insertions(+), 1 deletion(-) create mode 100644 languages/asciidoc.toml create mode 100644 src/editor/core/cm/asciidoc.ts diff --git a/crates/ide-language/src/registry.rs b/crates/ide-language/src/registry.rs index 31a9eda2..908d3ca5 100644 --- a/crates/ide-language/src/registry.rs +++ b/crates/ide-language/src/registry.rs @@ -8,6 +8,7 @@ use std::sync::Arc; /// Built-in definitions, embedded at compile time. Add new languages here. const BUILTIN: &[(&str, &str)] = &[ + ("asciidoc", include_str!("../../../languages/asciidoc.toml")), ("bat", include_str!("../../../languages/bat.toml")), ("c", include_str!("../../../languages/c.toml")), ("clojure", include_str!("../../../languages/clojure.toml")), @@ -373,6 +374,7 @@ mod tests { /// `name`s exported by `@codemirror/language-data` or built into `src/editor/core/cm/languages.ts` /// (the UI resolves `ui-mode` against these). const UI_MODES: &[&str] = &[ + "AsciiDoc", "Batch", "C", "C++", @@ -532,6 +534,12 @@ mod tests { let reg = reg(); assert_eq!(detect(®, "src/main.rs"), "rust"); assert_eq!(detect(®, "a/b.TS"), "typescript"); + for path in ["guide.adoc", "guide.asciidoc", "GUIDE.ADOC"] { + assert_eq!(detect(®, path), "asciidoc"); + } + let asciidoc = reg.get(&LanguageId::new("asciidoc")).unwrap(); + assert_eq!(asciidoc.ui_mode.as_deref(), Some("AsciiDoc")); + assert_eq!(asciidoc.line_comment.as_deref(), Some("//")); assert_eq!(reg.detect(Path::new("script"), Some("#!/usr/bin/env python3")).as_str(), "python"); assert_eq!(detect(®, "README"), "plaintext"); assert_eq!(detect(®, "unknown.zzz"), "plaintext"); diff --git a/languages/asciidoc.toml b/languages/asciidoc.toml new file mode 100644 index 00000000..e798cbbd --- /dev/null +++ b/languages/asciidoc.toml @@ -0,0 +1,6 @@ +id = "asciidoc" +name = "AsciiDoc" +extensions = ["adoc", "asciidoc"] +line-comment = "//" +indent = { tab-size = 2, insert-spaces = true } +ui-mode = "AsciiDoc" diff --git a/src/editor/core/backend.ts b/src/editor/core/backend.ts index 8e4b020c..55dfc59a 100644 --- a/src/editor/core/backend.ts +++ b/src/editor/core/backend.ts @@ -119,6 +119,8 @@ const EXT_LANG: Record = { go: "go", json: "json", md: "markdown", + adoc: "asciidoc", + asciidoc: "asciidoc", css: "css", html: "html", yml: "yaml", @@ -218,6 +220,15 @@ const MEM_LANGUAGES: LanguageInfo[] = [ blockComment: null, indent: { tabSize: 2, insertSpaces: true }, }, + { + id: "asciidoc", + name: "AsciiDoc", + extensions: ["adoc", "asciidoc"], + uiMode: "AsciiDoc", + lineComment: "//", + blockComment: null, + indent: { tabSize: 2, insertSpaces: true }, + }, { id: "markdown", name: "Markdown", diff --git a/src/editor/core/cm/asciidoc.ts b/src/editor/core/cm/asciidoc.ts new file mode 100644 index 00000000..b24a5f3a --- /dev/null +++ b/src/editor/core/cm/asciidoc.ts @@ -0,0 +1,50 @@ +import { StreamLanguage, type StreamParser } from "@codemirror/language"; + +type State = { block: string | null }; + +const parser: StreamParser = { + startState: () => ({ block: null }), + copyState: ({ block }) => ({ block }), + token(stream, state) { + if (stream.sol()) { + const line = stream.string; + const delimiter = line.trim(); + if (state.block) { + if (delimiter === state.block) { + state.block = null; + stream.skipToEnd(); + return "meta"; + } + stream.skipToEnd(); + return "monospace"; + } + if (/^={1,6}\s+\S/.test(line)) { + stream.skipToEnd(); + return "heading"; + } + if (/^:[\w-]+:/.test(line)) { + stream.skipToEnd(); + return "attributeName"; + } + if (["----", "....", "____", "++++"].includes(delimiter)) { + state.block = delimiter; + stream.skipToEnd(); + return "meta"; + } + if (/^\s*\/\//.test(line)) { + stream.skipToEnd(); + return "comment"; + } + } + if (stream.eatSpace()) return null; + if (stream.match(/^https?:\/\/[^\s[\]<>]+/)) return "link"; + if (stream.match(/^\*[^*\n]+\*/)) return "strong"; + if (stream.match(/^_[^_\n]+_/)) return "emphasis"; + if (stream.match(/^\x60[^\x60\n]+\x60/)) return "monospace"; + stream.next(); + return null; + }, + languageData: { commentTokens: { line: "//" } }, +}; + +export const asciidocLanguage = StreamLanguage.define(parser); diff --git a/src/editor/core/cm/languages.test.ts b/src/editor/core/cm/languages.test.ts index 26101398..72d674ea 100644 --- a/src/editor/core/cm/languages.test.ts +++ b/src/editor/core/cm/languages.test.ts @@ -3,6 +3,54 @@ import { highlightTree, tagHighlighter, tags as t } from "@lezer/highlight"; import { findLanguageDescription, loadSupport } from "./languages"; describe("language modes", () => { + it("recognizes AsciiDoc files and loads their lightweight syntax mode", async () => { + for (const path of ["/docs/guide.adoc", "/docs/guide.asciidoc"]) { + const description = findLanguageDescription("AsciiDoc", path); + expect(description?.name).toBe("AsciiDoc"); + const support = await loadSupport(description!); + expect(support?.language).toBeTruthy(); + } + }); + + it("assigns highlight tags to AsciiDoc syntax", async () => { + const support = await loadSupport(findLanguageDescription("AsciiDoc", "/docs/guide.adoc")!); + const doc = [ + "= Guide", + ":toc: left", + "----", + "const example = true;", + "----", + "*bold* _italic_ `code` https://example.com", + "// Comment", + ].join("\n"); + const highlighter = tagHighlighter([ + { tag: t.heading, class: "heading" }, + { tag: t.attributeName, class: "attribute" }, + { tag: t.meta, class: "meta" }, + { tag: t.monospace, class: "monospace" }, + { tag: t.strong, class: "strong" }, + { tag: t.emphasis, class: "emphasis" }, + { tag: t.link, class: "link" }, + { tag: t.comment, class: "comment" }, + ]); + const highlighted: [string, string][] = []; + highlightTree(support!.language.parser.parse(doc), highlighter, (from, to, style) => { + highlighted.push([doc.slice(from, to), style]); + }); + expect(highlighted).toEqual([ + ["= Guide", "heading"], + [":toc: left", "attribute"], + ["----", "meta"], + ["const example = true;", "monospace"], + ["----", "meta"], + ["*bold*", "strong"], + ["_italic_", "emphasis"], + ["`code`", "monospace"], + ["https://example.com", "link"], + ["// Comment", "comment"], + ]); + }); + it("styles `?.` like `.` in JavaScript and TypeScript", async () => { const highlighter = tagHighlighter([{ tag: t.derefOperator, class: "deref" }]); for (const [mode, path] of [ diff --git a/src/editor/core/cm/languages.ts b/src/editor/core/cm/languages.ts index bcd1d9bd..9be9fbe3 100644 --- a/src/editor/core/cm/languages.ts +++ b/src/editor/core/cm/languages.ts @@ -68,6 +68,11 @@ const OWN_MODES = [ filename: /^(?:CMakeLists\.txt|.*\.cmake(?:\.in)?)$/, load: async () => new LanguageSupport((await import("./cmake")).cmakeLanguage), }), + LanguageDescription.of({ + name: "AsciiDoc", + extensions: ["adoc", "asciidoc"], + load: () => import("./asciidoc").then((m) => new LanguageSupport(m.asciidocLanguage)), + }), LanguageDescription.of({ name: "Shell", load: async () => { @@ -245,7 +250,7 @@ const DEDENT_STATEMENT = /^\s*(?:pass|break|continue|return)\b/; const KEYWORD_OPEN_LANGS = new Set(["elixir", "ruby", "lua"]); const KEYWORD_OPEN = /\b(?:do(?:\s*\|[^|]*\|)?|then|->)$|^\s*(?:def|defp|defmodule|class|module|function|local\s+function|if|unless|elsif|elseif|else|while|until|for|case|begin|cond|with|receive|try)\b/; -const NO_INDENT_LANGS = new Set(["plaintext", "markdown", "log", "diff", "ignore", "gitattributes"]); +const NO_INDENT_LANGS = new Set(["plaintext", "markdown", "asciidoc", "log", "diff", "ignore", "gitattributes"]); function opensBlock(code: string, language: string | undefined): boolean { if (!code) return false; From c5ff0de1eeebdb7c7ea27852dcf7801c0a02e804 Mon Sep 17 00:00:00 2001 From: arookieofc <2128194521@qq.com> Date: Mon, 2 Nov 2026 10:03:59 +0800 Subject: [PATCH 02/11] fix(terminal): open the file a link names, not a same-named one (#711) resolvePath only understood POSIX paths, so on Windows the direct lookup never matched and every click fell back to a project-wide search by bare file name, which opened any same-named file. Resolve Windows drive paths and backslashes, and make the fallback query include the link's directory and check the hit's project-relative path ends with it; a bare-name match is used only for links without a directory. Fixes #705. Co-authored-by: a-rookie-of-C-language <186315048+a-rookie-of-C-language@users.noreply.github.com> --- src/features/terminal/links.ts | 14 ++--- src/features/terminal/session.test.ts | 74 ++++++++++++++++++++++++++ src/features/terminal/session.ts | 27 ++++++---- src/features/terminal/terminal.test.ts | 8 +++ 4 files changed, 107 insertions(+), 16 deletions(-) create mode 100644 src/features/terminal/session.test.ts diff --git a/src/features/terminal/links.ts b/src/features/terminal/links.ts index ca2a4acd..51c97189 100644 --- a/src/features/terminal/links.ts +++ b/src/features/terminal/links.ts @@ -60,14 +60,14 @@ export function findTerminalLinks(text: string): TerminalLink[] { return links.sort((a, b) => a.start - b.start); } -/** Resolves `path` against `cwd` (POSIX); `~` expands with `home` when known. */ +/** Resolves `path` against `cwd` (POSIX, or Windows drive paths); `~` expands with `home` when known. */ export function resolvePath(path: string, cwd: string, home?: string | null): string { - let p = path; + let p = path.replace(/\\/g, "/"); if (p === "~" || p.startsWith("~/")) { if (!home) return p; - p = home + p.slice(1); - } else if (!p.startsWith("/")) { - p = `${cwd.replace(/\/+$/, "")}/${p}`; + p = home.replace(/\\/g, "/") + p.slice(1); + } else if (!p.startsWith("/") && !/^[A-Za-z]:\//.test(p)) { + p = `${cwd.replace(/\\/g, "/").replace(/\/+$/, "")}/${p}`; } const parts: string[] = []; for (const seg of p.split("/")) { @@ -75,5 +75,7 @@ export function resolvePath(path: string, cwd: string, home?: string | null): st if (seg === "..") parts.pop(); else parts.push(seg); } - return "/" + parts.join("/"); + const joined = parts.join("/"); + // A Windows drive prefix is its own root; POSIX results keep the leading slash. + return /^[A-Za-z]:\//.test(joined) ? joined : `/${joined}`; } diff --git a/src/features/terminal/session.test.ts b/src/features/terminal/session.test.ts new file mode 100644 index 00000000..10134ade --- /dev/null +++ b/src/features/terminal/session.test.ts @@ -0,0 +1,74 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { findTerminalLinks, type TerminalLink } from "./links"; +import { openFileLink } from "./session"; + +const ipc = vi.hoisted(() => ({ + call: vi.fn(async (v: unknown) => v), + commands: { fsReadDir: vi.fn(), findFiles: vi.fn() }, +})); +const workbench = vi.hoisted(() => ({ + openFile: vi.fn(async () => undefined), + notify: vi.fn(), +})); + +vi.mock("@/platform/ipc", () => ({ call: ipc.call, commands: ipc.commands, isTauri: () => true })); +vi.mock("@/workbench/contributions", () => ({ + editorService: () => ({ openFile: workbench.openFile }), + notifications: () => ({ notify: workbench.notify }), + OWNS_FILE_DROP: { "data-owner": true }, +})); + +const fileLink = (text: string): Extract => { + const [l] = findTerminalLinks(text); + if (!l || l.kind !== "file") throw new Error(`not a file link: ${text}`); + return l; +}; + +describe("openFileLink", () => { + beforeEach(() => vi.clearAllMocks()); + + it("opens the resolved path when the directory lists it", async () => { + ipc.commands.fsReadDir.mockResolvedValue([{ name: "main.php", kind: "file" }]); + await openFileLink(fileLink("a/main.php:2"), "C:\\Users\\me\\proj"); + expect(ipc.commands.fsReadDir).toHaveBeenCalledWith("C:/Users/me/proj/a"); + expect(workbench.openFile).toHaveBeenCalledWith("C:/Users/me/proj/a/main.php", { + selection: { line: 1, col: 0 }, + focus: true, + }); + expect(ipc.commands.findFiles).not.toHaveBeenCalled(); + }); + + it("ranks the link's directory over the bare name in the project-wide fallback (#705)", async () => { + ipc.commands.fsReadDir.mockResolvedValue([]); + ipc.commands.findFiles.mockImplementation(async (q: string) => + q === "a/main.php" + ? [ + { path: "/p/b/main.php", relPath: "b/main.php" }, + { path: "/p/a/main.php", relPath: "a/main.php" }, + ] + : [{ path: "/p/c/main.php", relPath: "c/main.php" }], + ); + await openFileLink(fileLink("a/main.php:2"), "/p"); + expect(ipc.commands.findFiles).toHaveBeenCalledWith("a/main.php", 5); + expect(workbench.openFile).toHaveBeenCalledWith("/p/a/main.php", { + selection: { line: 1, col: 0 }, + focus: true, + }); + }); + + it("falls back to the bare name for a basename-only link", async () => { + ipc.commands.fsReadDir.mockResolvedValue([]); + ipc.commands.findFiles.mockResolvedValue([{ path: "/p/x/main.php", relPath: "x/main.php" }]); + await openFileLink(fileLink("main.php:1"), "/p"); + expect(ipc.commands.findFiles).toHaveBeenCalledTimes(1); + expect(workbench.openFile).toHaveBeenCalledWith("/p/x/main.php", { selection: { line: 0, col: 0 }, focus: true }); + }); + + it("warns when nothing matches", async () => { + ipc.commands.fsReadDir.mockResolvedValue([]); + ipc.commands.findFiles.mockResolvedValue([]); + await openFileLink(fileLink("gone.php:1"), "/p"); + expect(workbench.openFile).not.toHaveBeenCalled(); + expect(workbench.notify).toHaveBeenCalledWith({ level: "warning", title: "File not found", body: "gone.php" }); + }); +}); diff --git a/src/features/terminal/session.ts b/src/features/terminal/session.ts index 1f620980..da94bad3 100644 --- a/src/features/terminal/session.ts +++ b/src/features/terminal/session.ts @@ -137,25 +137,32 @@ export const LINK_HANDLER: ILinkHandler = { export async function openFileLink(link: Extract, cwd: string) { const selection = link.line ? { line: link.line - 1, col: Math.max(0, (link.col ?? 1) - 1) } : undefined; const path = resolvePath(link.path, cwd); - const name = link.path.split("/").pop() ?? link.path; + const rel = link.path.replace(/\\/g, "/"); + const name = rel.split("/").pop() ?? rel; try { const slash = path.lastIndexOf("/"); - const entries = await call(commands.fsReadDir(path.slice(0, slash) || "/")); + const entries = await call(commands.fsReadDir(slash > 0 ? path.slice(0, slash) : "/")); if (entries.some((e) => e.name === name && e.kind !== "dir")) { await editorService().openFile(path, { selection, focus: true }); return; } } catch { - /* fall back to a project-wide lookup by file name */ + /* fall back to a project-wide lookup */ } - try { - const [hit] = await call(commands.findFiles(name, 1)); - if (hit && hit.path.endsWith(name)) { - await editorService().openFile(hit.path, { selection, focus: true }); - return; + // The basename alone is ambiguous (`a/x.ts` vs `b/x.ts`): rank the link's + // directory part too and verify the hit actually sits on that path, settling + // for a basename match only when the link names no directory. + for (const q of rel === name ? [name] : [rel, name]) { + try { + const hits = await call(commands.findFiles(q, 5)); + const hit = hits.find((h) => h.relPath === q || h.relPath.endsWith(`/${q}`)); + if (hit) { + await editorService().openFile(hit.path, { selection, focus: true }); + return; + } + } catch { + /* ignore */ } - } catch { - /* ignore */ } notifications().notify({ level: "warning", title: "File not found", body: link.path }); } diff --git a/src/features/terminal/terminal.test.ts b/src/features/terminal/terminal.test.ts index 433749ce..548cb5ed 100644 --- a/src/features/terminal/terminal.test.ts +++ b/src/features/terminal/terminal.test.ts @@ -96,6 +96,14 @@ describe("terminal links", () => { expect(resolvePath("/abs/c.ts", "/p")).toBe("/abs/c.ts"); expect(resolvePath("~/d.ts", "/p", "/Users/me")).toBe("/Users/me/d.ts"); }); + + it("resolves paths against a Windows cwd (#705)", () => { + expect(resolvePath("a/main.php", "C:\\Users\\me\\proj")).toBe("C:/Users/me/proj/a/main.php"); + expect(resolvePath("a\\main.php", "C:/Users/me/proj")).toBe("C:/Users/me/proj/a/main.php"); + expect(resolvePath("C:\\abs\\d.ts", "C:/p")).toBe("C:/abs/d.ts"); + expect(resolvePath("~/e.ts", "C:/p", "C:\\Users\\me")).toBe("C:/Users/me/e.ts"); + expect(resolvePath("../f.ts", "C:/p/web")).toBe("C:/p/f.ts"); + }); }); describe("terminal keys", () => { From c8cc69a56ef251bb41170f7b9ae747e86a4145ba Mon Sep 17 00:00:00 2001 From: arookieofc <2128194521@qq.com> Date: Mon, 2 Nov 2026 10:04:03 +0800 Subject: [PATCH 03/11] feat(language): add D language support (#728) Register D (.d/.di) with comments, indentation, the built-in D highlighting mode, a file icon, and serve-d as its language server (dub.json/dub.sdl roots). Closes #727. Co-authored-by: a-rookie-of-C-language <186315048+a-rookie-of-C-language@users.noreply.github.com> --- crates/ide-language/src/registry.rs | 1 + languages/d.toml | 13 +++++++++++++ src/ui/icons.tsx | 2 ++ src/ui/openidea/fileTypes/d.svg | 3 +++ src/ui/openidea/fileTypes/d_dark.svg | 3 +++ 5 files changed, 22 insertions(+) create mode 100644 languages/d.toml create mode 100644 src/ui/openidea/fileTypes/d.svg create mode 100644 src/ui/openidea/fileTypes/d_dark.svg diff --git a/crates/ide-language/src/registry.rs b/crates/ide-language/src/registry.rs index 908d3ca5..5ceaec74 100644 --- a/crates/ide-language/src/registry.rs +++ b/crates/ide-language/src/registry.rs @@ -16,6 +16,7 @@ const BUILTIN: &[(&str, &str)] = &[ ("cpp", include_str!("../../../languages/cpp.toml")), ("csharp", include_str!("../../../languages/csharp.toml")), ("css", include_str!("../../../languages/css.toml")), + ("d", include_str!("../../../languages/d.toml")), ("dart", include_str!("../../../languages/dart.toml")), ("diff", include_str!("../../../languages/diff.toml")), ("dockerfile", include_str!("../../../languages/dockerfile.toml")), diff --git a/languages/d.toml b/languages/d.toml new file mode 100644 index 00000000..2fcdc2cf --- /dev/null +++ b/languages/d.toml @@ -0,0 +1,13 @@ +id = "d" +name = "D" +extensions = ["d", "di"] +line-comment = "//" +block-comment = ["/*", "*/"] +indent = { tab-size = 4, insert-spaces = true } +ui-mode = "D" + +[[language-servers]] +name = "serve-d" +command = "serve-d" +root-markers = ["dub.json", "dub.sdl", "dub.selections.json"] +install-hint = "Install serve-d (https://github.com/symmetryinvestments/serve-d) and expose it as `serve-d`" diff --git a/src/ui/icons.tsx b/src/ui/icons.tsx index bcf50a27..34163014 100644 --- a/src/ui/icons.tsx +++ b/src/ui/icons.tsx @@ -130,6 +130,7 @@ const ICONS = { "file-gdscript": "openidea/fileTypes/gdscript", "file-godot": "openidea/fileTypes/godot", "file-gdshader": "openidea/fileTypes/gdshader", + "file-d": "openidea/fileTypes/d", "file-dart": "openidea/fileTypes/dart", "file-elixir": "openidea/fileTypes/elixir", "file-erlang": "openidea/fileTypes/erlang", @@ -878,6 +879,7 @@ const EXT_ICONS = table({ "file-gdscript": "gd", "file-godot": "tscn tres godot import", "file-gdshader": "gdshader gdshaderinc", + "file-d": "d di", "file-dart": "dart", "file-elixir": "ex exs", "file-erlang": "erl hrl escript app.src", diff --git a/src/ui/openidea/fileTypes/d.svg b/src/ui/openidea/fileTypes/d.svg new file mode 100644 index 00000000..861238ca --- /dev/null +++ b/src/ui/openidea/fileTypes/d.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/ui/openidea/fileTypes/d_dark.svg b/src/ui/openidea/fileTypes/d_dark.svg new file mode 100644 index 00000000..216cac95 --- /dev/null +++ b/src/ui/openidea/fileTypes/d_dark.svg @@ -0,0 +1,3 @@ + + + From 239753df9f43374e72e89791c95b12b26acca4ca Mon Sep 17 00:00:00 2001 From: kkmihai Date: Mon, 2 Nov 2026 04:38:02 +0200 Subject: [PATCH 04/11] fix(editor): speed up rapid file opening (#734) Rapid double-clicks queued their opens one after another and an earlier open finishing late took the active tab and focus back. Each open now carries a sequence/activation generation: only the newest request activates and focuses, older ones still add their tab. Pending file probes are shared, document and syntax loading overlap, and repeated folder double-clicks are handled. A pending navigation selection survives a later plain open of any file; a newer selection still wins. Refs #724. Co-authored-by: kkMihai <59262001+kkMihai@users.noreply.github.com> --- src/editor/core/controller.ts | 116 +++++-- src/editor/core/file-open-order.test.ts | 300 ++++++++++++++++++ .../core/model/document-open-latency.test.ts | 120 +++++++ src/editor/core/model/document.ts | 14 +- .../core/model/documentLanguage.test.ts | 4 +- src/editor/core/model/tabs.ts | 3 +- .../project-tree/ProjectView.test.tsx | 29 ++ src/features/project-tree/ProjectView.tsx | 3 +- src/features/viewers/file-probe.test.ts | 56 ++++ src/features/viewers/index.ts | 10 +- 10 files changed, 627 insertions(+), 28 deletions(-) create mode 100644 src/editor/core/file-open-order.test.ts create mode 100644 src/editor/core/model/document-open-latency.test.ts create mode 100644 src/features/viewers/file-probe.test.ts diff --git a/src/editor/core/controller.ts b/src/editor/core/controller.ts index 90e12af6..723a27d5 100644 --- a/src/editor/core/controller.ts +++ b/src/editor/core/controller.ts @@ -171,6 +171,14 @@ function isTextTab(tab: Tab | null | undefined): tab is Tab & { input: { path: s type EditorKinds = ReturnType["editorKinds"]; +interface OpenRequest { + project: number; + admission: number; + sequence: number; + activation: number; + pane: string; +} + /** Tab title of an editor input: the file name, or what its editor kind calls it. */ export function tabTitle(input: EditorInput, kinds: EditorKinds): string { if (input.kind === "file") return basename(input.path ?? input.key); @@ -223,6 +231,10 @@ export class EditorController implements SlotHost { private closeCommitted = false; private projectGeneration = 0; private admissionGeneration = 0; + private openSequence = 0; + private selectionRequests = new Map(); + private activationGeneration = 0; + private admitting = false; private closeRequests = 0; private projectTransition: Promise = Promise.resolve(); @@ -246,6 +258,11 @@ export class EditorController implements SlotHost { useTabs.subscribe((s) => { const prev = prevTabs; prevTabs = s; + if ( + !this.admitting && + (s.activePane !== prev.activePane || s.panes[s.activePane]?.active !== prev.panes[prev.activePane]?.active) + ) + ++this.activationGeneration; this.stampActive(s); this.onTabsChanged(prev, s); }), @@ -347,6 +364,7 @@ export class EditorController implements SlotHost { this.preparedDocuments.clear(); for (const slot of this.slots.values()) slot.dispose(); this.slots.clear(); + this.selectionRequests.clear(); this.manager.stop(); useTabs.setState(initialTabsState(), true); void this.releaseDocuments(documents.values()).catch((e) => this.documentError(e)); @@ -361,6 +379,7 @@ export class EditorController implements SlotHost { private onTabsChanged(prev: TabsState, next: TabsState) { const live = new Set(); for (const p of Object.values(next.panes)) for (const t of p.tabs) live.add(t.id); + for (const id of this.selectionRequests.keys()) if (!live.has(id)) this.selectionRequests.delete(id); for (const [id, slot] of this.slots) { if (!live.has(id)) { this.slots.delete(id); @@ -529,7 +548,9 @@ export class EditorController implements SlotHost { if (active?.id === slot.tabId) this.setActiveSlot(slot); if (slot.wantsFocus) { slot.wantsFocus = false; - requestAnimationFrame(() => slot.focus()); + requestAnimationFrame(() => { + if (!slot.disposed && this.activeTabOf()?.id === slot.tabId) slot.focus(); + }); } } @@ -849,8 +870,15 @@ export class EditorController implements SlotHost { // --- opening --------------------------------------------------------------------------- - private openingGeneration() { - const generation = { project: this.projectGeneration, admission: this.admissionGeneration }; + private openingGeneration(o: OpenOptions): OpenRequest { + const s = useTabs.getState(); + const generation = { + project: this.projectGeneration, + admission: this.admissionGeneration, + sequence: ++this.openSequence, + activation: this.activationGeneration, + pane: o.pane && s.panes[o.pane] ? o.pane : s.activePane, + }; this.checkOpening(generation); return generation; } @@ -861,16 +889,28 @@ export class EditorController implements SlotHost { throw new Error("The project is closing"); } + private isCurrentOpening(generation: OpenRequest) { + return ( + generation.project === this.projectGeneration && + generation.admission === this.admissionGeneration && + generation.sequence === this.openSequence && + generation.activation === this.activationGeneration + ); + } + async openFile(path: string, o: OpenOptions = {}): Promise { - const generation = this.openingGeneration(); - if (isVirtualUri(path)) return this.openVirtual(path, o); + return this.openFileRequest(path, o, this.openingGeneration(o)); + } + + private async openFileRequest(path: string, o: OpenOptions, generation: OpenRequest): Promise { + if (isVirtualUri(path)) return this.openVirtualRequest(path, o, generation); if (isClassFile(path)) { await this.projectTransition; this.checkOpening(generation); try { const target = await call(commands.lspResolveClassFile(path)); this.checkOpening(generation); - return isVirtualUri(target) ? this.openVirtual(target, o) : this.openFile(target, o); + return this.openFileRequest(target, o, generation); } catch (e) { notifications().notify({ level: "warning", title: "Cannot open library class", body: String(e), group: "lsp" }); return; @@ -895,7 +935,10 @@ export class EditorController implements SlotHost { /** Opens the library class (or other server-provided document) behind `uri` read-only. */ async openVirtual(uri: string, o: OpenOptions = {}) { - const generation = this.openingGeneration(); + return this.openVirtualRequest(uri, o, this.openingGeneration(o)); + } + + private async openVirtualRequest(uri: string, o: OpenOptions, generation: OpenRequest) { await this.projectTransition; this.checkOpening(generation); let vd: VirtualDocument; @@ -915,13 +958,13 @@ export class EditorController implements SlotHost { async openInput(input: EditorInput, o: OpenOptions = {}) { if (input.path && isClassFile(input.path)) return this.openFile(input.path, o); - return this.admitInput(input, o, this.openingGeneration()); + return this.admitInput(input, o, this.openingGeneration(o)); } private async admitInput( input: EditorInput, o: OpenOptions, - generation: { project: number; admission: number }, + generation: OpenRequest, resolution?: FileEditorResolution, ) { await this.projectTransition; @@ -931,7 +974,14 @@ export class EditorController implements SlotHost { this.navSuppress++; try { let s = useTabs.getState(); - let paneId = o.pane && s.panes[o.pane] ? o.pane : s.activePane; + const activate = this.isCurrentOpening(generation); + const preview = + !!o.preview && + editorPrefs().tabs.previewTab && + !documentLifecycle(input, useContributions.getState().editorKinds); + if (!activate && preview) return; + let paneId = s.panes[generation.pane] ? generation.pane : activate ? s.activePane : null; + if (!paneId) return; if (o.side) { const right = neighbourPane(s, paneId); if (right) paneId = right; @@ -942,34 +992,44 @@ export class EditorController implements SlotHost { // The new pane starts with only the requested input. const pane = s.panes[paneId]!; s = { ...s, panes: { ...s.panes, [paneId]: { ...pane, tabs: [], active: null, mru: [] } } }; + if (!activate) s = { ...s, activePane: useTabs.getState().activePane }; } } const found = findTab(s, input.key, paneId); let tabId: string; if (found && (!(o.side || o.pane) || found.paneId === paneId)) { paneId = found.paneId; - s = activateTab(setActivePane(s, paneId), paneId, found.tab.id); + if (activate) s = activateTab(setActivePane(s, paneId), paneId, found.tab.id); if (!o.preview || documentLifecycle(input, useContributions.getState().editorKinds)) s = promoteTab(s, paneId, found.tab.id); tabId = found.tab.id; - if (found.tab.input.data !== input.data && input.data !== undefined) + if (activate && found.tab.input.data !== input.data && input.data !== undefined) s = openTab(s, paneId, input, { preview: o.preview && !documentLifecycle(input, useContributions.getState().editorKinds), + activate, }).state; } else { const tabs = editorPrefs().tabs; - const preview = - !!o.preview && tabs.previewTab && !documentLifecycle(input, useContributions.getState().editorKinds); const r = openTab(s, paneId, input, { preview, + activate, + preservePreview: !activate, ...(tabs.openAtEnd ? { index: s.panes[paneId]?.tabs.length ?? 0 } : {}), }); s = r.state; tabId = r.tabId; if (tabs.sortAlphabetically) s = sortTabs(s, paneId, (t) => tabSortKey(t.input)); } - resolution?.admit(); - useTabs.setState(s, true); + if (o.selection) + this.selectionRequests.set(tabId, Math.max(generation.sequence, this.selectionRequests.get(tabId) ?? 0)); + this.admitting = true; + try { + resolution?.admit(); + useTabs.setState(s, true); + } finally { + this.admitting = false; + } + const canFocus = () => this.isCurrentOpening(generation) && this.activeTabOf()?.id === tabId; const tabPrefs = editorPrefs().tabs; await this.removeGuarded( (current) => @@ -982,19 +1042,26 @@ export class EditorController implements SlotHost { (tab) => inputDirty(tab.input, useContributions.getState().editorKinds) ?? !!(tab.input.path && useDocMeta.getState()[tab.input.path]?.dirty), - tabId, + activate ? tabId : (current.panes[paneId]?.active ?? tabId), (tab) => inputDirty(tab.input, useContributions.getState().editorKinds) === true, ) : current, { protect: (tab) => inputDirty(tab.input, useContributions.getState().editorKinds) === true }, ); if (!isTextInput(input)) { - if (o.focus !== false) requestAnimationFrame(() => this.focus()); + if (o.focus !== false) + requestAnimationFrame(() => { + if (canFocus()) this.focus(); + }); return; } const slot = await this.ensureSlot(tabId); if (!slot) return; - if (o.selection) { + if ( + o.selection && + this.selectionRequests.get(tabId) === generation.sequence && + generation.activation === this.activationGeneration + ) { const state = slot.currentState(); const toOffset = (line: number, col: number) => { const l = state.doc.line(Math.min(Math.max(1, line + 1), state.doc.lines)); @@ -1006,7 +1073,7 @@ export class EditorController implements SlotHost { // Selection runs anchor→head; place the caret at the start like IDEA's navigation. slot.reveal({ anchor: head, head: anchor, center: true }); } - if (o.focus !== false) { + if (o.focus !== false && canFocus()) { if (slot.view) slot.focus(); else slot.wantsFocus = true; } @@ -1017,12 +1084,19 @@ export class EditorController implements SlotHost { } activate(paneId: string, tabId: string, focus = true) { + ++this.activationGeneration; useTabs.setState((s) => activateTab(setActivePane(s, paneId), paneId, tabId), true); if (focus) { const slot = this.slots.get(tabId); if (slot?.view) slot.focus(); else if (slot) slot.wantsFocus = true; - else void this.ensureSlot(tabId).then((sl) => sl && (sl.view ? sl.focus() : (sl.wantsFocus = true))); + else + void this.ensureSlot(tabId).then((sl) => { + if (sl && this.activeTabOf()?.id === tabId) { + if (sl.view) sl.focus(); + else sl.wantsFocus = true; + } + }); } } diff --git a/src/editor/core/file-open-order.test.ts b/src/editor/core/file-open-order.test.ts new file mode 100644 index 00000000..cb863097 --- /dev/null +++ b/src/editor/core/file-open-order.test.ts @@ -0,0 +1,300 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { registerFileEditorProvider } from "@/workbench/contributions"; +import { EditorController, openFilePaths, useTabs } from "./controller"; +import { createMemoryBackend } from "./backend"; +import { initialTabsState } from "./model/tabs"; +import { useProject } from "@/platform/project"; + +let ctrl: EditorController | null = null; +let dispose: (() => void) | undefined; +const initialSettings = useProject.getState().settings; + +afterEach(() => { + dispose?.(); + dispose = undefined; + ctrl?.stop(); + ctrl = null; + useTabs.setState(initialTabsState(), true); + useProject.setState({ settings: initialSettings }); +}); + +function deferred() { + let resolve!: () => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +function activePath() { + const state = useTabs.getState(); + const pane = state.panes[state.activePane]!; + return pane.tabs.find((tab) => tab.id === pane.active)?.input.path; +} + +function setup() { + ctrl = new EditorController(createMemoryBackend({ "/p/a.ts": "a\nsecond", "/p/b.ts": "b", "/p/c.ts": "c" })); + ctrl.start(); + return ctrl; +} + +function delayProvider(path = "/p/a.ts") { + const started = deferred(); + const response = deferred(); + let delayed = false; + dispose = registerFileEditorProvider({ + id: "delayed-file", + resolve: async (requested) => { + if (requested === path && !delayed) { + delayed = true; + started.resolve(); + await response.promise; + } + return null; + }, + }); + return { started, response }; +} + +function tabFor(path: string) { + return Object.values(useTabs.getState().panes) + .flatMap((pane) => pane.tabs) + .find((tab) => tab.input.path === path); +} + +describe("file open order", () => { + it.each([1, 30])("keeps the newer file active after an earlier probe (tab limit: %s)", async (tabLimit) => { + useProject.setState({ settings: { editor: { tabs: { tabLimit } } } as never }); + const { started, response } = delayProvider(); + const controller = setup(); + const first = controller.openFile("/p/a.ts", { focus: false }); + await started.promise; + await controller.openFile("/p/b.ts", { focus: false }); + expect(activePath()).toBe("/p/b.ts"); + response.resolve(); + await first; + expect(openFilePaths(useTabs.getState()).sort()).toEqual(tabLimit === 1 ? ["/p/b.ts"] : ["/p/a.ts", "/p/b.ts"]); + expect(activePath()).toBe("/p/b.ts"); + expect(controller.activeSlot?.model.path).toBe("/p/b.ts"); + }); + + it("activates the newer file after an earlier probe finishes while both are pending", async () => { + const a = { started: deferred(), response: deferred() }; + const b = { started: deferred(), response: deferred() }; + dispose = registerFileEditorProvider({ + id: "two-delayed-files", + resolve: async (path) => { + const pending = path === "/p/a.ts" ? a : b; + pending.started.resolve(); + await pending.response.promise; + return null; + }, + }); + const controller = setup(); + const first = controller.openFile("/p/a.ts", { focus: false }); + await a.started.promise; + const second = controller.openFile("/p/b.ts", { focus: false }); + await b.started.promise; + a.response.resolve(); + await first; + b.response.resolve(); + await second; + expect(openFilePaths(useTabs.getState()).sort()).toEqual(["/p/a.ts", "/p/b.ts"]); + expect(activePath()).toBe("/p/b.ts"); + }); + + it("discards an earlier preview without replacing the newer preview", async () => { + const { started, response } = delayProvider(); + const controller = setup(); + const first = controller.openFile("/p/a.ts", { preview: true, focus: false }); + await started.promise; + await controller.openFile("/p/b.ts", { preview: true, focus: false }); + response.resolve(); + await first; + expect(openFilePaths(useTabs.getState())).toEqual(["/p/b.ts"]); + expect(tabFor("/p/b.ts")?.preview).toBe(true); + expect(activePath()).toBe("/p/b.ts"); + }); + + it("keeps an earlier regular file without removing the newer preview", async () => { + const { started, response } = delayProvider(); + const controller = setup(); + const first = controller.openFile("/p/a.ts", { focus: false }); + await started.promise; + await controller.openFile("/p/b.ts", { preview: true, focus: false }); + response.resolve(); + await first; + expect(openFilePaths(useTabs.getState()).sort()).toEqual(["/p/a.ts", "/p/b.ts"]); + expect(tabFor("/p/a.ts")?.preview).toBe(false); + expect(tabFor("/p/b.ts")?.preview).toBe(true); + expect(activePath()).toBe("/p/b.ts"); + }); + + it("keeps manual tab activation while an earlier probe is pending", async () => { + const { started, response } = delayProvider(); + const controller = setup(); + await controller.openFile("/p/b.ts", { focus: false }); + await controller.openFile("/p/c.ts", { focus: false }); + const first = controller.openFile("/p/a.ts", { focus: false }); + await started.promise; + controller.activate(useTabs.getState().activePane, tabFor("/p/b.ts")!.id, false); + response.resolve(); + await first; + expect(openFilePaths(useTabs.getState()).sort()).toEqual(["/p/a.ts", "/p/b.ts", "/p/c.ts"]); + expect(activePath()).toBe("/p/b.ts"); + }); + + it("keeps a newer explicit open regular when the same file's preview resolves late", async () => { + const { started, response } = delayProvider(); + const controller = setup(); + const preview = controller.openFile("/p/a.ts", { preview: true, focus: false }); + await started.promise; + await controller.openFile("/p/a.ts", { focus: false }); + response.resolve(); + await preview; + expect(openFilePaths(useTabs.getState())).toEqual(["/p/a.ts"]); + expect(tabFor("/p/a.ts")?.preview).toBe(false); + expect(activePath()).toBe("/p/a.ts"); + }); + + it("promotes an admitted preview when the same file is opened explicitly", async () => { + const controller = setup(); + await controller.openFile("/p/a.ts", { preview: true, focus: false }); + const tabId = tabFor("/p/a.ts")!.id; + expect(tabFor("/p/a.ts")?.preview).toBe(true); + await controller.openFile("/p/a.ts", { focus: false }); + expect(openFilePaths(useTabs.getState())).toEqual(["/p/a.ts"]); + expect(tabFor("/p/a.ts")).toMatchObject({ id: tabId, preview: false }); + expect(activePath()).toBe("/p/a.ts"); + }); + + it("keeps the newer selection when an earlier open of the same file resolves late", async () => { + const { started, response } = delayProvider(); + const controller = setup(); + const first = controller.openFile("/p/a.ts", { selection: { line: 0, col: 0 }, focus: false }); + await started.promise; + await controller.openFile("/p/a.ts", { selection: { line: 1, col: 0 }, focus: false }); + response.resolve(); + await first; + const state = controller.slotFor(tabFor("/p/a.ts")!.id)!.currentState(); + expect(state.doc.lineAt(state.selection.main.head).number).toBe(2); + }); + + it.each([false, true])("keeps a pending navigation selection after a plain open (same file: %s)", async (same) => { + const backend = createMemoryBackend({ "/p/a.ts": "a\nsecond", "/p/b.ts": "b" }); + const started = deferred(); + const response = deferred(); + const open = backend.open.bind(backend); + backend.open = async (path) => { + if (path === "/p/a.ts") { + started.resolve(); + await response.promise; + } + return open(path); + }; + ctrl = new EditorController(backend); + ctrl.start(); + const navigation = ctrl.openFile("/p/a.ts", { selection: { line: 1, col: 2 } }); + await started.promise; + const plain = ctrl.openFile(same ? "/p/a.ts" : "/p/b.ts", { focus: false }); + if (!same) await plain; + response.resolve(); + await Promise.all([navigation, plain]); + const slot = ctrl.slotFor(tabFor("/p/a.ts")!.id)!; + expect(slot.caret()).toMatchObject({ line: 1, col: 2 }); + expect(slot.wantsFocus).toBe(false); + expect(activePath()).toBe(same ? "/p/a.ts" : "/p/b.ts"); + }); + + it.each([true, false])("keeps a delayed open in its origin pane (explicit: %s)", async (explicit) => { + const { started, response } = delayProvider(); + const controller = setup(); + await controller.openFile("/p/b.ts", { focus: false }); + const origin = useTabs.getState().activePane; + controller.split(origin, "row"); + const other = useTabs.getState().activePane; + expect(other).not.toBe(origin); + controller.activate(origin, tabFor("/p/b.ts")!.id, false); + const first = controller.openFile("/p/a.ts", { ...(explicit ? { pane: origin } : {}), focus: false }); + await started.promise; + await controller.openFile("/p/c.ts", { pane: other, focus: false }); + response.resolve(); + await first; + const state = useTabs.getState(); + expect(state.panes[origin]!.tabs.some((tab) => tab.input.path === "/p/a.ts")).toBe(true); + expect(state.panes[other]!.tabs.some((tab) => tab.input.path === "/p/a.ts")).toBe(false); + expect(activePath()).toBe("/p/c.ts"); + }); + + it("does not request focus when an earlier document finishes loading late", async () => { + const backend = createMemoryBackend({ "/p/a.ts": "a", "/p/b.ts": "b" }); + const started = deferred(); + const response = deferred(); + const open = backend.open.bind(backend); + backend.open = async (path) => { + if (path === "/p/a.ts") { + started.resolve(); + await response.promise; + } + return open(path); + }; + ctrl = new EditorController(backend); + ctrl.start(); + const first = ctrl.openFile("/p/a.ts"); + await started.promise; + await ctrl.openFile("/p/b.ts", { focus: false }); + response.resolve(); + await first; + expect(openFilePaths(useTabs.getState()).sort()).toEqual(["/p/a.ts", "/p/b.ts"]); + expect(activePath()).toBe("/p/b.ts"); + expect(ctrl.slotFor(tabFor("/p/a.ts")!.id)?.wantsFocus).toBe(false); + }); + + it("does not run queued mount focus after a newer file becomes active", async () => { + const controller = setup(); + await controller.openFile("/p/a.ts"); + const slot = controller.slotFor(tabFor("/p/a.ts")!.id)!; + expect(slot.wantsFocus).toBe(true); + const focus = vi.spyOn(slot, "focus"); + const frames: FrameRequestCallback[] = []; + vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => frames.push(callback)); + try { + controller.onMounted(slot); + expect(frames.length).toBeGreaterThan(0); + await controller.openFile("/p/b.ts", { focus: false }); + for (const frame of frames) frame(0); + expect(focus).not.toHaveBeenCalled(); + expect(activePath()).toBe("/p/b.ts"); + } finally { + vi.unstubAllGlobals(); + focus.mockRestore(); + } + }); + + it("rolls back a superseded custom preview before provider admission", async () => { + const started = deferred(); + const response = deferred(); + const commit = vi.fn(); + const rollback = vi.fn(async () => {}); + dispose = registerFileEditorProvider({ + id: "delayed-custom-preview", + resolve: async (path, context) => { + if (path !== "/p/a.ts") return null; + context.onAdmission(commit, rollback); + started.resolve(); + await response.promise; + return { kind: "image", key: "image:/p/a.ts", path }; + }, + }); + const controller = setup(); + const first = controller.openFile("/p/a.ts", { preview: true, focus: false }); + await started.promise; + await controller.openFile("/p/b.ts", { preview: true, focus: false }); + response.resolve(); + await first; + expect(commit).not.toHaveBeenCalled(); + expect(rollback).toHaveBeenCalledOnce(); + expect(activePath()).toBe("/p/b.ts"); + expect(tabFor("/p/a.ts")).toBeUndefined(); + }); +}); diff --git a/src/editor/core/model/document-open-latency.test.ts b/src/editor/core/model/document-open-latency.test.ts new file mode 100644 index 00000000..7d6795a1 --- /dev/null +++ b/src/editor/core/model/document-open-latency.test.ts @@ -0,0 +1,120 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { LanguageSupport, StreamLanguage } from "@codemirror/language"; +import { createMemoryBackend } from "../backend"; +import { _resetLanguages, loadLanguageInfos, loadSupport } from "../cm/languages"; +import { DocumentManager } from "./document"; + +vi.mock("../cm/languages", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, loadSupport: vi.fn(actual.loadSupport) }; +}); + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +afterEach(() => { + _resetLanguages(); + vi.mocked(loadSupport).mockClear(); +}); + +it.each(["/p/a.ts", "jdt://contents/a.ts"])("starts reading %s before the catalog resolves", async (path) => { + const backend = createMemoryBackend({ [path]: "const a = 1" }); + const languages = await backend.languages(); + const catalog = deferred(); + vi.spyOn(backend, "languages").mockReturnValue(catalog.promise); + const open = vi.spyOn(backend, path.startsWith("jdt:") ? "openVirtual" : "open"); + const manager = new DocumentManager(backend, { settings: () => ({}), notify: vi.fn() }); + let exposed = false; + const pending = manager.acquire(path).then((model) => { + exposed = true; + return model; + }); + try { + expect(open).toHaveBeenCalledWith(path); + await open.mock.results[0]!.value; + expect(exposed).toBe(false); + expect(manager.get(path)).toBeUndefined(); + } finally { + catalog.resolve(languages); + const model = await pending; + model.dispose(); + manager.stop(); + } +}); + +it("starts the known mode while document content is still pending", async () => { + const path = "/p/a.ts"; + const backend = createMemoryBackend({ [path]: "const a = 1" }); + const info = await backend.open(path); + const content = deferred(); + vi.spyOn(backend, "open").mockReturnValue(content.promise); + const manager = new DocumentManager(backend, { settings: () => ({}), notify: vi.fn() }); + const pending = manager.acquire(path); + await loadLanguageInfos(backend); + try { + expect(loadSupport).toHaveBeenCalledWith(expect.objectContaining({ name: "TypeScript" })); + } finally { + content.resolve(info); + const model = await pending; + model.dispose(); + manager.stop(); + } +}); + +it("handles a speculative mode rejection when opening the document fails", async () => { + const backend = createMemoryBackend(); + const failure = new Error("cannot read document"); + vi.spyOn(backend, "open").mockRejectedValue(failure); + vi.mocked(loadSupport).mockRejectedValueOnce(new Error("cannot preload mode")); + const manager = new DocumentManager(backend, { settings: () => ({}), notify: vi.fn() }); + try { + await expect(manager.acquire("/p/a.ts")).rejects.toBe(failure); + expect(loadSupport).toHaveBeenCalledWith(expect.objectContaining({ name: "TypeScript" })); + } finally { + manager.stop(); + } +}); + +it("opens within 50 ms with catalog, read and cold mode budgets of 20, 40 and 30 ms", async () => { + const path = "/p/cold.rs"; + const backend = createMemoryBackend({ [path]: "fn main() {}" }); + const catalog = await backend.languages(); + const info = await backend.open(path); + const originalSupport = vi.mocked(loadSupport).getMockImplementation()!; + const support = new LanguageSupport(StreamLanguage.define({ token: (stream) => (stream.skipToEnd(), null) })); + let loading: Promise | undefined; + let readyAt: number | undefined; + const manager = new DocumentManager(backend, { settings: () => ({}), notify: vi.fn() }); + vi.useFakeTimers(); + const start = Date.now(); + vi.spyOn(backend, "languages").mockImplementation( + () => new Promise((resolve) => setTimeout(() => resolve(catalog), 20)), + ); + vi.spyOn(backend, "open").mockImplementation(() => new Promise((resolve) => setTimeout(() => resolve(info), 40))); + vi.mocked(loadSupport).mockImplementation( + () => (loading ??= new Promise((resolve) => setTimeout(() => resolve(support), 30))), + ); + const pending = manager.acquire(path).then((model) => { + readyAt = Date.now() - start; + return model; + }); + try { + await vi.advanceTimersByTimeAsync(90); + expect(readyAt).toBeLessThanOrEqual(50); + expect((await pending).language).toBe(support); + } finally { + try { + await vi.runAllTimersAsync(); + (await pending).dispose(); + } finally { + manager.stop(); + vi.mocked(loadSupport).mockImplementation(originalSupport); + vi.useRealTimers(); + } + } +}); diff --git a/src/editor/core/model/document.ts b/src/editor/core/model/document.ts index 9f81ad20..ff94d564 100644 --- a/src/editor/core/model/document.ts +++ b/src/editor/core/model/document.ts @@ -36,6 +36,7 @@ import { loadSupport, loadedSupport, commentTokens, + uiModeForPath, } from "../cm/languages"; import { foldEffect } from "@codemirror/language"; import { backendFolds, setBackendFolds } from "../cm/folding"; @@ -683,8 +684,17 @@ export class DocumentManager { let p = this.opening.get(path); if (!p) { p = (async () => { - await loadLanguageInfos(this.backend); - const info = isVirtualUri(path) ? await this.backend.openVirtual(path) : await this.backend.open(path); + const catalog = loadLanguageInfos(this.backend); + void catalog + .then(() => { + const desc = findLanguageDescription(uiModeForPath(path) ?? null, path); + if (desc) return loadSupport(desc); + }) + .catch(() => {}); + const [info] = await Promise.all([ + isVirtualUri(path) ? this.backend.openVirtual(path) : this.backend.open(path), + catalog, + ]); const raced = this.byPath.get(path); if (raced) { void this.backend.close(info.id); diff --git a/src/editor/core/model/documentLanguage.test.ts b/src/editor/core/model/documentLanguage.test.ts index 0618acf2..0993edbd 100644 --- a/src/editor/core/model/documentLanguage.test.ts +++ b/src/editor/core/model/documentLanguage.test.ts @@ -9,7 +9,7 @@ const loading = vi.hoisted(() => { const ready = new Promise((resolve) => { finish = resolve; }); - return { ready, finish, started: false }; + return { ready, finish, started: false, modes: [] as string[] }; }); vi.mock("../cm/languages", async (importOriginal) => { @@ -18,6 +18,7 @@ vi.mock("../cm/languages", async (importOriginal) => { ...actual, loadSupport: async (...args: Parameters) => { loading.started = true; + loading.modes.push(args[0].name); await loading.ready; return actual.loadSupport(...args); }, @@ -49,6 +50,7 @@ it("waits for the lazy mode before exposing an Angular document to its first slo onContextMenu() {}, }); expect(slot.currentState().facet(languageFacet)?.name).toBe("angular"); + expect(loading.modes).toEqual(expect.arrayContaining(["HTML", "Angular Template"])); slot.dispose(); manager.stop(); }); diff --git a/src/editor/core/model/tabs.ts b/src/editor/core/model/tabs.ts index 269e4b20..56cf6a83 100644 --- a/src/editor/core/model/tabs.ts +++ b/src/editor/core/model/tabs.ts @@ -113,6 +113,7 @@ export interface OpenTabOptions { /** Insert at this index instead of after the active tab. */ index?: number; activate?: boolean; + preservePreview?: boolean; } export function openTab( @@ -152,7 +153,7 @@ export function openTab( tabs.splice(index, 0, tab); } let active = pane.active; - if (!tab.preview && tabs.some((t) => t.preview)) { + if (!tab.preview && !opts.preservePreview && tabs.some((t) => t.preview)) { // A new regular editor closes the preview tab (IDEA trims previews on every open). const previews = new Set(tabs.filter((t) => t.preview).map((t) => t.id)); tabs.splice(0, tabs.length, ...tabs.filter((t) => !previews.has(t.id))); diff --git a/src/features/project-tree/ProjectView.test.tsx b/src/features/project-tree/ProjectView.test.tsx index 05ce9b99..6c68edcf 100644 --- a/src/features/project-tree/ProjectView.test.tsx +++ b/src/features/project-tree/ProjectView.test.tsx @@ -172,6 +172,9 @@ describe("ProjectView", () => { act(() => setViewOption("previewTab", true)); fireEvent.click(content, { detail: 1 }); expect(openFile).toHaveBeenLastCalledWith("/w/acme/README.md", { preview: true, focus: false }); + openFile.mockClear(); + fireEvent.click(content, { detail: 3 }); + expect(openFile).not.toHaveBeenCalled(); act(() => setViewOption("previewTab", false)); }); @@ -207,6 +210,32 @@ describe("ProjectView", () => { act(() => setViewOption("singleClickExpand", false)); }); + it("toggles folders once for each rapid double-click pair", async () => { + const { row, rows } = setup(); + await waitFor(() => expect(rows()).toContain("README.md")); + act(() => setViewOption("singleClickExpand", true)); + const content = () => row("src").querySelector("[data-project-path]")!; + const click = (detail: number) => { + fireEvent.mouseDown(row("src"), { detail }); + fireEvent.click(content(), { detail }); + }; + click(1); + click(2); + fireEvent.doubleClick(content(), { detail: 2 }); + await waitFor(() => expect(rows()).toContain("App.kt")); + click(3); + click(4); + fireEvent.doubleClick(content(), { detail: 4 }); + expect(rows()).not.toContain("App.kt"); + + act(() => setViewOption("singleClickExpand", false)); + click(5); + click(6); + expect(rows()).not.toContain("App.kt"); + fireEvent.doubleClick(content(), { detail: 6 }); + await waitFor(() => expect(rows()).toContain("App.kt")); + }); + it("cancels a new file when its name field loses focus", async () => { const { rows } = setup(); await waitFor(() => expect(rows()).toContain("README.md")); diff --git a/src/features/project-tree/ProjectView.tsx b/src/features/project-tree/ProjectView.tsx index 832cbb51..f4e140f8 100644 --- a/src/features/project-tree/ProjectView.tsx +++ b/src/features/project-tree/ProjectView.tsx @@ -529,10 +529,11 @@ export function ProjectView() { const dragged = useRef(false); const onRowClick = useCallback((node: ProjectNode, e: ReactMouseEvent) => { - if (e.button !== 0 || e.shiftKey || e.metaKey || e.ctrlKey || e.altKey || e.detail > 1) return; + if (e.button !== 0 || e.shiftKey || e.metaKey || e.ctrlKey || e.altKey) return; if (node.placeholder || dragged.current) return; const { options: o, editing: ed } = useProjectTree.getState(); if (ed) return; + if (e.detail > 1 && !(node.dir && o.singleClickExpand && e.detail % 2 === 1)) return; if (node.dir) { if (o.singleClickExpand) toggleExpanded(node); return; diff --git a/src/features/viewers/file-probe.test.ts b/src/features/viewers/file-probe.test.ts new file mode 100644 index 00000000..49ae1bd4 --- /dev/null +++ b/src/features/viewers/file-probe.test.ts @@ -0,0 +1,56 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { commands, type FileProbe } from "@/platform/ipc"; +import { resolveFileEditor } from "@/workbench/contributions"; +import { activate } from "./index"; + +let dispose: (() => void) | undefined; + +afterEach(() => { + dispose?.(); + dispose = undefined; + vi.restoreAllMocks(); +}); + +describe("file probes", () => { + it.each([false, true])("shares an in-flight probe without caching its result (binary: %s)", async (binary) => { + let finish!: (result: { status: "ok"; data: FileProbe }) => void; + const pending = new Promise<{ status: "ok"; data: FileProbe }>((resolve) => { + finish = resolve; + }); + const probe = vi.spyOn(commands, "fsProbe").mockReturnValue(pending); + dispose = activate(); + const first = resolveFileEditor("/p/a.ts"); + const second = resolveFileEditor("/p/a.ts"); + await vi.waitFor(() => expect(probe).toHaveBeenCalled()); + finish({ status: "ok", data: { size: 12, binary, tooLarge: false } }); + const resolutions = await Promise.all([first, second]); + expect(probe).toHaveBeenCalledTimes(1); + expect(resolutions.map((r) => r?.input.kind ?? "file")).toEqual(binary ? ["binary", "binary"] : ["file", "file"]); + if (binary) expect(resolutions[0]).not.toBe(resolutions[1]); + probe.mockResolvedValue({ status: "ok", data: { size: 20, binary: !binary, tooLarge: false } }); + expect((await resolveFileEditor("/p/a.ts"))?.input.kind ?? "file").toBe(binary ? "file" : "binary"); + expect(probe).toHaveBeenCalledTimes(2); + }); + + it("retries a failed probe", async () => { + vi.spyOn(console, "warn").mockImplementation(() => {}); + const probe = vi + .spyOn(commands, "fsProbe") + .mockRejectedValueOnce(new Error("read failed")) + .mockResolvedValue({ status: "ok", data: { size: 20, binary: true, tooLarge: false } }); + dispose = activate(); + expect(await resolveFileEditor("/p/a.ts")).toBeNull(); + expect((await resolveFileEditor("/p/a.ts"))?.input.kind).toBe("binary"); + expect(probe).toHaveBeenCalledTimes(2); + }); + + it("probes different files independently", async () => { + const probe = vi.spyOn(commands, "fsProbe").mockResolvedValue({ + status: "ok", + data: { size: 20, binary: false, tooLarge: false }, + }); + dispose = activate(); + await Promise.all([resolveFileEditor("/p/a.ts"), resolveFileEditor("/p/b.ts")]); + expect(probe.mock.calls).toEqual([["/p/a.ts"], ["/p/b.ts"]]); + }); +}); diff --git a/src/features/viewers/index.ts b/src/features/viewers/index.ts index 92ac8c15..a0137a65 100644 --- a/src/features/viewers/index.ts +++ b/src/features/viewers/index.ts @@ -5,7 +5,7 @@ * Editor / Editor and Preview / Preview layouts around the text editor. */ import { registerActions, type Action } from "@/platform/actions"; -import { call, commands } from "@/platform/ipc"; +import { call, commands, type FileProbe } from "@/platform/ipc"; import { getContext } from "@/platform/context"; import { basename } from "@/platform/project"; import { fileIconName, lazyComponent } from "@/ui"; @@ -78,6 +78,7 @@ function markdownActions(): Action[] { } export function activate(): () => void { + const probes = new Map>(); const disposers = [ registerEditorKind({ kind: IMAGE_KIND, component: ImageViewer, title: titleOf, icon: iconOf }), registerEditorKind({ kind: BINARY_KIND, component: HexViewer, title: titleOf, icon: iconOf }), @@ -90,7 +91,12 @@ export function activate(): () => void { id: "viewers.hex", order: 50, resolve: async (path) => { - const probe = await call(commands.fsProbe(path)); + let pending = probes.get(path); + if (!pending) { + pending = call(commands.fsProbe(path)).finally(() => probes.delete(path)); + probes.set(path, pending); + } + const probe = await pending; return wantsHexView(probe) ? binaryInput(path, probe) : null; }, }), From ff97d7724c7cacf0db0ba8d4cfaee5170c67c0b6 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Mon, 2 Nov 2026 10:47:30 +0800 Subject: [PATCH 05/11] fix(ui): give AsciiDoc files their own icon (#763) AsciiDoc became a recognized language in #731 but .adoc/.asciidoc still used the generic text glyph, failing the icon catalog test that requires every detected language to have a specific icon. Add a dedicated AsciiDoc icon (light/dark) and map both extensions to it. --- src/ui/icons.tsx | 4 +++- src/ui/openidea/fileTypes/asciidoc.svg | 3 +++ src/ui/openidea/fileTypes/asciidoc_dark.svg | 3 +++ 3 files changed, 9 insertions(+), 1 deletion(-) create mode 100644 src/ui/openidea/fileTypes/asciidoc.svg create mode 100644 src/ui/openidea/fileTypes/asciidoc_dark.svg diff --git a/src/ui/icons.tsx b/src/ui/icons.tsx index 34163014..bcaf888b 100644 --- a/src/ui/icons.tsx +++ b/src/ui/icons.tsx @@ -130,6 +130,7 @@ const ICONS = { "file-gdscript": "openidea/fileTypes/gdscript", "file-godot": "openidea/fileTypes/godot", "file-gdshader": "openidea/fileTypes/gdshader", + "file-asciidoc": "openidea/fileTypes/asciidoc", "file-d": "openidea/fileTypes/d", "file-dart": "openidea/fileTypes/dart", "file-elixir": "openidea/fileTypes/elixir", @@ -826,7 +827,7 @@ const EXT_ICONS = table({ "file-rst": "rst", "file-yaml": "yaml yml", "file-toml": "toml", - "file-text": "txt text adoc", + "file-text": "txt text", "file-log": "log", "file-csv": "csv tsv", "file-html": "html htm shtml html.tmpl", @@ -879,6 +880,7 @@ const EXT_ICONS = table({ "file-gdscript": "gd", "file-godot": "tscn tres godot import", "file-gdshader": "gdshader gdshaderinc", + "file-asciidoc": "adoc asciidoc", "file-d": "d di", "file-dart": "dart", "file-elixir": "ex exs", diff --git a/src/ui/openidea/fileTypes/asciidoc.svg b/src/ui/openidea/fileTypes/asciidoc.svg new file mode 100644 index 00000000..81076e49 --- /dev/null +++ b/src/ui/openidea/fileTypes/asciidoc.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/ui/openidea/fileTypes/asciidoc_dark.svg b/src/ui/openidea/fileTypes/asciidoc_dark.svg new file mode 100644 index 00000000..2f60166f --- /dev/null +++ b/src/ui/openidea/fileTypes/asciidoc_dark.svg @@ -0,0 +1,3 @@ + + + From e2f8e8d5bc4215507cb9e58df352fb24e50fc976 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Mon, 2 Nov 2026 11:14:27 +0800 Subject: [PATCH 06/11] fix(cpp): explain missing clang-tidy checks with Apple's clangd (#770) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Apple's clangd (/usr/bin/clangd, Xcode and CommandLineTools toolchains) is built without clang-tidy, so on a Mac without a managed clangd the checks from .clang-tidy never appear. When a clangd server for C, C++, Objective-C or Objective-C++ resolves to Apple's binary and the project has a .clang-tidy, show one warning per session with Install (managed LLVM clangd, which already takes precedence once installed) and Language Servers… actions. Fixes #692. --- crates/ide-core/src/event.rs | 6 + crates/ide-lsp/src/clang_tidy.rs | 126 ++++++++++++++++++++ crates/ide-lsp/src/launch.rs | 27 ++++- crates/ide-lsp/src/lib.rs | 15 ++- crates/ide-lsp/src/server.rs | 2 + crates/ide-lsp/tests/mock_server.rs | 78 ++++++++++++ src/bindings.ts | 2 + src/features/tools/__tests__/tools.test.tsx | 48 +++++++- src/features/tools/index.ts | 3 +- src/features/tools/missing.ts | 28 +++++ src/platform/locales/text/zh.json | 1 + 11 files changed, 331 insertions(+), 5 deletions(-) create mode 100644 crates/ide-lsp/src/clang_tidy.rs diff --git a/crates/ide-core/src/event.rs b/crates/ide-core/src/event.rs index 5de98978..e2d45fec 100644 --- a/crates/ide-core/src/event.rs +++ b/crates/ide-core/src/event.rs @@ -82,6 +82,12 @@ pub enum IdeEvent { status: ServerStatus, message: Option, }, + /// A running server lacks a capability; the UI offers the managed tool as a replacement. + LanguageServerWarning { + name: String, + message: String, + install_tool: String, + }, /// A stopped language server left the project's list (disabled in settings, or a /// fallback that a preferred server took over from again). LanguageServerRemoved { diff --git a/crates/ide-lsp/src/clang_tidy.rs b/crates/ide-lsp/src/clang_tidy.rs new file mode 100644 index 00000000..351ae473 --- /dev/null +++ b/crates/ide-lsp/src/clang_tidy.rs @@ -0,0 +1,126 @@ +//! Apple clangd accepts `.clang-tidy` projects but ships without clang-tidy checks. + +use crate::{LspManager, Server}; +use ide_core::IdeEvent; +use std::path::Path; + +pub(crate) const MESSAGE: &str = "Apple's clangd does not run clang-tidy, so checks from .clang-tidy are not shown. Install clangd (LLVM) to get them."; + +fn is_apple_clangd(path: &Path) -> bool { + if path == Path::new("/usr/bin/clangd") { + return true; + } + if !path.ends_with("usr/bin/clangd") { + return false; + } + path.starts_with("/Library/Developer/CommandLineTools") + || path.ancestors().any(|ancestor| { + ancestor.file_name().is_some_and(|name| name == "Toolchains") + && ancestor.parent().is_some_and(|developer| { + developer.ends_with("Contents/Developer") + && developer.parent().and_then(Path::parent).and_then(Path::file_name).is_some_and(|name| { + let name = name.to_string_lossy(); + name.starts_with("Xcode") && name.ends_with(".app") + }) + }) + }) +} + +fn has_config(server_root: &Path, project_root: &Path, file: &Path) -> bool { + server_root.join(".clang-tidy").is_file() + || file + .parent() + .into_iter() + .flat_map(Path::ancestors) + .take_while(|dir| dir.starts_with(project_root)) + .any(|dir| dir.join(".clang-tidy").is_file()) +} + +impl LspManager { + pub(crate) async fn check_clang_tidy(&self, server: &Server) { + if server.name != "clangd" + || !cfg!(target_os = "macos") + || self.shown_messages.lock().contains(&("clangd".into(), MESSAGE.into())) + { + return; + } + let binary = server.resolved_binary.read().clone(); + let Some(binary) = binary else { return }; + let files: Vec<_> = self + .docs + .lock() + .values() + .filter(|doc| { + matches!(doc.language.as_str(), "c" | "cpp" | "objective-c" | "objective-cpp") + && doc.servers.iter().any(|attached| attached.server.id == server.id) + }) + .map(|doc| doc.path.clone()) + .collect(); + let root = server.root.clone(); + let Some(me) = self.me.upgrade() else { return }; + let _ = tokio::task::spawn_blocking(move || { + if !is_apple_clangd(&binary) && !std::fs::canonicalize(&binary).is_ok_and(|path| is_apple_clangd(&path)) { + return; + } + if !me.shutting_down.load(std::sync::atomic::Ordering::Acquire) + && files.iter().any(|file| has_config(&root, &me.project_root_for(file), file)) + && me.first_message("clangd", MESSAGE) + { + me.sink.emit(IdeEvent::LanguageServerWarning { + name: "clangd".into(), + message: MESSAGE.into(), + install_tool: "clangd".into(), + }); + } + }) + .await; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn apple_paths() { + for path in [ + "/usr/bin/clangd", + "/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/clangd", + "/Applications/Xcode-beta.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/clangd", + "/Library/Developer/CommandLineTools/usr/bin/clangd", + ] { + assert!(is_apple_clangd(Path::new(path)), "{path}"); + } + for path in [ + "/Users/me/Library/Application Support/openidea/tools/clangd/bin/clangd", + "/opt/homebrew/opt/llvm/bin/clangd", + "/usr/local/bin/clangd", + "/usr/bin/clangd-custom", + "/Library/Developer/CommandLineTools-custom/usr/bin/clangd", + "/Applications/Other.app/Contents/Developer/Toolchains/LLVM.xctoolchain/usr/bin/clangd", + ] { + assert!(!is_apple_clangd(Path::new(path)), "{path}"); + } + } + + #[test] + fn config_in_server_root_or_file_ancestors_stays_inside_project() { + let dir = tempfile::tempdir().unwrap(); + let project = dir.path().join("project"); + let server = project.join("build"); + let source = project.join("src/nested"); + let file = source.join("main.cpp"); + std::fs::create_dir_all(&source).unwrap(); + std::fs::create_dir_all(&server).unwrap(); + std::fs::write(dir.path().join(".clang-tidy"), "").unwrap(); + assert!(!has_config(&server, &project, &file)); + for root in [&server, &source, &project.join("src"), &project] { + let config = root.join(".clang-tidy"); + std::fs::write(&config, "Checks: '-*,modernize-use-nullptr'").unwrap(); + assert!(has_config(&server, &project, &file), "{}", root.display()); + std::fs::remove_file(config).unwrap(); + } + std::fs::create_dir(source.join(".clang-tidy")).unwrap(); + assert!(!has_config(&server, &project, &file)); + } +} diff --git a/crates/ide-lsp/src/launch.rs b/crates/ide-lsp/src/launch.rs index 36ae556c..5c7d53c0 100644 --- a/crates/ide-lsp/src/launch.rs +++ b/crates/ide-lsp/src/launch.rs @@ -43,6 +43,18 @@ pub trait Launcher: Send + Sync + 'static { self.launch(config, root, handler, on_stderr) } + /// Starts a server and reports its executable when the launcher runs a local process. + fn launch_resolved( + &self, + config: &LanguageServerConfig, + root: &Path, + handler: Arc, + on_stderr: Box, + cancel: &CancellationToken, + ) -> Result<(Peer, Option), LaunchError> { + self.launch_cancellable(config, root, handler, on_stderr, cancel).map(|peer| (peer, None)) + } + /// Whether `config` could start without a `Missing` error. fn available(&self, _config: &LanguageServerConfig, _root: &Path) -> bool { true @@ -72,6 +84,17 @@ impl Launcher for ProcessLauncher { on_stderr: Box, cancel: &CancellationToken, ) -> Result { + self.launch_resolved(config, root, handler, on_stderr, cancel).map(|(peer, _)| peer) + } + + fn launch_resolved( + &self, + config: &LanguageServerConfig, + root: &Path, + handler: Arc, + on_stderr: Box, + cancel: &CancellationToken, + ) -> Result<(Peer, Option), LaunchError> { let search = server_search_path(config); let binary = ide_core::tool_path::find_command(&config.command, &search, root) .and_then(|b| resolve_rustup_proxy(&config.command, b)); @@ -87,10 +110,12 @@ impl Launcher for ProcessLauncher { handler, on_stderr, cancel, - ); + ) + .map(|peer| (peer, None)); } let binary = binary.ok_or(LaunchError::Missing)?; Peer::spawn(&binary.to_string_lossy(), &config.args, root, &env, handler, on_stderr) + .map(|peer| (peer, Some(binary.clone()))) .map_err(|e| LaunchError::Failed(format!("failed to start {}: {e}", binary.display()))) } diff --git a/crates/ide-lsp/src/lib.rs b/crates/ide-lsp/src/lib.rs index 52145184..d8ead994 100644 --- a/crates/ide-lsp/src/lib.rs +++ b/crates/ide-lsp/src/lib.rs @@ -8,6 +8,7 @@ mod angular; mod caps; +mod clang_tidy; mod convert; mod external_project; mod file_ops; @@ -472,13 +473,16 @@ impl LspManager { if !me.execution_allowed.load(Ordering::Acquire) || cancel.is_cancelled() { return Err(LaunchError::Config("Project is in Restricted Mode".into())); } - me.launcher.launch_cancellable(&cfg, &root, handler, Box::new(move |line| tail.push_stderr(line)), &cancel) + me.launcher.launch_resolved(&cfg, &root, handler, Box::new(move |line| tail.push_stderr(line)), &cancel) }) .await .unwrap_or_else(|e| Err(LaunchError::Failed(e.to_string()))); let peer = match launched { - Ok(peer) => peer, + Ok((peer, binary)) => { + *server.resolved_binary.write() = binary; + peer + } Err(LaunchError::Missing) => { let cfg = server.effective_config(); let message = @@ -587,6 +591,7 @@ impl LspManager { } } } + self.check_clang_tidy(&server).await; server.refresh_busy_status(&self.sink); server.announce_ready(generation); self.spawn_supervisor(server, generation, peer); @@ -810,6 +815,12 @@ impl LspManager { } for s in &servers { self.ensure_started(s); + if s.running().is_some() + && let Some(me) = self.me.upgrade() + { + let server = s.clone(); + self.runtime.spawn(async move { me.check_clang_tidy(&server).await }); + } } } diff --git a/crates/ide-lsp/src/server.rs b/crates/ide-lsp/src/server.rs index 81c21b02..f15a893e 100644 --- a/crates/ide-lsp/src/server.rs +++ b/crates/ide-lsp/src/server.rs @@ -79,6 +79,7 @@ pub struct Server { pub config: LanguageServerConfig, /// `config` with the settings hook applied, as the current instance was launched. effective: Mutex>, + pub resolved_binary: RwLock>, languages: Mutex>, state: Mutex, /// Fired when the instance being started is abandoned (stop / restart while starting). @@ -114,6 +115,7 @@ impl Server { root, config, effective: Mutex::new(None), + resolved_binary: RwLock::new(None), languages: Mutex::new(vec![language]), state: Mutex::new(State { generation: 0, conn: Conn::Idle }), start_cancel: Mutex::new(CancellationToken::new()), diff --git a/crates/ide-lsp/tests/mock_server.rs b/crates/ide-lsp/tests/mock_server.rs index 7bfb45cc..d2136467 100644 --- a/crates/ide-lsp/tests/mock_server.rs +++ b/crates/ide-lsp/tests/mock_server.rs @@ -21,6 +21,7 @@ use std::time::Duration; struct MockState { caps: Mutex, missing: std::sync::atomic::AtomicBool, + binary: Mutex>, launches: AtomicUsize, notifications: Mutex>, requests: Mutex>, @@ -148,6 +149,18 @@ impl MockLauncher { } impl Launcher for MockLauncher { + fn launch_resolved( + &self, + config: &LanguageServerConfig, + root: &Path, + handler: Arc, + on_stderr: Box, + cancel: &ide_core::CancellationToken, + ) -> Result<(Peer, Option), LaunchError> { + self.launch_cancellable(config, root, handler, on_stderr, cancel) + .map(|peer| (peer, self.state(&config.name).binary.lock().clone())) + } + fn launch( &self, config: &LanguageServerConfig, @@ -2836,3 +2849,68 @@ async fn formatting_provider_selection_tracks_dynamic_registration() { .unwrap(); assert!(!h.mgr.can_format(doc).await.unwrap()); } + +#[cfg(target_os = "macos")] +#[tokio::test] +async fn apple_clangd_warns_for_tidy_config_once_across_languages_and_restarts() { + for language in ["c", "cpp", "objective-c", "objective-cpp"] { + let h = harness_for(language, "src", &[("clangd", incremental_caps())], &[]); + *h.launcher.state("clangd").binary.lock() = Some(PathBuf::from("/usr/bin/clangd")); + std::fs::write(h.root.join(".clang-tidy"), "Checks: '-*,modernize-use-nullptr'").unwrap(); + h.open("main.src", "int *p = 0;"); + eventually(|| h.events.lock().iter().any(|e| matches!(e, IdeEvent::LanguageServerWarning { .. }))).await; + let id = h.mgr.servers()[0].id; + h.mgr.restart(id).await.unwrap(); + h.wait_status("clangd", ServerStatus::Ready).await; + h.open("other.src", "int *p = 0;"); + h.mgr.shutdown_all().await; + let events = h.events.lock(); + let warnings: Vec<_> = events + .iter() + .filter_map(|e| match e { + IdeEvent::LanguageServerWarning { name, message, install_tool } => Some((name, message, install_tool)), + _ => None, + }) + .collect(); + assert_eq!(warnings.len(), 1, "{language}"); + assert_eq!(warnings[0].0, "clangd"); + assert!(warnings[0].1.contains(".clang-tidy")); + assert_eq!(warnings[0].2, "clangd"); + } +} + +#[cfg(target_os = "macos")] +#[tokio::test] +async fn clang_tidy_warning_requires_apple_binary_and_a_config() { + for (binary, config) in [ + ("/usr/bin/clangd", false), + ("/opt/homebrew/opt/llvm/bin/clangd", true), + ("/Users/me/Library/Application Support/openidea/tools/clangd/bin/clangd", true), + ] { + let h = harness_for("cpp", "cpp", &[("clangd", incremental_caps())], &[]); + *h.launcher.state("clangd").binary.lock() = Some(PathBuf::from(binary)); + if config { + std::fs::write(h.root.join(".clang-tidy"), "Checks: '-*,modernize-use-nullptr'").unwrap(); + } + h.open("main.cpp", "int *p = 0;"); + h.wait_status("clangd", ServerStatus::Ready).await; + h.mgr.shutdown_all().await; + assert!(!h.events.lock().iter().any(|e| matches!(e, IdeEvent::LanguageServerWarning { .. })), "{binary}"); + } +} + +#[cfg(target_os = "macos")] +#[tokio::test] +async fn clang_tidy_config_for_a_later_opened_file_warns_on_a_running_server() { + let h = harness_for("cpp", "cpp", &[("clangd", incremental_caps())], &[]); + *h.launcher.state("clangd").binary.lock() = Some(PathBuf::from("/usr/bin/clangd")); + h.open("main.cpp", "int *p = 0;"); + h.wait_status("clangd", ServerStatus::Ready).await; + assert!(!h.events.lock().iter().any(|e| matches!(e, IdeEvent::LanguageServerWarning { .. }))); + std::fs::create_dir(h.root.join("nested")).unwrap(); + std::fs::write(h.root.join("nested/.clang-tidy"), "Checks: '-*,modernize-use-nullptr'").unwrap(); + h.open("nested/other.cpp", "int *p = 0;"); + eventually(|| h.events.lock().iter().any(|e| matches!(e, IdeEvent::LanguageServerWarning { .. }))).await; + assert_eq!(h.mgr.servers().len(), 1); + h.mgr.shutdown_all().await; +} diff --git a/src/bindings.ts b/src/bindings.ts index b3ea339c..e2eb8a2a 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -3619,6 +3619,8 @@ cancellable: boolean } | { type: "indexState"; indexing: boolean; files: number; symbols: number } | /** Git status / branch information should be refreshed. */ { type: "vcsChanged"; root: IpcPath } | { type: "languageServer"; server: ServerId; name: string; status: ServerStatus; message: string | null } | +/** A running server lacks a capability; the UI offers the managed tool as a replacement. */ +{ type: "languageServerWarning"; name: string; message: string; installTool: string } | /** * A stopped language server left the project's list (disabled in settings, or a * fallback that a preferred server took over from again). diff --git a/src/features/tools/__tests__/tools.test.tsx b/src/features/tools/__tests__/tools.test.tsx index 0d4bd57c..49c64365 100644 --- a/src/features/tools/__tests__/tools.test.tsx +++ b/src/features/tools/__tests__/tools.test.tsx @@ -7,7 +7,7 @@ import { useTasks } from "@/workbench/tasks"; import { MemoryToolsBackend, sampleTools, setToolsBackend } from "../backend"; import { cancelInstall, installTool, refreshTools, uninstallTool, useTools } from "../store"; import { filterTools, LanguageServersPage } from "../LanguageServersPage"; -import { installMissingServerPrompts, missingKey, promptInstall } from "../missing"; +import { installMissingServerPrompts, installServerWarningPrompts, missingKey, promptInstall } from "../missing"; import { resetTestForm, stagedFor, TestSettingsForm } from "./settingsForm"; type Note = Parameters[0]; @@ -273,3 +273,49 @@ describe("tools.install action", () => { } }); }); + +describe("language server capability warning", () => { + const event = { + type: "languageServerWarning", + name: "clangd", + message: + "Apple's clangd does not run clang-tidy, so checks from .clang-tidy are not shown. Install clangd (LLVM) to get them.", + installTool: "clangd", + } as const; + + it("offers a managed replacement for system clangd once and restarts it after installation", async () => { + backend.tools.push({ + ...sampleTools()[0]!, + name: "clangd", + displayName: "clangd", + command: "clangd", + status: "onPath", + path: "/usr/bin/clangd", + languages: ["C", "C++", "Objective-C", "Objective-C++"], + }); + const off = installServerWarningPrompts(); + try { + emitLocalIdeEvent(event); + emitLocalIdeEvent(event); + await waitFor(() => expect(notes).toHaveLength(1)); + expect(notes[0]).toMatchObject({ title: "clangd", level: "warning", sticky: true, body: event.message }); + expect(notes[0]!.actions!.map((a) => a.label)).toEqual(["Install", "Language Servers…"]); + notes[0]!.actions![0]!.run(); + await waitFor(() => expect(backend.restarted).toEqual(["clangd"])); + expect(notes.at(-1)).toMatchObject({ title: "clangd installed", key: notes[0]!.key }); + } finally { + off(); + } + }); + + it("keeps the Settings action when the tool cannot be installed", async () => { + const off = installServerWarningPrompts(); + try { + emitLocalIdeEvent(event); + await waitFor(() => expect(notes).toHaveLength(1)); + expect(notes[0]!.actions!.map((a) => a.label)).toEqual(["Language Servers…"]); + } finally { + off(); + } + }); +}); diff --git a/src/features/tools/index.ts b/src/features/tools/index.ts index 76736a60..f9c79d9a 100644 --- a/src/features/tools/index.ts +++ b/src/features/tools/index.ts @@ -9,7 +9,7 @@ import { lazyComponent } from "@/ui"; import { executeAction, registerActions } from "@/platform/actions"; import { registerSettingsPage } from "@/workbench/contributions"; import { installJdkPrompts, registerJdkActions } from "./jdk"; -import { installMissingServerPrompts, promptInstall } from "./missing"; +import { installMissingServerPrompts, installServerWarningPrompts, promptInstall } from "./missing"; import { installTool, refreshTools, SETTINGS_PAGE_ID, toolByName, useTools } from "./store"; /** The Language Servers page (also used by the Settings window). */ @@ -79,6 +79,7 @@ export function activate(): () => void { }, ]), installMissingServerPrompts(), + installServerWarningPrompts(), registerJdkActions(), installJdkPrompts(), ]; diff --git a/src/features/tools/missing.ts b/src/features/tools/missing.ts index c1c48d1a..6bd53191 100644 --- a/src/features/tools/missing.ts +++ b/src/features/tools/missing.ts @@ -56,3 +56,31 @@ export function installMissingServerPrompts(): () => void { void promptInstall(e.name).catch(() => {}); }); } + +/** A system server that lacks a capability can be replaced by the managed tool. */ +export function installServerWarningPrompts(): () => void { + const prompted = new Set(); + return onIdeEvent("languageServerWarning", (e) => { + const key = `lsp-warning-${e.name}-${e.installTool}`; + if (prompted.has(key)) return; + prompted.add(key); + void (async () => { + if (!useTools.getState().loaded) await refreshTools(); + const tool = toolByName(e.installTool); + notifications().notify({ + group: "lsp", + level: "warning", + title: e.name, + body: tx(e.message), + key, + sticky: true, + actions: [ + ...(tool?.canInstall + ? [{ label: "Install", run: () => void installTool(e.installTool, { notificationKey: key }) }] + : []), + { label: "Language Servers…", run: () => void executeAction("file.settings", SETTINGS_PAGE_ID) }, + ], + }); + })(); + }); +} diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index daeb595f..fec979fe 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -1,4 +1,5 @@ { + "Apple's clangd does not run clang-tidy, so checks from .clang-tidy are not shown. Install clangd (LLVM) to get them.": "Apple 的 clangd 不运行 clang-tidy,因此不会显示 .clang-tidy 中配置的检查。请安装 clangd(LLVM)以启用这些检查。", "Newer dependency version available": "有更新的依赖版本可用", "Reports newer crate versions that Cargo.toml requirements do not already allow.": "报告 Cargo.toml 版本要求尚未允许的较新 crate 版本。", "Update to {latest}": "更新至 {latest}", From 44c603f7dba0a785feffb01dc64dd571bfcd633d Mon Sep 17 00:00:00 2001 From: d0whc3r <1378986+d0whc3r@users.noreply.github.com> Date: Mon, 2 Nov 2026 04:22:49 +0100 Subject: [PATCH 07/11] feat: add oxfmt/oxlint support for JS and TS projects (#717) Below an oxfmt configuration (.oxfmtrc.json/.jsonc, oxfmt.config.ts/.mts) Reformat Code runs oxfmt instead of Prettier, with the same external-formatter settings; oxfmt formats whole files, so a selection formats the file with a notice. oxlint --lsp serves JS/TS/React/Vue/Svelte files below an oxlint config. Both are managed npm tools; a missing oxfmt gets the same Install prompt and error handling as Prettier, and formatter messages are translated for either tool. Co-authored-by: d0whc3r <1378986+d0whc3r@users.noreply.github.com> --- crates/ide-host/src/formatter.rs | 50 ++++++++--- crates/ide-language/src/registry.rs | 20 ++++- crates/ide-tools/src/formatter.rs | 129 +++++++++++++++++++-------- crates/ide-tools/src/registry.rs | 30 +++++-- crates/ide-tools/tests/prettier.rs | 4 +- languages/javascript.toml | 8 ++ languages/javascriptreact.toml | 8 ++ languages/svelte.toml | 8 ++ languages/typescript.toml | 8 ++ languages/typescriptreact.toml | 8 ++ languages/vue.toml | 8 ++ src/editor/lsp/format.ts | 13 +-- src/editor/lsp/formatErrors.test.tsx | 13 +-- src/platform/locales/text/zh.json | 14 +-- 14 files changed, 246 insertions(+), 75 deletions(-) diff --git a/crates/ide-host/src/formatter.rs b/crates/ide-host/src/formatter.rs index 7bc9af9d..054497b3 100644 --- a/crates/ide-host/src/formatter.rs +++ b/crates/ide-host/src/formatter.rs @@ -7,6 +7,16 @@ use ide_core::{DocId, IdeError, IdeEvent, IdeResult, Range, TextEdit}; use ide_language::FormatterConfig; use ide_lsp::types::FormatOptions; use ide_tools::{Tools, formatter}; +use std::path::Path; + +const OXFMT_CONFIGS: [&str; 4] = [".oxfmtrc.json", ".oxfmtrc.jsonc", "oxfmt.config.ts", "oxfmt.config.mts"]; + +/// oxfmt below an oxfmt configuration file, else Prettier. The Prettier +/// settings (languages, preference, on save) apply to both. +fn external_tool(file: &Path) -> &'static str { + let oxfmt = OXFMT_CONFIGS.iter().any(|name| formatter::find_up(file, Path::new(name)).is_some()); + if oxfmt { "oxfmt" } else { "prettier" } +} fn external_selected(config: Option<&FormatterConfig>, language: &str, settings: &PrettierSettings, lsp: bool) -> bool { config.is_some_and(|c| c.tool == "prettier") @@ -44,16 +54,19 @@ pub async fn format( return Err(IdeError::invalid("The file is read-only")); } let config = config.unwrap().clone(); - let custom = ide_sdk::apply::setting_path(&project.root, &prettier.path); + let tool = external_tool(&snap.path); + let name = formatter::display_name(tool); + let custom = ide_sdk::apply::setting_path(&project.root, &prettier.path).filter(|_| tool == "prettier"); let tools = Tools::new(&host.local_data_dir); - let whole_selection = range.is_some() && !config.range; + let whole_selection = range.is_some() && !formatter::supports_range(tool, &config); let edits: IdeResult> = tokio::task::spawn_blocking(move || { let text = ide_text::Document::new(&snap.text); let offsets = range.map(|r| (text.pos_to_offset(r.start), text.pos_to_offset(r.end))); - let mut command = tools.formatter_command(&config.tool, &snap.path, custom.as_deref())?; - command.current_dir(snap.path.parent().ok_or_else(|| IdeError::invalid("Prettier: file has no directory"))?); - command.args(formatter::arguments(&config, &snap.path, options.tab_size, options.insert_spaces, offsets)); - let formatted = formatter::run(command, &snap.text, &Default::default())?; + let mut command = tools.formatter_command(tool, &snap.path, custom.as_deref())?; + let dir = snap.path.parent().ok_or_else(|| IdeError::invalid(format!("{name}: file has no directory")))?; + command.current_dir(dir); + command.args(formatter::arguments(tool, &config, &snap.path, options.tab_size, options.insert_spaces, offsets)); + let formatted = formatter::run(tool, command, &snap.text, &Default::default())?; Ok(text .diff(&formatted) .changes @@ -69,13 +82,13 @@ pub async fn format( if edits.is_ok() && whole_selection { project.sink.emit(IdeEvent::Notify { level: NotifyLevel::Info, - title: "Prettier".into(), - body: Some( - "Prettier does not support selection formatting for this language; formatted the whole file".into(), - ), + title: name.into(), + body: Some(format!( + "{name} does not support selection formatting for this language; formatted the whole file" + )), }); } - edits.map_err(|e| e.with_tool("prettier")) + edits.map_err(|e| e.with_tool(tool)) } #[cfg(test)] @@ -104,4 +117,19 @@ mod tests { assert!(!external_selected(ts.formatter.as_ref(), "typescript", &settings, false)); assert!(!external_selected(None, "rust", &settings, false)); } + + #[test] + fn oxfmt_config_selects_oxfmt_over_prettier() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + std::fs::create_dir_all(root.join("web/src")).unwrap(); + let (web, api) = (root.join("web/src/app.ts"), root.join("api/main.ts")); + assert_eq!(external_tool(&web), "prettier"); + for config in OXFMT_CONFIGS { + std::fs::write(root.join("web").join(config), "{}").unwrap(); + assert_eq!(external_tool(&web), "oxfmt", "{config}"); + assert_eq!(external_tool(&api), "prettier", "{config}"); + std::fs::remove_file(root.join("web").join(config)).unwrap(); + } + } } diff --git a/crates/ide-language/src/registry.rs b/crates/ide-language/src/registry.rs index 5ceaec74..58494359 100644 --- a/crates/ide-language/src/registry.rs +++ b/crates/ide-language/src/registry.rs @@ -576,7 +576,7 @@ mod tests { }; // After the TypeScript fallback (an available server before it would hide // it); before the HTML server (hover, rename and symbols take the first). - assert_eq!(names("typescript"), ["vtsls", "typescript-language-server", "angular-language-server"]); + assert_eq!(names("typescript"), ["vtsls", "typescript-language-server", "angular-language-server", "oxlint"]); assert_eq!(names("html"), ["angular-language-server", "vscode-html-language-server"]); for lang in ["typescript", "html"] { let cfg = reg.get(&LanguageId::new(lang)).unwrap(); @@ -586,6 +586,24 @@ mod tests { } } + #[test] + fn oxlint_serves_js_files_only_below_its_config() { + let reg = reg(); + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + std::fs::create_dir_all(root.join("apps/web/src")).unwrap(); + std::fs::write(root.join("apps/web/.oxlintrc.json"), "{}").unwrap(); + for lang in ["javascript", "javascriptreact", "typescript", "typescriptreact", "vue", "svelte"] { + let cfg = reg.get(&LanguageId::new(lang)).unwrap(); + let oxlint = cfg.language_servers.iter().find(|s| s.name == "oxlint").unwrap(); + assert_eq!((oxlint.command.as_str(), oxlint.args.as_slice()), ("oxlint", ["--lsp".to_string()].as_slice())); + assert!(oxlint.require_root_marker && !oxlint.fallback, "{lang}"); + let served = |file: &str| crate::marked_root(Some(root), &root.join(file), &oxlint.root_markers); + assert_eq!(served("apps/web/src/main.ts"), Some(root.join("apps/web")), "{lang}"); + assert_eq!(served("apps/api/main.ts"), None, "{lang}"); + } + } + #[test] fn html_of_angular_projects_is_an_angular_template() { let reg = reg(); diff --git a/crates/ide-tools/src/formatter.rs b/crates/ide-tools/src/formatter.rs index 9b599f0b..95dd011b 100644 --- a/crates/ide-tools/src/formatter.rs +++ b/crates/ide-tools/src/formatter.rs @@ -13,20 +13,30 @@ pub fn find_up(file: &Path, relative: &Path) -> Option { file.parent()?.ancestors().map(|dir| dir.join(relative)).find(|p| p.is_file()) } -fn package_bin(package: &Path) -> IdeResult { +pub fn display_name(tool: &str) -> &str { + crate::registry::find(tool).map_or(tool, |t| t.display) +} + +fn package_bin(package: &Path, tool: &str) -> IdeResult { + let name = display_name(tool); let json: serde_json::Value = serde_json::from_slice(&std::fs::read(package.join("package.json"))?) - .map_err(|e| IdeError::invalid(format!("Prettier: {e}")))?; + .map_err(|e| IdeError::invalid(format!("{name}: {e}")))?; let bin = json["bin"] .as_str() - .or_else(|| json["bin"]["prettier"].as_str()) - .ok_or_else(|| IdeError::invalid("Prettier: package has no CLI entry point"))?; + .or_else(|| json["bin"][tool].as_str()) + .ok_or_else(|| IdeError::invalid(format!("{name}: package has no CLI entry point")))?; let path = package.join(bin); if !path.is_file() { - return Err(IdeError::unavailable(format!("Prettier: CLI does not exist: {}", path.display()))); + return Err(IdeError::unavailable(format!("{name}: CLI does not exist: {}", path.display()))); } Ok(path) } +/// Prettier formats a range for some languages (`config.range`); oxfmt formats whole files only. +pub fn supports_range(tool: &str, config: &FormatterConfig) -> bool { + tool == "prettier" && config.range +} + impl Tools { /// Explicit executable/script, then the nearest project package, then managed tools and PATH. pub fn formatter_command(&self, tool: &str, file: &Path, custom: Option<&Path>) -> IdeResult { @@ -34,22 +44,26 @@ impl Tools { } fn resolve_formatter_command(&self, tool: &str, file: &Path, custom: Option<&Path>) -> IdeResult { - if tool != "prettier" { + if crate::registry::find(tool).is_none_or(|t| t.kind != crate::ToolKind::Formatter) { return Err(IdeError::unavailable(format!("Unknown external formatter: {tool}"))); } + let package = Path::new("node_modules").join(tool); + let manifest = package.join("package.json"); let (path, node) = if let Some(custom) = custom.filter(|p| p.is_dir()) { - (package_bin(custom)?, true) + (package_bin(custom, tool)?, true) } else if let Some(custom) = custom { (custom.to_path_buf(), matches!(custom.extension().and_then(|s| s.to_str()), Some("js" | "cjs" | "mjs"))) - } else if let Some(manifest) = find_up(file, Path::new("node_modules/prettier/package.json")) { - (package_bin(manifest.parent().unwrap())?, true) - } else if self.root.join("node_modules/prettier/package.json").is_file() { - (package_bin(&self.root.join("node_modules/prettier"))?, true) + } else if let Some(manifest) = find_up(file, &manifest) { + (package_bin(manifest.parent().unwrap(), tool)?, true) + } else if self.root.join(&manifest).is_file() { + (package_bin(&self.root.join(&package), tool)?, true) } else { - (self.which("prettier").ok_or_else(|| IdeError::missing_tool("prettier"))?, false) + (self.which(tool).ok_or_else(|| IdeError::missing_tool(tool))?, false) }; if node { - let node = self.which("node").ok_or_else(|| IdeError::unavailable("Prettier: Node.js is required"))?; + let name = display_name(tool); + let node = + self.which("node").ok_or_else(|| IdeError::unavailable(format!("{name}: Node.js is required")))?; let mut cmd = self.command(node); cmd.arg(path); Ok(cmd) @@ -59,34 +73,40 @@ impl Tools { } } -/// Project configuration overrides these defaults; Prettier resolves config and plugins itself. +/// Both tools resolve their configuration and plugins themselves. Prettier's +/// project configuration overrides the editor indentation passed here; oxfmt has +/// no such flags and reads its configuration and `.editorconfig` only. pub fn arguments( + tool: &str, config: &FormatterConfig, file: &Path, tab_size: u32, insert_spaces: bool, range: Option<(u32, u32)>, ) -> Vec { - let parser = if config.parser == "markdown" && file.extension().is_some_and(|s| s == "mdx") { - "mdx" - } else { - &config.parser - }; - let mut args: Vec = [ - "--parser", - parser, - "--config-precedence", - "file-override", - "--tab-width", - &tab_size.max(1).to_string(), - "--use-tabs", - if insert_spaces { "false" } else { "true" }, - ] - .into_iter() - .map(Into::into) - .collect(); + let mut args: Vec = Vec::new(); + if tool == "prettier" { + let parser = if config.parser == "markdown" && file.extension().is_some_and(|s| s == "mdx") { + "mdx" + } else { + &config.parser + }; + args.extend( + [ + "--parser", + parser, + "--config-precedence", + "file-override", + "--tab-width", + &tab_size.max(1).to_string(), + "--use-tabs", + if insert_spaces { "false" } else { "true" }, + ] + .map(OsString::from), + ); + } args.extend(["--stdin-filepath".into(), file.as_os_str().to_owned()]); - if config.range + if supports_range(tool, config) && let Some((start, end)) = range { args.extend(["--range-start".into(), start.to_string().into(), "--range-end".into(), end.to_string().into()]); @@ -100,8 +120,8 @@ pub fn arguments( args } -pub fn run(mut command: Command, text: &str, cancel: &CancellationToken) -> IdeResult { - crate::process::run_input(&mut command, text, Duration::from_secs(15), cancel).map_err(|e| e.with_tool("prettier")) +pub fn run(tool: &str, mut command: Command, text: &str, cancel: &CancellationToken) -> IdeResult { + crate::process::run_input(&mut command, text, Duration::from_secs(15), cancel).map_err(|e| e.with_tool(tool)) } #[cfg(test)] @@ -118,7 +138,7 @@ mod tests { assert_eq!(missing.kind, ErrorKind::NotFound); assert_eq!(missing.data, Some(ErrorData::Tool { tool: "prettier".into(), missing: true })); let custom = tools.formatter_command("prettier", &file, Some(&dir.path().join("absent-prettier"))).unwrap(); - let failure = run(custom, "text", &Default::default()).unwrap_err(); + let failure = run("prettier", custom, "text", &Default::default()).unwrap_err(); assert_eq!(failure.data, Some(ErrorData::Tool { tool: "prettier".into(), missing: false })); } @@ -127,7 +147,7 @@ mod tests { fn stderr_is_preserved_verbatim_even_when_longer_than_four_thousand_characters() { let mut command = Command::new("sh"); command.args(["-c", "i=0; while [ $i -lt 1000 ]; do printf 'error\\n' >&2; i=$((i+1)); done; exit 1"]); - let failure = run(command, "", &Default::default()).unwrap_err(); + let failure = run("prettier", command, "", &Default::default()).unwrap_err(); assert_eq!(failure.message, "error\n".repeat(1000)); assert_eq!(failure.data, Some(ide_core::error::ErrorData::Tool { tool: "prettier".into(), missing: false })); } @@ -161,7 +181,8 @@ mod tests { ("vue", true), ] { let config = FormatterConfig { tool: "prettier".into(), parser: parser.into(), range: supports_range }; - let args = arguments(&config, Path::new("/project with spaces/file.md"), 4, false, Some((3, 9))); + let args = + arguments("prettier", &config, Path::new("/project with spaces/file.md"), 4, false, Some((3, 9))); let args: Vec<_> = args.iter().map(|a| a.to_string_lossy()).collect(); assert!(args.windows(2).any(|a| a == ["--config-precedence", "file-override"])); assert!(args.windows(2).any(|a| a == ["--stdin-filepath", "/project with spaces/file.md"])); @@ -171,4 +192,36 @@ mod tests { assert!(!args.contains(&"--write".into())); } } + + #[test] + fn oxfmt_runs_the_nearest_package_without_prettier_flags() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + std::fs::create_dir_all(root.join("node_modules/oxfmt/bin")).unwrap(); + std::fs::write(root.join("node_modules/oxfmt/package.json"), r#"{"bin":{"oxfmt":"bin/oxfmt"}}"#).unwrap(); + std::fs::write(root.join("node_modules/oxfmt/bin/oxfmt"), "").unwrap(); + std::fs::write(root.join(".prettierignore"), "dist").unwrap(); + let file = root.join("packages/web/src/app.ts"); + let path = std::env::var_os("PATH").unwrap_or_default(); + let tools = Tools::with_paths(root.join("tools"), path, crate::platform::Platform::current()); + + let command = tools.formatter_command("oxfmt", &file, None).unwrap(); + let program = Path::new(command.get_program()).file_stem().unwrap().to_string_lossy().into_owned(); + assert_eq!(program, "node"); + let script: Vec<_> = command.get_args().collect(); + assert_eq!(script, [root.join("node_modules/oxfmt/bin/oxfmt").as_os_str()]); + + let config = FormatterConfig { tool: "prettier".into(), parser: "typescript".into(), range: true }; + let args = arguments("oxfmt", &config, &file, 4, false, Some((3, 9))); + let ignore = root.join(".prettierignore").into_os_string(); + assert_eq!( + args[..4], + ["--stdin-filepath".into(), file.clone().into_os_string(), "--ignore-path".into(), ignore] + ); + assert!(args[4..].chunks(2).all(|pair| pair[0] == "--ignore-path"), "{args:?}"); + assert!(!supports_range("oxfmt", &config)); + + let unknown = tools.formatter_command("rust-analyzer", &file, None).unwrap_err(); + assert!(unknown.message.contains("Unknown external formatter"), "{}", unknown.message); + } } diff --git a/crates/ide-tools/src/registry.rs b/crates/ide-tools/src/registry.rs index 6dfcd5df..661a7170 100644 --- a/crates/ide-tools/src/registry.rs +++ b/crates/ide-tools/src/registry.rs @@ -134,6 +134,28 @@ pub static TOOLS: &[ToolSpec] = &[ needs: NODE, homepage: "https://prettier.io", }, + ToolSpec { + name: "oxfmt", + display: "oxfmt", + kind: Formatter, + commands: &["oxfmt"], + method: Method::Npm { packages: &["oxfmt"] }, + fallback: None, + version: ver("oxfmt"), + needs: NODE, + homepage: "https://oxc.rs/docs/guide/usage/formatter", + }, + ToolSpec { + name: "oxlint", + display: "oxlint", + kind: LanguageServer, + commands: &["oxlint"], + method: Method::Npm { packages: &["oxlint"] }, + fallback: None, + version: ver("oxlint"), + needs: NODE, + homepage: "https://oxc.rs/docs/guide/usage/linter", + }, ToolSpec { name: "rust-analyzer", display: "rust-analyzer", @@ -965,12 +987,8 @@ mod tests { known.insert((d.adapter.clone(), d.command.clone())); } } - for l in &langs { - if let Some(f) = &l.formatter { - known.insert((f.tool.clone(), f.tool.clone())); - } - } - for t in TOOLS.iter().filter(|t| t.kind != Plugin) { + // The host selects the formatter of each file (Prettier or oxfmt). + for t in TOOLS.iter().filter(|t| !matches!(t.kind, Plugin | Formatter)) { assert!( known.contains(&(t.name.to_string(), t.commands[0].to_string())), "{} / {} is not referenced by languages/*.toml", diff --git a/crates/ide-tools/tests/prettier.rs b/crates/ide-tools/tests/prettier.rs index 3d622caa..c09f6d59 100644 --- a/crates/ide-tools/tests/prettier.rs +++ b/crates/ide-tools/tests/prettier.rs @@ -19,8 +19,8 @@ fn format( }; let mut cmd = tools.formatter_command("prettier", path, None)?; cmd.current_dir(path.parent().unwrap()); - cmd.args(formatter::arguments(&config, path, 4, true, range)); - formatter::run(cmd, text, &CancellationToken::new()) + cmd.args(formatter::arguments("prettier", &config, path, 4, true, range)); + formatter::run("prettier", cmd, text, &CancellationToken::new()) } #[test] diff --git a/languages/javascript.toml b/languages/javascript.toml index b5676537..5f19df86 100644 --- a/languages/javascript.toml +++ b/languages/javascript.toml @@ -27,6 +27,14 @@ args = ["--stdio"] root-markers = ["jsconfig.json", "tsconfig.json", "package.json"] install-hint = "npm i -g typescript@6 typescript-language-server" +[[language-servers]] +name = "oxlint" +command = "oxlint" +args = ["--lsp"] +root-markers = [".oxlintrc.json", ".oxlintrc.jsonc", "oxlint.config.ts", "oxlint.config.mts"] +require-root-marker = true +install-hint = "npm i -D oxlint" + [debugger] adapter = "js-debug" command = "js-debug-adapter" diff --git a/languages/javascriptreact.toml b/languages/javascriptreact.toml index 3c5a1776..be5b5ab9 100644 --- a/languages/javascriptreact.toml +++ b/languages/javascriptreact.toml @@ -26,6 +26,14 @@ args = ["--stdio"] root-markers = ["jsconfig.json", "tsconfig.json", "package.json"] install-hint = "npm i -g typescript@6 typescript-language-server" +[[language-servers]] +name = "oxlint" +command = "oxlint" +args = ["--lsp"] +root-markers = [".oxlintrc.json", ".oxlintrc.jsonc", "oxlint.config.ts", "oxlint.config.mts"] +require-root-marker = true +install-hint = "npm i -D oxlint" + [debugger] adapter = "js-debug" command = "js-debug-adapter" diff --git a/languages/svelte.toml b/languages/svelte.toml index 41095ee1..b441d410 100644 --- a/languages/svelte.toml +++ b/languages/svelte.toml @@ -13,3 +13,11 @@ command = "svelteserver" args = ["--stdio"] root-markers = ["svelte.config.js", "svelte.config.mjs", "svelte.config.ts", "package.json"] install-hint = "npm i -g svelte-language-server" + +[[language-servers]] +name = "oxlint" +command = "oxlint" +args = ["--lsp"] +root-markers = [".oxlintrc.json", ".oxlintrc.jsonc", "oxlint.config.ts", "oxlint.config.mts"] +require-root-marker = true +install-hint = "npm i -D oxlint" diff --git a/languages/typescript.toml b/languages/typescript.toml index 41817c5c..0dedc17a 100644 --- a/languages/typescript.toml +++ b/languages/typescript.toml @@ -37,6 +37,14 @@ root-markers = ["angular.json", "node_modules/@angular/core"] require-root-marker = true install-hint = "npm i -g @angular/language-server" +[[language-servers]] +name = "oxlint" +command = "oxlint" +args = ["--lsp"] +root-markers = [".oxlintrc.json", ".oxlintrc.jsonc", "oxlint.config.ts", "oxlint.config.mts"] +require-root-marker = true +install-hint = "npm i -D oxlint" + [debugger] adapter = "js-debug" command = "js-debug-adapter" diff --git a/languages/typescriptreact.toml b/languages/typescriptreact.toml index ad238c6c..d7ecf0b8 100644 --- a/languages/typescriptreact.toml +++ b/languages/typescriptreact.toml @@ -26,6 +26,14 @@ args = ["--stdio"] root-markers = ["tsconfig.json", "jsconfig.json", "package.json"] install-hint = "npm i -g typescript@6 typescript-language-server" +[[language-servers]] +name = "oxlint" +command = "oxlint" +args = ["--lsp"] +root-markers = [".oxlintrc.json", ".oxlintrc.jsonc", "oxlint.config.ts", "oxlint.config.mts"] +require-root-marker = true +install-hint = "npm i -D oxlint" + [debugger] adapter = "js-debug" command = "js-debug-adapter" diff --git a/languages/vue.toml b/languages/vue.toml index e260bce7..247f0930 100644 --- a/languages/vue.toml +++ b/languages/vue.toml @@ -22,3 +22,11 @@ command = "vtsls" args = ["--stdio"] root-markers = ["tsconfig.json", "jsconfig.json", "package.json"] install-hint = "npm i -g @vtsls/language-server" + +[[language-servers]] +name = "oxlint" +command = "oxlint" +args = ["--lsp"] +root-markers = [".oxlintrc.json", ".oxlintrc.jsonc", "oxlint.config.ts", "oxlint.config.mts"] +require-root-marker = true +install-hint = "npm i -D oxlint" diff --git a/src/editor/lsp/format.ts b/src/editor/lsp/format.ts index 061e377c..5d3031c3 100644 --- a/src/editor/lsp/format.ts +++ b/src/editor/lsp/format.ts @@ -118,19 +118,22 @@ async function format( try { edits = await lspRequest(() => commands.lspFormat(doc.doc, range, { ...options, onSave: opts.onSave })); } catch (e) { - if (e instanceof IpcError && e.data?.type === "tool" && e.data.tool === "prettier") { + if (e instanceof IpcError && e.data?.type === "tool" && (e.data.tool === "prettier" || e.data.tool === "oxfmt")) { + const tool = e.data.tool; if (e.kind === "notFound" && e.data.missing) { - await executeAction("tools.promptInstall", { name: "prettier", sticky: !opts.onSave }); + await executeAction("tools.promptInstall", { name: tool, sticky: !opts.onSave }); } else if (e.kind !== "cancelled") { notifications().notify({ group: "tools", level: "error", - title: tx("Prettier formatting failed"), + title: tool === "oxfmt" ? tx("oxfmt formatting failed") : tx("Prettier formatting failed"), body: e.kind === "timedOut" - ? tx("Prettier formatting timed out") + ? tool === "oxfmt" + ? tx("oxfmt formatting timed out") + : tx("Prettier formatting timed out") : createElement("span", { className: styles.diagnostics }, e.message), - key: `prettier-format-error:${doc.path}`, + key: `${tool}-format-error:${doc.path}`, sticky: false, }); } diff --git a/src/editor/lsp/formatErrors.test.tsx b/src/editor/lsp/formatErrors.test.tsx index aa21c997..3107316b 100644 --- a/src/editor/lsp/formatErrors.test.tsx +++ b/src/editor/lsp/formatErrors.test.tsx @@ -18,7 +18,6 @@ import { import { formatDocument } from "./format"; import * as lsp from "./lsp"; -const tool = { type: "tool", tool: "prettier", missing: false } as const; const views: EditorView[] = []; const viewFor = (path = "/project/file.css") => { const view = new EditorView({ @@ -43,7 +42,9 @@ afterEach(() => { _resetI18nForTests(); }); -describe("external formatter errors", () => { +describe.each(["prettier", "oxfmt"])("%s formatter errors", (name) => { + const tool = { type: "tool", tool: name, missing: false } as const; + const display = name === "prettier" ? "Prettier" : name; it("uses structured missing-tool information to request the shared install prompt", async () => { const prompt = vi.fn(); const dispose = registerAction({ @@ -54,9 +55,9 @@ describe("external formatter errors", () => { try { failWith({ kind: "notFound", message: "unrelated diagnostic", data: { ...tool, missing: true } }); await formatDocument(viewFor()); - expect(prompt).toHaveBeenCalledWith({ name: "prettier", sticky: true }); + expect(prompt).toHaveBeenCalledWith({ name, sticky: true }); await formatDocument(viewFor(), { explicit: false, onSave: true }); - expect(prompt).toHaveBeenLastCalledWith({ name: "prettier", sticky: false }); + expect(prompt).toHaveBeenLastCalledWith({ name, sticky: false }); expect(useNotifications.getState().items).toHaveLength(0); } finally { dispose(); @@ -95,7 +96,7 @@ describe("external formatter errors", () => { for (let i = 0; i < 3; i++) await formatDocument(view, { explicit: false, onSave: true }); const notes = useNotifications.getState().items; expect(notes).toHaveLength(1); - expect(notes[0]).toMatchObject({ sticky: false, title: "Prettier 格式化失败" }); + expect(notes[0]).toMatchObject({ sticky: false, title: `${display} 格式化失败` }); expect(balloonTimeout(notes[0]!)).toBe(10000); render(); expect(screen.getByRole("alert")).toHaveTextContent(stderr); @@ -115,7 +116,7 @@ describe("external formatter errors", () => { const request = failWith({ kind: "timedOut", message: "arbitrary backend text", data: tool }); const view = viewFor(); await formatDocument(view); - expect(useNotifications.getState().items[0]).toMatchObject({ body: "Prettier 格式化超时", sticky: false }); + expect(useNotifications.getState().items[0]).toMatchObject({ body: `${display} 格式化超时`, sticky: false }); clearNotifications(); request.mockRejectedValue(new IpcError({ kind: "cancelled", message: "cancelled", data: tool })); await formatDocument(view); diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index fec979fe..811644d7 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -1614,16 +1614,18 @@ "Install Missing Tool": "安装缺失的工具", "Prettier formatting failed": "Prettier 格式化失败", "Prettier formatting timed out": "Prettier 格式化超时", - "Prettier does not support selection formatting for this language; formatted the whole file": "Prettier 不支持此语言的选区格式化,已格式化整个文件", + "oxfmt formatting failed": "oxfmt 格式化失败", + "oxfmt formatting timed out": "oxfmt 格式化超时", + "{name} does not support selection formatting for this language; formatted the whole file": "{name} 不支持此语言的选区格式化,已格式化整个文件", "Formatting timed out": "格式化超时", - "Prettier: Node.js is required": "Prettier:需要 Node.js", + "{name}: Node.js is required": "{name}:需要 Node.js", "Formatter": "格式化工具", "JSON with Comments": "带注释的 JSON", "Unknown external formatter: {tool}": "未知的外部格式化工具:{tool}", - "Prettier: package has no CLI entry point": "Prettier:软件包未提供命令行入口", - "Prettier: CLI does not exist: {path}": "Prettier:命令行程序不存在:{path}", - "Prettier: file has no directory": "Prettier:文件路径不含目录", - "Prettier: {error}": "Prettier:{error}", + "{name}: package has no CLI entry point": "{name}:软件包未提供命令行入口", + "{name}: CLI does not exist: {path}": "{name}:命令行程序不存在:{path}", + "{name}: file has no directory": "{name}:文件路径不含目录", + "{name}: {error}": "{name}:{error}", "Reformat File": "重新格式化文件", "Reformat code": "重新格式化代码", "Reformat code on save": "保存时重新格式化代码", From 743612d4ec3adc6a16ac660f180e8c5643a276d3 Mon Sep 17 00:00:00 2001 From: misswell Date: Mon, 2 Nov 2026 12:05:25 +0800 Subject: [PATCH 08/11] fix(java): let jdtls read jars JDK 25 rejects (#771) jdtls runs on JDK 25, whose ZipFile validates the ZIP64 extra field and rejects jars written by older tools (aspectjweaver 1.8.10: "Invalid CEN header (invalid zip64 extra data field size)"). ECJ then reports the dependency as unreadable, the module gets a build path error and never compiles, and running its main class fails with a missing class. Pass -Djdk.util.zip.disableZip64ExtraFieldValidation=true to jdtls unless the user set the property; set_jvm_property now owns the set-once, user-value-wins rule shared with the proxy properties. Co-authored-by: misswell <28730815+misswell@users.noreply.github.com> --- crates/ide-sdk/src/java.rs | 59 ++++++++++++++++++++++++++++++++------ 1 file changed, 51 insertions(+), 8 deletions(-) diff --git a/crates/ide-sdk/src/java.rs b/crates/ide-sdk/src/java.rs index 21b217dc..814ccf7d 100644 --- a/crates/ide-sdk/src/java.rs +++ b/crates/ide-sdk/src/java.rs @@ -313,21 +313,36 @@ fn text_key(text: &str) -> String { format!("{h:016x}") } +/// Adds `arg` as its own `--jvm-arg`, unless the user already set the property it +/// carries: a value of theirs wins, and a second call adds nothing. +fn set_jvm_property(args: &mut Vec, arg: &str) { + let key = arg.split('=').next().unwrap_or(arg); + let taken = args.iter().enumerate().any(|(i, a)| { + let value = + a.strip_prefix("--jvm-arg=").or_else(|| (i > 0 && args[i - 1] == "--jvm-arg").then_some(a.as_str())); + value.is_some_and(|a| a.split('=').next() == Some(key)) + }); + if !taken { + args.push(format!("--jvm-arg={arg}")); + } +} + fn add_jdtls_proxy_args(args: &mut Vec, proxy: &ide_core::proxy_settings::ProxySettings) { for arg in proxy.jvm_args() { - let key = arg.split('=').next().unwrap_or(arg.as_str()); - if !args.iter().enumerate().any(|(i, a)| { - let value = - a.strip_prefix("--jvm-arg=").or_else(|| (i > 0 && args[i - 1] == "--jvm-arg").then_some(a.as_str())); - value.is_some_and(|a| a.split('=').next() == Some(key)) - }) { - args.push(format!("--jvm-arg={arg}")); - } + set_jvm_property(args, &arg); } } const METADATA_PROPERTY: &str = "java.import.generatesMetadataFilesAtProjectRoot"; +/// JDK 25's `ZipFile` validates the ZIP64 extra field, and jars written by older tools +/// carry a size that check rejects: ECJ reports the dependency as "cannot be read or is +/// not a valid ZIP file", the project using it gets a build path error, and every project +/// depending on that one stops compiling — aspectjweaver 1.8.10 stalls a whole Maven +/// build that way, while the same jar loads fine on IDEA's JBR 21. This is how 25 is told +/// to behave the same. +const ZIP64_LENIENCY: &str = "-Djdk.util.zip.disableZip64ExtraFieldValidation=true"; + /// jdtls: its Eclipse workspace (`-data`) lives under the IDE's data /// directory, one per server root, and no Eclipse metadata is written into /// the project; it runs on a JDK `min_java`+ (via `JAVA_HOME`, which its @@ -384,6 +399,10 @@ pub fn configure_jdtls( // of the `HTTP_PROXY` / `HTTPS_PROXY` it inherits: pass the same proxy as system // properties. One the user set in their own JVM arguments wins. add_jdtls_proxy_args(&mut cfg.args, &ide_core::proxy_settings::proxy()); + // ECJ reads the project's jars with the server JVM's own zip reader, so a jar JDK 25 + // refuses to open takes the whole Maven build with it. IDEA never notices: its JBR 21 + // accepts the same file. + set_jvm_property(&mut cfg.args, ZIP64_LENIENCY); let managed = language_server_runtimes_dir(data_dir); let managed = dunce::canonicalize(&managed).unwrap_or(managed); let project_jdks: Vec<_> = project_jdk @@ -664,6 +683,11 @@ mod tests { assert_eq!(cfg.args[n], "-data"); assert!(cfg.args[n + 1].starts_with("/data/lsp/jdtls/"), "{}", cfg.args[n + 1]); assert_eq!(cfg.args[n + 2], "--jvm-arg=-Djava.import.generatesMetadataFilesAtProjectRoot=false"); + assert!( + cfg.args.iter().any(|a| a == "--jvm-arg=-Djdk.util.zip.disableZip64ExtraFieldValidation=true"), + "jars JDK 25 rejects must still load: {:?}", + cfg.args + ); let opts = serde_json::to_value(cfg.initialization_options.clone().unwrap()).unwrap(); let rt = &opts["settings"]["java"]["configuration"]["runtimes"]; assert_eq!(rt[0], json!({ "name": "JavaSE-1.8", "path": "/jdk8" })); @@ -751,6 +775,25 @@ mod tests { } } + #[test] + fn a_jar_jdk_25_rejects_loads_and_the_users_value_wins() { + // aspectjweaver 1.8.10 carries a ZIP64 extra field size 25 refuses to read; on the + // JVM that runs jdtls that is a build path error in every project using it. + let mut args = Vec::new(); + set_jvm_property(&mut args, ZIP64_LENIENCY); + assert_eq!(args, ["--jvm-arg=-Djdk.util.zip.disableZip64ExtraFieldValidation=true"]); + set_jvm_property(&mut args, ZIP64_LENIENCY); + assert_eq!(args.len(), 1, "added once"); + for user in [ + "--jvm-arg=-Djdk.util.zip.disableZip64ExtraFieldValidation=false".to_owned(), + "--jvm-arg=-Djdk.util.zip.disableZip64ExtraFieldValidation".to_owned(), + ] { + let mut own = vec![user.clone()]; + set_jvm_property(&mut own, ZIP64_LENIENCY); + assert_eq!(own, [user], "the user decides"); + } + } + #[test] fn jdtls_lombok_agent() { let jdtls = || -> LanguageServerConfig { toml::from_str("name = \"jdtls\"\ncommand = \"jdtls\"").unwrap() }; From 8e8a998abe37758a073923424239df7b49e89815 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Mon, 2 Nov 2026 12:11:12 +0800 Subject: [PATCH 09/11] feat(vcs): inline blame for the current line (#778) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Show the current line's author, relative date and commit summary as a dimmed hint after the line end, with the full message, date and hash on hover and the commit opened on click. Uncommitted lines read "You, now · Uncommitted changes". Reuses the blame command with a per-version cache and a debounced caret; skipped outside repositories, for untracked/ignored and very large files. Off by default: Settings › Version Control › Git › Show inline blame for the current line. Fixes #742. --- crates/ide-core/src/workflow_settings.rs | 3 + src/bindings.ts | 2 + src/editor/api.ts | 3 + src/editor/core/cm/setup.ts | 5 +- src/features/settings/pages/vcs.tsx | 1 + .../vcs/__tests__/inlineBlame.test.ts | 255 ++++++++++++++++ src/features/vcs/index.ts | 3 +- src/features/vcs/inlineBlame.ts | 281 ++++++++++++++++++ src/platform/clientSettings.ts | 1 + src/platform/locales/text/zh.json | 5 +- src/platform/workflowSettings.test.ts | 6 + src/platform/workflowSettings.ts | 3 + 12 files changed, 563 insertions(+), 5 deletions(-) create mode 100644 src/features/vcs/__tests__/inlineBlame.test.ts create mode 100644 src/features/vcs/inlineBlame.ts diff --git a/crates/ide-core/src/workflow_settings.rs b/crates/ide-core/src/workflow_settings.rs index 64fb58fb..3b31a576 100644 --- a/crates/ide-core/src/workflow_settings.rs +++ b/crates/ide-core/src/workflow_settings.rs @@ -41,6 +41,8 @@ impl Default for VcsSettings { #[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] #[serde(default, rename_all = "camelCase")] pub struct GitSettings { + /// Show blame after the current editor line. + pub inline_blame: bool, /// Path to the git executable; empty = auto-detect. pub executable: String, /// Default of Update Project: `merge` | `rebase`. @@ -71,6 +73,7 @@ pub struct GitSettings { impl Default for GitSettings { fn default() -> Self { Self { + inline_blame: false, executable: String::new(), update_method: "merge".into(), auto_update_on_push_rejected: false, diff --git a/src/bindings.ts b/src/bindings.ts index e2eb8a2a..7c4a2d95 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -3350,6 +3350,8 @@ export type GitOperation = { op: "interactiveRebase"; base: string | null; entries: RebaseEntry[] }; export type GitSettings = { + /** Show blame after the current editor line. */ + inlineBlame?: boolean, /** Path to the git executable; empty = auto-detect. */ executable?: string, /** Default of Update Project: `merge` | `rebase`. */ diff --git a/src/editor/api.ts b/src/editor/api.ts index 9f0251cb..9d1e81e0 100644 --- a/src/editor/api.ts +++ b/src/editor/api.ts @@ -32,6 +32,9 @@ import type { LanguageDescription } from "@codemirror/language"; import { create } from "zustand"; import { maybeEditorService } from "@/workbench/contributions"; +/** Documents above this size (UTF-16 units) skip per-line decorations. */ +export const HEAVY_LIMIT = 8_000_000; + export interface EditorDocContext { doc: number; path: string; diff --git a/src/editor/core/cm/setup.ts b/src/editor/core/cm/setup.ts index bcecc965..059b6c13 100644 --- a/src/editor/core/cm/setup.ts +++ b/src/editor/core/cm/setup.ts @@ -28,7 +28,7 @@ import type { EditorConfigProps } from "@/bindings"; import type { EditorSettings } from "@/platform/ipc"; import { editorPrefs, lineCommentStyleFor, type EditorExtras } from "@/platform/editorSettings"; import { BUNDLED_MONO_FAMILY, monoFontFamily } from "@/platform/fonts"; -import { editorContributions, type EditorDocContext } from "@/editor/api"; +import { editorContributions, HEAVY_LIMIT, type EditorDocContext } from "@/editor/api"; import { distractionFreeWidth, isDistractionFree } from "@/workbench/distractionFree"; import { openideaHighlightStyle } from "./highlight"; import { openideaTheme } from "./theme"; @@ -78,8 +78,7 @@ export function prefsOf(settings: EditorSettings | undefined): EditorExtras { return editorPrefs({ editor: settings }); } -/** Documents above this size (UTF-16 units) skip per-line decorations. */ -export const HEAVY_LIMIT = 8_000_000; +export { HEAVY_LIMIT } from "@/editor/api"; export interface EffectiveIndent { tabSize: number; diff --git a/src/features/settings/pages/vcs.tsx b/src/features/settings/pages/vcs.tsx index 225095a0..db087571 100644 --- a/src/features/settings/pages/vcs.tsx +++ b/src/features/settings/pages/vcs.tsx @@ -492,6 +492,7 @@ export const VCS_PAGES: PageSpec[] = [ keywords: ["Test", "git version"], component: GitExecutableRow, }, + { kind: "toggle", key: "vcs.git.inlineBlame", label: "Show inline blame for the current line" }, ], }, { diff --git a/src/features/vcs/__tests__/inlineBlame.test.ts b/src/features/vcs/__tests__/inlineBlame.test.ts new file mode 100644 index 00000000..e49cb3de --- /dev/null +++ b/src/features/vcs/__tests__/inlineBlame.test.ts @@ -0,0 +1,255 @@ +import { EditorSelection, EditorState } from "@codemirror/state"; +import { EditorView, activateHover, closeHoverTooltips, hasHoverTooltips } from "@codemirror/view"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { BlameLine, Settings } from "@/bindings"; +import { HEAVY_LIMIT, _emitEditorEvent } from "@/editor/api"; +import { useProject } from "@/platform/project"; +import { useI18n } from "@/platform/i18n"; +import { formatInlineBlame, inlineBlame, relativeBlameDate } from "../inlineBlame"; +import { useVcs } from "../store"; + +const mocks = vi.hoisted(() => ({ blame: vi.fn(), file: vi.fn(), log: vi.fn(), action: vi.fn() })); +vi.mock("@/platform/actions", async (original) => ({ + ...(await original()), + executeAction: mocks.action, +})); +vi.mock("@/platform/ipc", async (original) => { + const actual = await original(); + return { + ...actual, + isTauri: () => true, + commands: { ...actual.commands, vcsBlame: mocks.blame, vcsFileAt: mocks.file, vcsLog: mocks.log }, + }; +}); + +const now = Date.UTC(2026, 10, 2, 12); +const alice: BlameLine = { + commit: "a".repeat(40), + author: "Alice Smith", + timeMs: now - 86400_000, + summary: "First line", +}; +const bob: BlameLine = { commit: "b".repeat(40), author: "Bob Jones", timeMs: now - 3600_000, summary: "Second line" }; +const uncommitted: BlameLine = { commit: "0".repeat(40), author: "Not Committed Yet", timeMs: now, summary: "" }; +const ctx = { doc: 1, path: "/repo/file.ts", language: "typescript", uiMode: null, readOnly: false }; +let view: EditorView | undefined; + +function settings(enabled: boolean) { + useProject.setState({ settings: { vcs: { git: { inlineBlame: enabled } } } as Settings }); +} + +function editor(doc = "one\ntwo\nthree", context = ctx) { + view = new EditorView({ + parent: document.body, + state: EditorState.create({ doc, extensions: [inlineBlame(context)] }), + }); + return view; +} + +const hint = () => view?.dom.querySelector(".cm-vcs-inline-blame"); +const tick = () => vi.advanceTimersByTimeAsync(300); + +beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(now); + vi.clearAllMocks(); + useI18n.setState({ locale: "en", texts: {} }); + settings(true); + useVcs.setState({ available: true, roots: ["/repo"], statusLoaded: true, byPath: new Map(), version: 0 }); + mocks.file.mockResolvedValue({ status: "ok", data: "one\ntwo\nthree" }); + mocks.blame.mockResolvedValue({ status: "ok", data: [alice, bob, uncommitted] }); +}); + +afterEach(() => { + view?.destroy(); + view = undefined; + useProject.setState({ settings: null }); + vi.useRealTimers(); +}); + +describe("inline blame formatting", () => { + it("formats authors, relative dates, summaries and missing dates", () => { + expect(formatInlineBlame(alice, now)).toBe("Alice Smith, 1 day ago · First line"); + expect(formatInlineBlame({ ...bob, timeMs: null, summary: "Long\nmessage" }, now)).toBe("Bob Jones · Long message"); + expect(formatInlineBlame(uncommitted, now)).toBe("You, now · Uncommitted changes"); + expect(relativeBlameDate(now + 1000, now)).toBe("now"); + expect(relativeBlameDate(now - 120_000, now)).toBe("2 minutes ago"); + expect(relativeBlameDate(now - 400 * 86400_000, now)).toBe("1 year ago"); + }); +}); + +describe("current line decoration", () => { + it("debounces caret moves and reuses blame for the same document version", async () => { + const v = editor(); + await vi.advanceTimersByTimeAsync(299); + expect(mocks.blame).not.toHaveBeenCalled(); + v.dispatch({ selection: { anchor: 4 } }); + await vi.advanceTimersByTimeAsync(299); + expect(mocks.blame).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(1); + expect(hint()?.textContent).toContain("Bob Jones"); + v.dispatch({ selection: { anchor: 0 } }); + expect(hint()).toBeNull(); + await tick(); + expect(hint()?.textContent).toContain("Alice Smith"); + expect(mocks.blame).toHaveBeenCalledTimes(1); + expect(mocks.file).toHaveBeenCalledTimes(1); + expect(hint()?.contentEditable).toBe("false"); + hint()?.click(); + expect(mocks.action).toHaveBeenCalledWith("vcs.showCommit", alice.commit); + }); + + it("uses only the primary selection and hides multiline selections", async () => { + const v = editor(); + v.dispatch({ selection: EditorSelection.create([EditorSelection.cursor(0), EditorSelection.cursor(4)], 1) }); + await tick(); + expect(hint()?.textContent).toContain("Bob Jones"); + v.dispatch({ selection: { anchor: 0, head: 5 } }); + await tick(); + expect(hint()).toBeNull(); + v.dispatch({ selection: { anchor: 4, head: 6 } }); + await tick(); + expect(hint()?.textContent).toContain("Bob Jones"); + }); + + it("loads and caches the full commit message on hover and closes it on caret movement", async () => { + const message = "First line\n\nThe complete commit body."; + mocks.log.mockResolvedValue({ status: "ok", data: [{ message }] }); + const v = editor(); + await tick(); + activateHover(v, 3, 1); + await vi.advanceTimersByTimeAsync(0); + expect(hasHoverTooltips(v.state)).toBe(true); + const tooltip = v.dom.querySelector(".cm-vcs-inline-blame-tooltip"); + expect(tooltip?.textContent).toContain(message); + expect(tooltip?.textContent).toContain(alice.author); + expect(tooltip?.textContent).toContain(alice.commit); + expect(tooltip?.textContent).toContain(new Date(alice.timeMs!).toLocaleString()); + v.dispatch({ effects: closeHoverTooltips }); + activateHover(v, 3, 1); + await vi.advanceTimersByTimeAsync(0); + expect(mocks.log).toHaveBeenCalledTimes(1); + v.dispatch({ selection: { anchor: 4 } }); + expect(hasHoverTooltips(v.state)).toBe(false); + }); + + it("closes an open tooltip when the setting is disabled", async () => { + mocks.log.mockResolvedValue({ status: "ok", data: [{ message: "Full message" }] }); + const v = editor(); + await tick(); + activateHover(v, 3, 1); + await vi.advanceTimersByTimeAsync(0); + expect(hasHoverTooltips(v.state)).toBe(true); + settings(false); + expect(hasHoverTooltips(v.state)).toBe(false); + }); + + it("refetches edits, saves and VCS refreshes", async () => { + const v = editor(); + await tick(); + mocks.blame.mockResolvedValue({ status: "ok", data: [uncommitted, bob, uncommitted] }); + v.dispatch({ changes: { from: 0, insert: "x" } }); + expect(hint()).toBeNull(); + await tick(); + expect(mocks.blame).toHaveBeenLastCalledWith(ctx.path, "xone\ntwo\nthree", null); + expect(hint()?.textContent).toBe("You, now · Uncommitted changes"); + _emitEditorEvent({ type: "docSaved", doc: 1, path: ctx.path }); + await tick(); + useVcs.setState({ version: 1 }); + await tick(); + expect(mocks.blame).toHaveBeenCalledTimes(4); + }); + + it("discards a response from an older document version", async () => { + let resolve!: (result: { status: "ok"; data: BlameLine[] }) => void; + mocks.blame.mockReturnValueOnce(new Promise((done) => (resolve = done))); + const v = editor(); + await tick(); + v.dispatch({ changes: { from: 0, insert: "x" } }); + resolve({ status: "ok", data: [alice] }); + await vi.advanceTimersByTimeAsync(0); + expect(hint()).toBeNull(); + mocks.blame.mockResolvedValue({ status: "ok", data: [uncommitted] }); + await tick(); + expect(hint()?.textContent).toBe("You, now · Uncommitted changes"); + }); + + it("coalesces an in-flight blame when the caret moves", async () => { + let resolve!: (result: { status: "ok"; data: BlameLine[] }) => void; + mocks.blame.mockReturnValueOnce(new Promise((done) => (resolve = done))); + const v = editor(); + await tick(); + v.dispatch({ selection: { anchor: 4 } }); + await tick(); + resolve({ status: "ok", data: [alice, bob] }); + await vi.advanceTimersByTimeAsync(0); + expect(hint()?.textContent).toContain("Bob Jones"); + expect(mocks.blame).toHaveBeenCalledTimes(1); + }); + + it("responds to the setting immediately", async () => { + settings(false); + editor(); + await tick(); + expect(mocks.blame).not.toHaveBeenCalled(); + settings(true); + await tick(); + expect(hint()).not.toBeNull(); + settings(false); + expect(hint()).toBeNull(); + await tick(); + expect(mocks.blame).toHaveBeenCalledTimes(1); + }); + + it("ignores a pending response after disabling", async () => { + let resolve!: (result: { status: "ok"; data: BlameLine[] }) => void; + mocks.blame.mockReturnValueOnce(new Promise((done) => (resolve = done))); + editor(); + await tick(); + settings(false); + resolve({ status: "ok", data: [alice] }); + await vi.advanceTimersByTimeAsync(0); + expect(hint()).toBeNull(); + await tick(); + expect(hint()).toBeNull(); + }); + + it.each(["untracked", "ignored"] as const)("skips %s paths", async (unstaged) => { + useVcs.setState({ + byPath: new Map([[ctx.path, { path: ctx.path, relPath: "file.ts", unstaged, staged: null, renamedFrom: null }]]), + }); + editor(); + await tick(); + expect(mocks.file).not.toHaveBeenCalled(); + expect(hint()).toBeNull(); + }); + + it("skips ignored paths missing from status and HEAD", async () => { + mocks.file.mockResolvedValue({ status: "ok", data: null }); + editor(); + await tick(); + expect(mocks.blame).not.toHaveBeenCalled(); + expect(hint()).toBeNull(); + }); + + it("skips paths outside repositories", async () => { + editor("one", { ...ctx, path: "/elsewhere/file.ts" }); + await tick(); + expect(mocks.file).not.toHaveBeenCalled(); + }); + + it("skips large, virtual and read-only documents", async () => { + editor("x".repeat(HEAVY_LIMIT + 1)); + await tick(); + expect(mocks.file).not.toHaveBeenCalled(); + expect(inlineBlame({ ...ctx, virtual: true })).toEqual([]); + expect(inlineBlame({ ...ctx, readOnly: true })).toEqual([]); + }); + + it("cancels pending work when the view is destroyed", async () => { + editor().destroy(); + view = undefined; + await tick(); + expect(mocks.file).not.toHaveBeenCalled(); + }); +}); diff --git a/src/features/vcs/index.ts b/src/features/vcs/index.ts index 80198517..9e74b30c 100644 --- a/src/features/vcs/index.ts +++ b/src/features/vcs/index.ts @@ -23,6 +23,7 @@ import { registerHistoryActions, trackOperationContext } from "./historyActions" import { registerLogPaneActions } from "./logPaneActions"; import { controlLabel, controlOperation, inProgress } from "./operations"; import { annotationsShown, blameSlot } from "./blame"; +import { inlineBlame } from "./inlineBlame"; import { BranchWidget, useBranchesPopup } from "./BranchWidget"; import { showConflictsDialog } from "./conflictsUi"; import { registerCompareActions } from "./compare"; @@ -212,7 +213,7 @@ export function activate(): () => void { registerEditorContribution({ id: "vcs.changeMarkers", order: 60, - extensions: (ctx) => [changeMarkers(ctx), blameSlot()], + extensions: (ctx) => [changeMarkers(ctx), blameSlot(), inlineBlame(ctx)], }), registerMenuGroup({ id: "vcs.gutter", diff --git a/src/features/vcs/inlineBlame.ts b/src/features/vcs/inlineBlame.ts new file mode 100644 index 00000000..047b3136 --- /dev/null +++ b/src/features/vcs/inlineBlame.ts @@ -0,0 +1,281 @@ +import { StateEffect, StateField, type Extension, type Text } from "@codemirror/state"; +import { + Decoration, + EditorView, + ViewPlugin, + WidgetType, + hoverTooltip, + type DecorationSet, + type ViewUpdate, +} from "@codemirror/view"; +import type { BlameLine } from "@/bindings"; +import { HEAVY_LIMIT, onEditorEvent, type EditorDocContext } from "@/editor/api"; +import { executeAction } from "@/platform/actions"; +import { tx, useI18n } from "@/platform/i18n"; +import { call, commands, isTauri } from "@/platform/ipc"; +import { onWorkflowSettingsChange, workflowSettings } from "@/platform/workflowSettings"; +import { isUncommitted } from "./blame"; +import { repoOf, statusOf, useVcs } from "./store"; + +export function relativeBlameDate(timeMs: number | null, now = Date.now()): string { + if (timeMs === null) return ""; + const seconds = Math.max(0, (now - timeMs) / 1000); + const units = [ + ["year", 365 * 86400], + ["month", 30 * 86400], + ["day", 86400], + ["hour", 3600], + ["minute", 60], + ] as const; + const unit = units.find(([, size]) => seconds >= size); + return unit + ? new Intl.RelativeTimeFormat(useI18n.getState().locale, { numeric: "always" }).format( + -Math.floor(seconds / unit[1]), + unit[0], + ) + : tx("now"); +} + +export function formatInlineBlame(line: BlameLine, now = Date.now()): string { + if (isUncommitted(line)) return tx("You, now · Uncommitted changes"); + const date = relativeBlameDate(line.timeMs, now); + return `${line.author}${date ? `, ${date}` : ""} · ${line.summary.replace(/\s+/g, " ")}`; +} + +/** Only the current document version is retained, including its in-flight request. */ +class BlameCache { + private doc: Text | undefined; + private pending: Promise | undefined; + + clear() { + this.doc = undefined; + this.pending = undefined; + } + + get(doc: Text, load: () => Promise): Promise { + if (doc !== this.doc || !this.pending) { + this.doc = doc; + this.pending = load(); + } + return this.pending; + } +} + +class BlameWidget extends WidgetType { + constructor(readonly line: BlameLine) { + super(); + } + + eq(other: BlameWidget) { + return formatInlineBlame(other.line) === formatInlineBlame(this.line) && other.line.commit === this.line.commit; + } + + toDOM() { + const el = document.createElement("span"); + el.className = "cm-vcs-inline-blame"; + el.textContent = formatInlineBlame(this.line); + el.contentEditable = "false"; + if (!isUncommitted(this.line)) { + el.setAttribute("role", "button"); + el.tabIndex = 0; + const open = () => void executeAction("vcs.showCommit", this.line.commit); + el.addEventListener("mousedown", (e) => e.preventDefault()); + el.addEventListener("click", open); + el.addEventListener("keydown", (e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + open(); + } + }); + } + return el; + } +} + +const setInlineBlame = StateEffect.define(); +const inlineBlameField = StateField.define({ + create: () => Decoration.none, + update(value, tr) { + if (tr.docChanged || tr.selection) return Decoration.none; + for (const effect of tr.effects) if (effect.is(setInlineBlame)) return effect.value; + return value; + }, + provide: (field) => EditorView.decorations.from(field), +}); + +export function inlineBlame(ctx: EditorDocContext): Extension { + if (ctx.virtual || ctx.readOnly) return []; + const plugin = ViewPlugin.fromClass( + class { + private timer: ReturnType | undefined; + private readonly cache = new BlameCache(); + private readonly messages = new Map>(); + private readonly off: (() => void)[]; + private generation = 0; + private destroyed = false; + line: BlameLine | undefined; + + constructor(readonly view: EditorView) { + this.off = [ + useVcs.subscribe((s, prev) => { + if (s.version !== prev.version) this.invalidate(); + }), + onEditorEvent((e) => { + if (e.type === "docSaved" && e.path === ctx.path) this.invalidate(); + }), + onWorkflowSettingsChange( + (s) => s.vcs.git.inlineBlame, + () => this.invalidate(), + ), + useI18n.subscribe(() => this.invalidate()), + ]; + this.schedule(); + } + + private enabled() { + const vcs = useVcs.getState(); + const status = statusOf(ctx.path); + return ( + workflowSettings().vcs.git.inlineBlame && + isTauri() && + vcs.available && + vcs.statusLoaded && + !!repoOf(ctx.path) && + status?.unstaged !== "untracked" && + status?.unstaged !== "ignored" && + this.view.state.doc.length <= HEAVY_LIMIT + ); + } + + private clear() { + this.line = undefined; + if (this.view.state.field(inlineBlameField).size) + this.view.dispatch({ effects: setInlineBlame.of(Decoration.none) }); + } + + private invalidate() { + this.cache.clear(); + this.clear(); + this.schedule(); + } + + update(update: ViewUpdate) { + if (update.docChanged) this.cache.clear(); + if (update.docChanged || update.selectionSet) { + this.line = undefined; + this.schedule(); + } + } + + private schedule() { + clearTimeout(this.timer); + ++this.generation; + this.timer = setTimeout(() => void this.refresh(), 300); + } + + private async refresh() { + if (this.destroyed || !this.enabled()) return; + const { doc, selection } = this.view.state; + const caret = doc.lineAt(selection.main.head); + if (doc.lineAt(selection.main.from).number !== doc.lineAt(selection.main.to).number) return; + const generation = this.generation; + try { + const lines = await this.cache.get(doc, async () => { + // Blame also returns synthetic entries for paths absent from HEAD (ignored / new files). + if ((await call(commands.vcsFileAt(ctx.path, "HEAD"))) === null) return []; + return call(commands.vcsBlame(ctx.path, doc.toString(), null)); + }); + if (this.destroyed || generation !== this.generation || doc !== this.view.state.doc || !this.enabled()) + return; + this.line = lines[caret.number - 1]; + if (this.line) { + const widget = Decoration.widget({ widget: new BlameWidget(this.line), side: 1 }).range(caret.to); + this.view.dispatch({ effects: setInlineBlame.of(Decoration.set([widget])) }); + } + } catch { + // Repository removed, unborn HEAD, inaccessible file, etc.: leave the line undecorated. + } + } + + message(line: BlameLine): Promise { + if (isUncommitted(line)) return Promise.resolve(tx("You, now · Uncommitted changes")); + let message = this.messages.get(line.commit); + if (!message) { + message = call( + commands.vcsLog(repoOf(ctx.path), { + branch: line.commit, + path: null, + author: null, + text: null, + skip: 0, + limit: 1, + }), + ).then( + (commits) => commits[0]?.message ?? line.summary, + () => line.summary, + ); + if (this.messages.size >= 100) this.messages.delete(this.messages.keys().next().value!); + this.messages.set(line.commit, message); + } + return message; + } + + destroy() { + this.destroyed = true; + clearTimeout(this.timer); + this.cache.clear(); + this.off.forEach((off) => off()); + } + }, + ); + return [ + inlineBlameField, + plugin, + hoverTooltip( + async (view, pos, side) => { + const instance = view.plugin(plugin); + const line = instance?.line; + const end = view.state.doc.lineAt(view.state.selection.main.head).to; + if (!instance || !line || pos !== end || side < 0) return null; + const message = await instance.message(line); + if (view.plugin(plugin)?.line !== line) return null; + return { + pos: end, + above: true, + create() { + const dom = document.createElement("div"); + dom.className = "cm-vcs-inline-blame-tooltip"; + dom.textContent = isUncommitted(line) + ? message + : `${line.author}\n${line.timeMs === null ? "" : new Date(line.timeMs).toLocaleString()}\n${line.commit}\n\n${message}`; + return { dom }; + }, + }; + }, + { + hideOnChange: true, + hideOn: (tr) => tr.effects.some((effect) => effect.is(setInlineBlame)), + }, + ), + EditorView.baseTheme({ + ".cm-vcs-inline-blame": { + color: "var(--fg-inlay)", + marginLeft: "3ch", + display: "inline-block", + maxWidth: "min(60ch, 50vw)", + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", + verticalAlign: "bottom", + userSelect: "none", + }, + '.cm-vcs-inline-blame[role="button"]': { cursor: "pointer" }, + ".cm-vcs-inline-blame-tooltip": { + padding: "8px 12px", + maxWidth: "min(70ch, 80vw)", + whiteSpace: "pre-wrap", + overflowWrap: "anywhere", + }, + }), + ]; +} diff --git a/src/platform/clientSettings.ts b/src/platform/clientSettings.ts index b2c2c83b..742e797d 100644 --- a/src/platform/clientSettings.ts +++ b/src/platform/clientSettings.ts @@ -77,6 +77,7 @@ export const CLIENT_SETTINGS_KEYS = [ "run.confirmTerminate", "run.showOnStart", "vcs.showDirtyDirectories", + "vcs.git.inlineBlame", "vcs.git.showPushDialog", "vcs.git.pushDialogOnlyProtected", "vcs.git.combineStashAndShelf", diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 811644d7..844d5081 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -5497,5 +5497,8 @@ "Cannot paste image": "无法粘贴图片", "Remote server does not support image paste.": "远程服务器不支持粘贴图片。", "Clipboard image reading is unavailable.": "无法读取剪贴板图片。", - "Image is too large to paste (limit {limit} MB).": "图片过大,无法粘贴(上限 {limit} MB)。" + "Image is too large to paste (limit {limit} MB).": "图片过大,无法粘贴(上限 {limit} MB)。", + "Show inline blame for the current line": "为当前行显示内联 Git Blame", + "You, now · Uncommitted changes": "你,刚刚 · 未提交的更改", + "now": "刚刚" } diff --git a/src/platform/workflowSettings.test.ts b/src/platform/workflowSettings.test.ts index 1721e2ca..d12da7d5 100644 --- a/src/platform/workflowSettings.test.ts +++ b/src/platform/workflowSettings.test.ts @@ -3,6 +3,12 @@ import { splitCommandLine } from "./commandLine"; import { DEFAULT_WORKFLOW, validateWorkflow } from "./workflowSettings"; describe("validateWorkflow", () => { + it("defaults inline blame off and accepts only boolean values", () => { + expect(DEFAULT_WORKFLOW.vcs.git.inlineBlame).toBe(false); + expect(validateWorkflow({ vcs: { git: { inlineBlame: true } } }).vcs.git.inlineBlame).toBe(true); + expect(validateWorkflow({ vcs: { git: { inlineBlame: "true" } } }).vcs.git.inlineBlame).toBe(false); + }); + it("falls back to defaults for missing or malformed values", () => { expect(validateWorkflow(undefined)).toEqual(DEFAULT_WORKFLOW); const v = validateWorkflow({ diff --git a/src/platform/workflowSettings.ts b/src/platform/workflowSettings.ts index 42871480..559d20d9 100644 --- a/src/platform/workflowSettings.ts +++ b/src/platform/workflowSettings.ts @@ -18,6 +18,7 @@ export interface IssueLinkValid { } export interface GitSettingsValid { + inlineBlame: boolean; executable: string; updateMethod: UpdateMethod; autoUpdateOnPushRejected: boolean; @@ -183,6 +184,7 @@ export const DEFAULT_WORKFLOW: WorkflowSettings = { limitHistory: true, historyLimit: 1000, git: { + inlineBlame: false, executable: "", updateMethod: "merge", autoUpdateOnPushRejected: false, @@ -309,6 +311,7 @@ export function validateVcs(raw: unknown): VcsSettingsValid { limitHistory: bool(v.limitHistory, d.limitHistory), historyLimit: num(v.historyLimit, d.historyLimit, L.historyLimit, true), git: { + inlineBlame: bool(g.inlineBlame, d.git.inlineBlame), executable: text(g.executable, d.git.executable).trim(), updateMethod: oneOf(g.updateMethod, ["merge", "rebase"] as const, d.git.updateMethod), autoUpdateOnPushRejected: bool(g.autoUpdateOnPushRejected, d.git.autoUpdateOnPushRejected), From 6184330876df74b9c3eade84fedbedaa11f847ec Mon Sep 17 00:00:00 2001 From: arookieofc <2128194521@qq.com> Date: Mon, 2 Nov 2026 14:33:11 +0800 Subject: [PATCH 10/11] test(tools): compare the oxfmt script path by components on every platform (#786) package_bin joins the manifest's bin entry (bin/oxfmt) onto a platform path, so on Windows the separators differ from the expected path and the oxfmt formatter test failed. Compare path components instead. Co-authored-by: a-rookie-of-C-language <186315048+a-rookie-of-C-language@users.noreply.github.com> --- crates/ide-tools/src/formatter.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/crates/ide-tools/src/formatter.rs b/crates/ide-tools/src/formatter.rs index 95dd011b..b6a7a692 100644 --- a/crates/ide-tools/src/formatter.rs +++ b/crates/ide-tools/src/formatter.rs @@ -209,7 +209,12 @@ mod tests { let program = Path::new(command.get_program()).file_stem().unwrap().to_string_lossy().into_owned(); assert_eq!(program, "node"); let script: Vec<_> = command.get_args().collect(); - assert_eq!(script, [root.join("node_modules/oxfmt/bin/oxfmt").as_os_str()]); + // Compare components: `package_bin` joins the manifest's `bin` entry + // (`bin/oxfmt`) onto a platform path, so separators differ on Windows. + assert_eq!( + Path::new(&script[0]).components().collect::>(), + root.join("node_modules/oxfmt/bin/oxfmt").components().collect::>() + ); let config = FormatterConfig { tool: "prettier".into(), parser: "typescript".into(), range: true }; let args = arguments("oxfmt", &config, &file, 4, false, Some((3, 9))); From 95188d9f40598172c698e2b19a136ec1fb9462d6 Mon Sep 17 00:00:00 2001 From: arookieofc <2128194521@qq.com> Date: Mon, 2 Nov 2026 14:33:14 +0800 Subject: [PATCH 11/11] fix(vcs): fall back to plain blame when git rejects --contents with a revision (#787) Git before 2.41 refuses `blame --contents - ` ("cannot use --contents with final commit object name"), so blame failed outright on e.g. Ubuntu 22.04's git 2.34. On that error, blame the revision plainly and mark the edited lines as uncommitted from the diff against it, the same path restricted mode already uses. Fixes #755. Co-authored-by: a-rookie-of-C-language <186315048+a-rookie-of-C-language@users.noreply.github.com> --- crates/ide-vcs/src/write.rs | 31 ++++++++++++++++++++++--------- 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/crates/ide-vcs/src/write.rs b/crates/ide-vcs/src/write.rs index e31701be..4e4f9283 100644 --- a/crates/ide-vcs/src/write.rs +++ b/crates/ide-vcs/src/write.rs @@ -89,15 +89,28 @@ pub(crate) fn blame( return Ok(uncommitted_lines(line_count)); } // Restricted blame reads committed blobs only. Feeding working-tree bytes to - // Git's --contents path would run configured clean filters. - let out = if crate::execution_allowed() { - run( - r.root(), - &["--literal-pathspecs", "blame", "--porcelain", "--contents", "-", revision, "--", &rel], - Some(&contents), - )? + // Git's --contents path would run configured clean filters, so it blames the + // revision plain. Git learned to combine `--contents` with a revision in 2.41; + // older releases reject the pair (`fatal: cannot use --contents with final + // commit object name`) and take the same plain path — the edited lines are + // marked uncommitted below either way (issue #755). + let contents_args = ["--literal-pathspecs", "blame", "--porcelain", "--contents", "-", revision, "--", &rel]; + let plain_args: &[&str] = if crate::execution_allowed() { + &["--literal-pathspecs", "blame", "--porcelain", revision, "--", &rel] } else { - run(r.root(), &["--literal-pathspecs", "blame", "--no-textconv", "--porcelain", revision, "--", &rel], None)? + &["--literal-pathspecs", "blame", "--no-textconv", "--porcelain", revision, "--", &rel] + }; + let (out, contents_blame) = if crate::execution_allowed() { + let out = cli::run_raw(r.root(), &contents_args, Some(&contents))?; + if out.code == Some(0) { + (out, true) + } else if String::from_utf8_lossy(&out.stderr).contains("cannot use --contents") { + (run(r.root(), plain_args, None)?, false) + } else { + return Err(cli::failure(&contents_args, &out.stderr, &out.stdout, out.code)); + } + } else { + (run(r.root(), plain_args, None)?, false) }; let mut lines = crate::blame::parse_porcelain(&out.stdout_str()); for line in &mut lines { @@ -106,7 +119,7 @@ pub(crate) fn blame( line.summary = String::new(); } } - if !crate::execution_allowed() { + if !contents_blame { let base = r.file_at(path, Some(revision), encoding)?.unwrap_or_default(); for hunk in crate::diff::line_hunks(&base, &text).into_iter().rev() { let start = (hunk.old_start as usize).min(lines.len());