From 743612d4ec3adc6a16ac660f180e8c5643a276d3 Mon Sep 17 00:00:00 2001 From: misswell Date: Mon, 2 Nov 2026 12:05:25 +0800 Subject: [PATCH 001/106] 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 21b217dc0..814ccf7db 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 002/106] 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 64fb58fbe..3b31a576f 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 e2eb8a2ae..7c4a2d955 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 9f0251cbf..9d1e81e01 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 bcecc965e..059b6c139 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 225095a0e..db0875711 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 000000000..e49cb3de8 --- /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 801985175..9e74b30c3 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 000000000..047b3136c --- /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 b2c2c83b0..742e797d0 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 811644d75..844d50811 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 1721e2ca7..d12da7d58 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 428714803..559d20d9b 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 003/106] 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 95dd011b3..b6a7a692b 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 004/106] 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 e31701bea..4e4f92832 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()); From 193e6a4840c928f6d90b3ba4ddba6afca7476c6a Mon Sep 17 00:00:00 2001 From: ntbang0901 <121003585+ntbang0901@users.noreply.github.com> Date: Mon, 2 Nov 2026 13:57:07 +0700 Subject: [PATCH 005/106] feat(run): colour the console of a Spring Boot run (#781) Spring Boot configurations get -Dspring.output.ansi.enabled=always: the Run console renders ANSI colours, but Spring only enables them for a terminal and the process runs on pipes. A configuration that sets the property keeps its value. Co-authored-by: ntbang0901 <121003585+ntbang0901@users.noreply.github.com> --- src/features/run/java.test.ts | 15 +++++++++++++++ src/platform/javaLaunch.ts | 13 ++++++++++++- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/src/features/run/java.test.ts b/src/features/run/java.test.ts index 3aae4705c..586f3f8cb 100644 --- a/src/features/run/java.test.ts +++ b/src/features/run/java.test.ts @@ -252,6 +252,21 @@ describe("java command lines", () => { ).toEqual(["--module-path", "/m/x.jar"]); }); + it("colours a Spring Boot console unless the configuration sets the property", () => { + const boot = (args: string[]) => ({ + ...applicationConfig({ className: "a.Main" }, "src/main/java/a/Main.java"), + kind: "spring-boot" as const, + args, + }); + expect(resolveJavaCommand(boot(["-Xmx1g", "a.Main"]), info).args[0]).toBe("-Dspring.output.ansi.enabled=always"); + expect(resolveJavaCommand(boot(["-Dspring.output.ansi.enabled=never", "a.Main"]), info).args).toEqual([ + "-Dspring.output.ansi.enabled=never", + "a.Main", + ]); + const plain = applicationConfig({ className: "a.Main" }, "src/main/java/a/Main.java"); + expect(resolveJavaCommand(plain, info).args).not.toContain("-Dspring.output.ansi.enabled=always"); + }); + it("joins arguments for java-debug and reads JDWP's port", () => { expect(joinJavaArgs(["a", "b c", 'q"'])).toBe('a "b c" "q\\""'); expect(jdwpListeningPort("Listening for transport dt_socket at address: 50123")).toBe(50123); diff --git a/src/platform/javaLaunch.ts b/src/platform/javaLaunch.ts index 91d9cf6d5..75c57eefb 100644 --- a/src/platform/javaLaunch.ts +++ b/src/platform/javaLaunch.ts @@ -71,10 +71,21 @@ export function parseJavaArgs(args: readonly string[]): JavaCommandLine { return { vmArgs, mainClass: null, programArgs: [], classpath, explicitClasspath }; } +/** + * VM options a launch adds for the console, as IDEA's Spring Boot configurations do: the + * console renders ANSI colours, but Spring only detects a terminal, and the process runs on pipes. + * Kept out when the configuration sets the property itself. + */ +export function consoleVmArgs(config: Pick): string[] { + if (config.kind !== "spring-boot") return []; + const own = parseJavaArgs(config.args).vmArgs.some((a) => a.startsWith("-Dspring.output.ansi.enabled=")); + return own ? [] : ["-Dspring.output.ansi.enabled=always"]; +} + /** The configuration with its placeholders and launcher resolved. */ export function resolveJavaCommand(config: RunConfig, info: JavaLaunchInfo): RunConfig { const cp = info.classPaths.join(info.pathListSeparator); - const args: string[] = []; + const args: string[] = [...consoleVmArgs(config)]; if (info.modulePaths.length && !config.args.some((a) => a === "-p" || a === "--module-path")) args.push("--module-path", info.modulePaths.join(info.pathListSeparator)); args.push(...config.args.map((a) => (a === CLASSPATH || a === TEST_CLASSPATH ? cp : a))); From 47f68862c8bd6086556c652dcbc0fa3c427989e1 Mon Sep 17 00:00:00 2001 From: ntbang0901 <121003585+ntbang0901@users.noreply.github.com> Date: Mon, 2 Nov 2026 13:57:11 +0700 Subject: [PATCH 006/106] feat(project-view): restore the persisted expansion state as the user left it (#782) The Project view restored only expanded paths inside the project roots and always re-expanded the roots. Restore the persisted state as is (a collapsed root stays collapsed, nodes outside it such as content roots stay open); only a first open expands the roots. Co-authored-by: ntbang0901 <121003585+ntbang0901@users.noreply.github.com> --- src/features/project-tree/store.test.ts | 8 ++++++++ src/features/project-tree/store.ts | 11 ++++++----- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/src/features/project-tree/store.test.ts b/src/features/project-tree/store.test.ts index 032924c3e..8d60d4d89 100644 --- a/src/features/project-tree/store.test.ts +++ b/src/features/project-tree/store.test.ts @@ -68,6 +68,14 @@ describe("lazy loading", () => { expect([...useProjectTree.getState().expanded].sort()).toEqual(["/p", "/p/docs"]); }); + it("keeps a collapsed project root collapsed and an expanded node outside it open", async () => { + setExpanded(new Set(["/other/lib"])); + await new Promise((r) => setTimeout(r, 350)); + initProjectTree(null); + initProjectTree(ROOT, "proj"); + expect([...useProjectTree.getState().expanded]).toEqual(["/other/lib"]); + }); + it("drops a folder that vanished and collapses it", async () => { await loadDir(ROOT); setExpanded(new Set([ROOT, "/p/gone"])); diff --git a/src/features/project-tree/store.ts b/src/features/project-tree/store.ts index 3d90bfcb2..cca259ea9 100644 --- a/src/features/project-tree/store.ts +++ b/src/features/project-tree/store.ts @@ -301,11 +301,12 @@ function initialExpanded(root: string | null, mode: ViewMode, members = get().me r.scope === "scratches" && r.path ? [r.path] : [], ), ); - const persisted = mode === "project" ? (readJson(expandedKey(root)) ?? []) : []; - const roots = members ?? [root]; - const expanded = new Set(persisted.filter((p) => roots.some((r) => isSameOrInside(p, r)))); - roots.forEach((r) => expanded.add(r)); - return expanded; + // The tree as the user left it, like IDEA: a collapsed project root stays collapsed, and + // nodes outside it (content roots, contributed roots) stay open. + // Keys of nodes that no longer exist are inert. Only a first open expands the roots. + const persisted = mode === "project" ? readJson(expandedKey(root)) : null; + if (persisted) return new Set(persisted); + return new Set(members ?? [root]); } function persistExpanded() { From a8e12ff7c42784d3b9d2cecf3916f72c8dafec34 Mon Sep 17 00:00:00 2001 From: ntbang0901 <121003585+ntbang0901@users.noreply.github.com> Date: Mon, 2 Nov 2026 13:57:15 +0700 Subject: [PATCH 007/106] feat(run): reuse the configuration of a main class run from its context (#783) Running or debugging a main class from the editor, gutter or Project view now selects an existing saved Application or Spring Boot configuration of the same main class and module, keeping its VM options, environment and profiles, instead of creating a temporary one. Co-authored-by: ntbang0901 <121003585+ntbang0901@users.noreply.github.com> --- src/features/run/java.test.ts | 19 +++++++++++++++++++ src/features/run/store.ts | 12 +++++++++++- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/src/features/run/java.test.ts b/src/features/run/java.test.ts index 586f3f8cb..161e1017a 100644 --- a/src/features/run/java.test.ts +++ b/src/features/run/java.test.ts @@ -395,6 +395,25 @@ describe("temporary configurations", () => { expect(useRun.getState().temporary).toEqual([]); }); + it("reuse a configuration of the same main class and module, whatever its id", () => { + const own = (module: string) => + cfg({ + id: `user:${module}`, + kind: "spring-boot", + name: `Mine ${module}`, + args: ["-Xmx1g", "-cp", "${classpath}", "a.Main"], + module, + detected: false, + }); + useRun.setState({ configs: [own("other"), own("app")] }); + expect(adoptTemporary(applicationConfig({ className: "a.Main" }, "app/src/main/java/a/Main.java")).name).toBe( + "Mine app", + ); + expect(useRun.getState().temporary).toEqual([]); + const fresh = adoptTemporary(applicationConfig({ className: "a.Other" }, "app/src/main/java/a/Other.java")); + expect(useRun.getState().temporary).toEqual([fresh.id]); + }); + it("offer Save in the chooser while selected", async () => { const app = adoptTemporary(applicationConfig({ className: "a.Main" }, "src/main/java/a/Main.java")); const { configs, temporary } = useRun.getState(); diff --git a/src/features/run/store.ts b/src/features/run/store.ts index 38a3c1b3c..3d7f35529 100644 --- a/src/features/run/store.ts +++ b/src/features/run/store.ts @@ -256,6 +256,13 @@ function persistTemporary() { ); } +/** IDEA's `isConfigurationFromContext` for an Application: the same main class of the same module. */ +function sameApplication(a: RunConfig, b: RunConfig): boolean { + if (!isJvmApplication(a) || !isJvmApplication(b)) return false; + const main = parseJavaArgs(a.args).mainClass; + return main !== null && main === parseJavaArgs(b.args).mainClass && (a.module ?? a.cwd) === (b.module ?? b.cwd); +} + /** * Selects the configuration for a gutter / context run: a saved or detected * configuration with the same id is reused, otherwise `config` becomes a @@ -263,7 +270,10 @@ function persistTemporary() { */ export function adoptTemporary(config: RunConfig): RunConfig { const state = useRun.getState(); - const existing = state.configs.find((c) => c.id === config.id); + // IDEA's `findExistingConfiguration`: a configuration the user keeps for this context wins. + const existing = + state.configs.find((c) => c.id === config.id) ?? + state.configs.find((c) => !state.temporary.includes(c.id) && sameApplication(c, config)); if (existing && !state.temporary.includes(config.id)) { selectConfig(existing.id); return existing; From 7375f30422ab7367a09705b63904b85a17c06409 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Mon, 2 Nov 2026 14:58:23 +0800 Subject: [PATCH 008/106] fix(vcs): keep merge editor panes scrollable while they scroll together (#758) Scrolling a pane set the other two panes' scrollTop; their scroll events arrived a frame later, after the sync guard was cleared, were taken as user scrolls and written back, so the pane being scrolled was pulled back. Sync now maps positions by content and ignores the echoes of its own writes; a pane that cannot scroll no longer drags the others. Next/Previous Difference (F7 / Shift+F7) go through the keymap instead of a fixed key handler. Fixes #744. --- src/features/vcs/MergeEditor.tsx | 92 ++++++++++------- .../vcs/__tests__/mergeScroll.test.ts | 28 ++++++ src/features/vcs/diffScroll.ts | 4 +- src/features/vcs/mergeScroll.ts | 98 +++++++++++++++++++ 4 files changed, 186 insertions(+), 36 deletions(-) create mode 100644 src/features/vcs/__tests__/mergeScroll.test.ts create mode 100644 src/features/vcs/mergeScroll.ts diff --git a/src/features/vcs/MergeEditor.tsx b/src/features/vcs/MergeEditor.tsx index 4c899b98f..b04e51675 100644 --- a/src/features/vcs/MergeEditor.tsx +++ b/src/features/vcs/MergeEditor.tsx @@ -8,13 +8,17 @@ import { keymap } from "@codemirror/view"; import { unifiedMergeView } from "@codemirror/merge"; import type { MergeSides } from "@/bindings"; import { embeddedEditorExtensions } from "@/editor/api"; +import { setContext } from "@/platform/context"; import { tx, useTxTemplate } from "@/platform/i18n"; import { call, commands } from "@/platform/ipc"; +import { shortcutLabel } from "@/platform/keymap"; import { Button, IconButton, showContextMenu, Spinner, T } from "@/ui"; import { editorService, type EditorInput } from "@/workbench/contributions"; import { blameSlot, toggleAnnotations } from "./blame"; import { languageFor, viewerBase } from "./cm"; import { confirmDialog } from "./dialogs"; +import { focusedDiffNavigator, setFocusedDiffNavigator, type DiffNavigator } from "./diffNavigator"; +import { syncMergeScroll } from "./mergeScroll"; import { initialResult, mergeTitles, resolutionText, type Resolution, type ResultConflict } from "./conflicts"; import { baseName } from "./model"; import { errorMessage, readWorkingText, runOp, writeFileText } from "./ops"; @@ -220,28 +224,10 @@ export function MergeEditor({ input }: { input: EditorInput }) { setLeft(result.state.field(conflictField).conflicts.length); oursView.current = ours; theirsView.current = theirs; - // Scroll the three panes together (by scroll ratio), like IDEA's merge view. - const views = [ours, result, theirs]; - let syncing = false; - const onScroll = (from: EditorView) => () => { - if (syncing) return; - syncing = true; - const s = from.scrollDOM; - const ratio = s.scrollTop / Math.max(1, s.scrollHeight - s.clientHeight); - for (const other of views) { - if (other === from) continue; - const o = other.scrollDOM; - o.scrollTop = ratio * Math.max(0, o.scrollHeight - o.clientHeight); - } - syncing = false; - }; - const offs = views.map((v) => { - const h = onScroll(v); - v.scrollDOM.addEventListener("scroll", h, { passive: true }); - return () => v.scrollDOM.removeEventListener("scroll", h); - }); + // Scroll the three panes together, lined up through their changes. + const stopSync = syncMergeScroll({ ours, result, theirs }); return () => { - offs.forEach((off) => off()); + stopSync(); ours.destroy(); theirs.destroy(); result.destroy(); @@ -313,17 +299,31 @@ export function MergeEditor({ input }: { input: EditorInput }) { } }; - // IDEA's merge view: F7 / Shift+F7 jump between conflicts; each side pane can - // annotate its version with Git Blame from the context menu. - // Each side pane blames on the branch it belongs to, so lines that only exist on - // the other branch show their real history instead of "Not Committed Yet". + // Next / Previous Difference (F7 / Shift+F7 by default) jump between conflicts through + // the keymap, as in the diff viewer. + // `goto` reads only refs, so the first render's one stays valid. + const [nav] = useState(() => ({ + next: () => goto(1), + prev: () => goto(-1), + jumpToSource: () => void editorService().openFile(path, { focus: true }), + applyChange: () => false, + file: () => {}, + showSettings: () => {}, + })); + useEffect( + () => () => { + if (focusedDiffNavigator() === nav) { + setFocusedDiffNavigator(null); + setContext("diffEditorFocus", false); + } + }, + [nav], + ); + + // Each side pane can annotate its version with Git Blame from the context menu, on + // the branch it belongs to, so lines that only exist on the other branch show their + // real history instead of "Not Committed Yet". const otherRev = state?.sides.commit ?? "HEAD"; - const onKeyDown = (e: React.KeyboardEvent) => { - if (e.key === "F7") { - e.preventDefault(); - goto(e.shiftKey ? -1 : 1); - } - }; const sideMenu = (e: React.MouseEvent, viewRef: { current: EditorView | null }, text: string, revision?: string) => { const view = viewRef.current; @@ -358,10 +358,34 @@ export function MergeEditor({ input }: { input: EditorInput }) { ); return ( -
+
{ + setFocusedDiffNavigator(nav); + setContext("diffEditorFocus", true); + }} + onBlurCapture={(e) => { + if (!e.currentTarget.contains(e.relatedTarget as Node | null) && focusedDiffNavigator() === nav) { + setFocusedDiffNavigator(null); + setContext("diffEditorFocus", false); + } + }} + >
- goto(-1)} /> - goto(1)} /> + goto(-1)} + /> + goto(1)} + />
); })} diff --git a/src/features/run/useConsoleSelection.test.tsx b/src/features/run/useConsoleSelection.test.tsx new file mode 100644 index 000000000..9e0058cf9 --- /dev/null +++ b/src/features/run/useConsoleSelection.test.tsx @@ -0,0 +1,94 @@ +import { act, fireEvent, renderHook } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { useConsoleSelection } from "./useConsoleSelection"; + +const viewport = { startIndex: 100, endIndex: 110, overscan: 2, count: 500 }; + +afterEach(() => { + window.getSelection()?.removeAllRanges(); + document.body.replaceChildren(); +}); + +function setup() { + const host = document.createElement("div"); + document.body.append(host); + const rows = [5, 6, 7].map((index) => { + const row = document.createElement("div"); + row.dataset.index = String(index); + row.textContent = `output ${index}`; + host.append(row); + return row; + }); + const ref = { current: host }; + const pause = vi.fn(); + const hook = renderHook(({ pause }) => useConsoleSelection(ref, pause), { initialProps: { pause } }); + const select = (backward = false) => { + act(() => { + const [anchor, focus] = backward ? [rows[2]!, rows[0]!] : [rows[0]!, rows[2]!]; + window.getSelection()!.setBaseAndExtent(anchor.firstChild!, 1, focus.firstChild!, 3); + document.dispatchEvent(new Event("selectionchange")); + }); + }; + return { ...hook, host, rows, pause, select }; +} + +describe("console selection retention", () => { + it.each([false, true])("keeps the full selected interval mounted (backward=%s)", (backward) => { + const { result, select, pause } = setup(); + select(backward); + const indices = result.current.rangeExtractor(viewport); + expect(indices.slice(0, 3)).toEqual([5, 6, 7]); + expect(indices).not.toContain(50); + expect(indices).toContain(100); + expect(pause).toHaveBeenCalled(); + }); + + it("renders the intervening text when dragging beyond the viewport", () => { + const { result, rows, select } = setup(); + fireEvent.mouseDown(rows[0]!, { button: 0 }); + select(); + const indices = result.current.rangeExtractor(viewport); + expect(indices[0]).toBe(5); + expect(indices).toHaveLength(108); + fireEvent.mouseUp(document); + expect(result.current.rangeExtractor(viewport)).not.toContain(50); + expect(result.current.rangeExtractor(viewport)).toContain(5); + }); + + it("releases pinned rows when the selection is cleared or moved outside the console", () => { + const { result, select } = setup(); + select(); + act(() => { + window.getSelection()!.removeAllRanges(); + document.dispatchEvent(new Event("selectionchange")); + }); + expect(result.current.selecting).toBe(false); + expect(result.current.rangeExtractor(viewport)[0]).toBe(98); + select(); + const other = document.createTextNode("other output"); + document.body.append(other); + act(() => { + window.getSelection()!.setBaseAndExtent(other, 0, other, 5); + document.dispatchEvent(new Event("selectionchange")); + }); + expect(result.current.selecting).toBe(false); + expect(result.current.rangeExtractor(viewport)[0]).toBe(98); + }); + + it("keeps a drag active when incoming output rerenders the parent", () => { + const { result, rows, select, rerender } = setup(); + fireEvent.mouseDown(rows[0]!, { button: 0 }); + const pause = vi.fn(); + rerender({ pause }); + select(); + expect(result.current.rangeExtractor(viewport)).toContain(50); + expect(pause).toHaveBeenCalled(); + }); + + it("does not pin rows or pause output for a context click", () => { + const { result, rows, pause } = setup(); + fireEvent.mouseDown(rows[0]!, { button: 2 }); + expect(result.current.selecting).toBe(false); + expect(pause).not.toHaveBeenCalled(); + }); +}); diff --git a/src/features/run/useConsoleSelection.ts b/src/features/run/useConsoleSelection.ts new file mode 100644 index 000000000..6fa897240 --- /dev/null +++ b/src/features/run/useConsoleSelection.ts @@ -0,0 +1,73 @@ +import { useCallback, useEffect, useEffectEvent, useState, type RefObject } from "react"; +import { defaultRangeExtractor, type Range } from "@tanstack/react-virtual"; + +type SelectedRows = { start: number; end: number; dragging: boolean }; + +/** Keep the browser's selection endpoints, and the text between them, mounted. */ +export function useConsoleSelection(host: RefObject, pauseFollow: () => void) { + const pause = useEffectEvent(pauseFollow); + const [selected, setSelected] = useState(null); + + useEffect(() => { + const el = host.current; + if (!el) return; + let dragging = false; + const indexOf = (node: Node | null) => { + const element = node instanceof Element ? node : node?.parentElement; + const row = element?.closest("[data-index]"); + return row && el.contains(row) ? Number(row.dataset.index) : null; + }; + const update = () => { + const selection = window.getSelection(); + const anchor = indexOf(selection?.anchorNode ?? null); + const focus = indexOf(selection?.focusNode ?? null); + const next = + selection && (!selection.isCollapsed || dragging) && anchor !== null && focus !== null + ? { start: Math.min(anchor, focus), end: Math.max(anchor, focus), dragging } + : null; + setSelected((prev) => + prev?.start === next?.start && prev?.end === next?.end && prev?.dragging === next?.dragging ? prev : next, + ); + if (next && !selection!.isCollapsed) pause(); + }; + const down = (event: MouseEvent) => { + if (event.button !== 0) return; + const index = indexOf(event.target as Node); + if (index === null) return; + dragging = true; + // Pin the anchor before a scroll can recycle it, even before selectionchange fires. + setSelected({ start: index, end: index, dragging: true }); + pause(); + }; + const up = () => { + dragging = false; + update(); + }; + el.addEventListener("mousedown", down); + document.addEventListener("mouseup", up); + document.addEventListener("selectionchange", update); + return () => { + el.removeEventListener("mousedown", down); + document.removeEventListener("mouseup", up); + document.removeEventListener("selectionchange", update); + }; + }, [host]); + + const rangeExtractor = useCallback( + (range: Range) => { + const visible = defaultRangeExtractor(range); + if (!selected || !visible.length) return visible; + // During a drag, scrolling must also render every line up to the pointer. + const start = Math.max(0, selected.dragging ? Math.min(selected.start, visible[0]!) : selected.start); + const end = Math.min( + range.count - 1, + selected.dragging ? Math.max(selected.end, visible[visible.length - 1]!) : selected.end, + ); + const indices = new Set(visible); + for (let i = start; i <= end; i++) indices.add(i); + return [...indices].sort((a, b) => a - b); + }, + [selected], + ); + return { rangeExtractor, selecting: selected !== null }; +} From 362ae3535cc0ee27cc5cc43e47e67689065620fb Mon Sep 17 00:00:00 2001 From: kkmihai Date: Mon, 2 Nov 2026 14:15:42 +0200 Subject: [PATCH 014/106] fix(trust): keep LightEdit sessions restricted (#857) LightEdit created its project as trusted and refresh_project_trust skips light projects, so opening a loose notebook detected Python environments in the file's folder and could run a repository-shipped .venv/bin/python without a trust prompt. LightEdit now starts restricted and stays restricted when folder trust changes, since one session can hold files from unrelated folders; notebooks still open and save, while interpreter discovery, kernel execution and external formatting are refused. Opening the folder as a trusted project enables execution as before. Fixes #849 Co-authored-by: kkMihai <59262001+kkMihai@users.noreply.github.com> --- crates/ide-host/src/project.rs | 2 +- crates/ide-host/tests/light_edit.rs | 101 ++++++++++++++++++++++++++++ 2 files changed, 102 insertions(+), 1 deletion(-) diff --git a/crates/ide-host/src/project.rs b/crates/ide-host/src/project.rs index faf28db01..0e00ae8b9 100644 --- a/crates/ide-host/src/project.rs +++ b/crates/ide-host/src/project.rs @@ -513,7 +513,7 @@ impl Host { sink.clone(), ); let project = Arc::new(Project { - trusted: Arc::new(AtomicBool::new(true)), + trusted: Arc::new(AtomicBool::new(false)), sink, languages, settings, diff --git a/crates/ide-host/tests/light_edit.rs b/crates/ide-host/tests/light_edit.rs index 1eff9e388..99f2e96c2 100644 --- a/crates/ide-host/tests/light_edit.rs +++ b/crates/ide-host/tests/light_edit.rs @@ -78,3 +78,104 @@ async fn light_windows_ignore_project_scoped_settings() { assert!(!applied.project_scope); assert!(!dir.join(".openidea").exists(), "no project settings next to the file"); } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn light_windows_stay_restricted_when_folder_trust_changes() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join("loose"); + let other = tmp.path().join("other"); + std::fs::create_dir_all(&root).unwrap(); + std::fs::create_dir_all(&other).unwrap(); + std::fs::write(root.join("one.py"), "print(1)\n").unwrap(); + std::fs::write(other.join("two.js"), "const value = 1;\n").unwrap(); + let host = host(&tmp.path().join("app")); + host.global_settings + .set(ide_workspace::SettingsScope::Global, "languages.prettier.prefer", serde_json::json!(true)) + .unwrap(); + let light = host.open_light("main", &root.join("one.py")).unwrap(); + assert!(!light.is_trusted()); + assert!(light.require_trust().is_err()); + + host.set_project_trust(&root, true, false).await.unwrap(); + assert!(!light.is_trusted()); + let trusted_folder = host.open_light("second", &root.join("one.py")).unwrap(); + assert!(!trusted_folder.is_trusted()); + let again = host.open_light("main", &other.join("two.js")).unwrap(); + assert!(Arc::ptr_eq(&light, &again)); + assert!(!again.is_trusted()); + host.set_project_trust(&root, true, true).await.unwrap(); + assert!(!light.is_trusted()); + assert!(!trusted_folder.is_trusted()); + for command in ["sdk_detect", "sdk_locate_tools", "notebook_execute", "notebook_restart", "run_start"] { + assert!(host.authorize_command("main", command).is_err(), "{command}"); + } + + let doc = ops::doc::open(&host, "main", other.join("two.js")).await.unwrap(); + let error = ops::lsp::lsp_format( + &host, + "main", + doc.id, + None, + ide_lsp::types::FormatOptions { tab_size: 2, insert_spaces: true, on_save: false }, + ) + .await + .unwrap_err(); + assert_eq!(error.kind, ide_core::error::ErrorKind::Unavailable); + assert!(error.message.contains("Restricted Mode")); + + host.set_project_trust(&root, false, false).await.unwrap(); + assert!(!light.is_trusted()); + assert!(!trusted_folder.is_trusted()); + host.set_project_trust(&root, true, false).await.unwrap(); + let project = host.open_project("main", &root).unwrap(); + assert!(!project.light); + assert!(project.is_trusted()); + assert!(project.require_trust().is_ok()); + host.shutdown().await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn light_notebooks_open_without_interpreter_detection_or_execution() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join("loose"); + let bin = root.join(".venv").join(if cfg!(windows) { "Scripts" } else { "bin" }); + std::fs::create_dir_all(&bin).unwrap(); + std::fs::write(root.join(".venv/pyvenv.cfg"), "home = python\n").unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let python = bin.join("python"); + std::fs::write(&python, "#!/bin/sh\nprintf probed > \"$0.probed\"\nprintf 'Python 3.12.0\\n'\n").unwrap(); + std::fs::set_permissions(&python, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + let file = root.join("analysis.ipynb"); + std::fs::write( + &file, + r#"{"cells":[{"id":"cell","cell_type":"code","source":["print(1)"],"metadata":{},"outputs":[],"execution_count":null}],"metadata":{},"nbformat":4,"nbformat_minor":5}"#, + ) + .unwrap(); + let host = host(&tmp.path().join("app")); + host.open_light("main", &file).unwrap(); + let notebook = ops::notebook::open(&host, "main", file.to_string_lossy().into()).await.unwrap(); + assert!(ops::notebook::save(&host, "main", notebook.id.clone()).await.is_ok()); + let error = ops::sdk::sdk_detect(&host, "main", ide_sdk::SdkKind::Python).await.unwrap_err(); + assert!(error.message.contains("Restricted Mode")); + assert!(ops::sdk::sdk_python_effective(&host, "main").is_err()); + assert!(ops::sdk::sdk_locate_tools(&host, "main", vec!["prettier".into()], Some(root)).await.is_err()); + let error = ops::notebook::restart(&host, "main", notebook.id.clone()).await.unwrap_err(); + assert!(error.message.contains("Restricted Mode")); + let error = ops::notebook::execute( + &host, + "main", + notebook.id, + vec![notebook.cells[0].id.clone()], + None, + ide_core::RequestId(849), + ops::Channel::new(|_| {}), + ) + .await + .unwrap_err(); + assert!(error.message.contains("Restricted Mode")); + assert!(!bin.join("python.probed").exists()); + host.shutdown().await; +} From 78b23df7b5da24df6b707f11db4bb72cda9a3864 Mon Sep 17 00:00:00 2001 From: misswell Date: Mon, 2 Nov 2026 20:15:51 +0800 Subject: [PATCH 015/106] fix(host): close a project's background processes with it (#852) Closing a project only cancelled its agents and notebooks; its window's requests, run processes, terminal shells and debug sessions kept running until app exit, and the language server shutdown was a detached task a closing app could drop (orphaned jdtls). Closing now cancels the window's requests, kills its run processes and shells, stops its debug sessions and waits for the language servers to shut down; other windows are untouched. Terminals are keyed by owner window so they can be closed per window. Co-authored-by: misswell <28730815+misswell@users.noreply.github.com> --- crates/ide-dap/src/lib.rs | 10 +++++ crates/ide-host/src/lib.rs | 23 +++++++++- crates/ide-host/src/ops/term.rs | 3 +- crates/ide-host/src/trust.rs | 4 +- crates/ide-host/tests/close_project.rs | 54 ++++++++++++++++++++++++ crates/ide-host/tests/terminal_images.rs | 1 + crates/ide-terminal/src/lib.rs | 16 ++++++- 7 files changed, 104 insertions(+), 7 deletions(-) create mode 100644 crates/ide-host/tests/close_project.rs diff --git a/crates/ide-dap/src/lib.rs b/crates/ide-dap/src/lib.rs index 638ec8d38..5b8786a14 100644 --- a/crates/ide-dap/src/lib.rs +++ b/crates/ide-dap/src/lib.rs @@ -1389,6 +1389,16 @@ impl DebugManager { }) } + /// Stops every session `key` started: the window that opened the project is closing. + pub async fn stop_key(&self, key: &str) { + let ids: Vec = self.sessions(Some(key)).into_iter().map(|s| s.id).collect(); + for id in ids { + if let Err(e) = self.stop(id).await { + tracing::debug!("debug session {id:?} did not stop: {e}"); + } + } + } + /// Stops a session and its child sessions. pub async fn stop(&self, s: DebugSessionId) -> IdeResult<()> { let session = self.session(s)?; diff --git a/crates/ide-host/src/lib.rs b/crates/ide-host/src/lib.rs index 66e21c9b7..d5fef1e5f 100644 --- a/crates/ide-host/src/lib.rs +++ b/crates/ide-host/src/lib.rs @@ -176,12 +176,21 @@ impl Host { } project.agents.shutdown(); project.notebooks.stop_admission(); + // What the closing window started goes with it — the builds and searches it + // asked for, its runs, its shells and its debug sessions — and the language + // servers finish shutting down before the window is gone, as IDEA closes a + // project's processes. + for request in self.project_requests.write().remove(self.owner_window(window)).unwrap_or_default() { + self.cancel_request(request); + } + self.processes.kill_project(&project.root); + self.terminals.kill_key(self.owner_window(window)); self.shutdown_project_workers_blocking( vec![project.notebooks.clone()], project.kotlin_bridge.iter().cloned().collect(), + Some(project.lsp.clone()), + Some((self.debugger.clone(), window.to_owned())), ); - let lsp = project.lsp.clone(); - self.runtime.spawn(async move { lsp.shutdown_all().await }); } } @@ -191,6 +200,8 @@ impl Host { &self, notebooks: Vec>, bridges: Vec>, + lsp: Option>, + debug: Option<(Arc, String)>, ) { for bridge in &bridges { bridge.stop(); @@ -208,6 +219,12 @@ impl Host { for service in notebooks { service.shutdown().await; } + if let Some(lsp) = lsp { + lsp.shutdown_all().await; + } + if let Some((debug, key)) = debug { + debug.stop_key(&key).await; + } }); }); if let Err(error) = worker.join() { @@ -229,6 +246,8 @@ impl Host { self.shutdown_project_workers_blocking( projects.iter().map(|project| project.notebooks.clone()).collect(), projects.iter().filter_map(|project| project.kotlin_bridge.clone()).collect(), + None, + None, ); } diff --git a/crates/ide-host/src/ops/term.rs b/crates/ide-host/src/ops/term.rs index c7a284c9f..02a340266 100644 --- a/crates/ide-host/src/ops/term.rs +++ b/crates/ide-host/src/ops/term.rs @@ -40,7 +40,8 @@ pub fn spawn( .ok() .filter(|p| p.is_trusted()) .and_then(|p| ide_sdk::apply::terminal_activation(&p.root, &p.settings.effective(), &shell)); - let info = host.terminals.spawn(options, host.window_sink(window), Box::new(move |chunk| on_output(chunk)))?; + let key = host.owner_window(window).to_owned(); + let info = host.terminals.spawn(options, key, host.window_sink(window), Box::new(move |chunk| on_output(chunk)))?; if let Some(line) = activation { let _ = host.terminals.write(info.id, &format!(" {line}\r")); } diff --git a/crates/ide-host/src/trust.rs b/crates/ide-host/src/trust.rs index 7e8522e10..5fd072bba 100644 --- a/crates/ide-host/src/trust.rs +++ b/crates/ide-host/src/trust.rs @@ -135,9 +135,7 @@ impl crate::Host { for request in requests { self.cancel_request(request); } - for session in self.debugger.sessions(Some(&window)) { - let _ = self.debugger.stop(session.id).await; - } + self.debugger.stop_key(&window).await; } } project.lsp.reconfigure().await; diff --git a/crates/ide-host/tests/close_project.rs b/crates/ide-host/tests/close_project.rs new file mode 100644 index 000000000..e0b07cd11 --- /dev/null +++ b/crates/ide-host/tests/close_project.rs @@ -0,0 +1,54 @@ +//! Closing a project takes the background processes of its window with it, as IDEA does, +//! and leaves the other windows' processes alone. +#![cfg(unix)] + +use ide_core::NullSink; +use ide_host::{Host, HostConfig, ops}; +use std::sync::Arc; + +fn options(root: &std::path::Path) -> ide_terminal::TerminalOptions { + ide_terminal::TerminalOptions { + cwd: root.to_path_buf().into(), + // A fixed shell: the test must not depend on the login shell of this machine. + shell: Some("/bin/sh".into()), + args: vec![], + env: Default::default(), + cols: 80, + rows: 24, + shell_integration: false, + } +} + +#[test] +fn closing_a_project_closes_its_terminals_and_keeps_other_windows() { + let tmp = tempfile::tempdir().unwrap(); + let root = dunce::canonicalize(tmp.path()).unwrap().join("project"); + std::fs::create_dir_all(&root).unwrap(); + let runtime = tokio::runtime::Builder::new_multi_thread().worker_threads(2).enable_all().build().unwrap(); + runtime.block_on(async { + let host = Arc::new(Host::new(HostConfig { + config_dir: tmp.path().join("config"), + data_dir: tmp.path().join("data"), + local_data_dir: tmp.path().join("data"), + languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()), + syntax: Arc::default(), + sink_for: Box::new(|_| Arc::new(NullSink)), + owner: |w| w, + runtime: tokio::runtime::Handle::current(), + project_environment: false, + })); + host.trust.decide(&root, true, false).unwrap(); + host.open_project("main", &root).unwrap(); + let project_shell = + ops::term::spawn(&host, "main", options(&root), Arc::new(|_| {})).expect("project terminal"); + let other_shell = ops::term::spawn(&host, "other", options(&root), Arc::new(|_| {})).expect("other terminal"); + assert_eq!(host.terminals.list().len(), 2); + + host.close_project("main"); + + let left: Vec<_> = host.terminals.list().into_iter().map(|t| t.id).collect(); + assert!(!left.contains(&project_shell.id), "the closing window's shell is gone"); + assert!(left.contains(&other_shell.id), "another window keeps its shell"); + host.terminals.kill_key("other"); + }); +} diff --git a/crates/ide-host/tests/terminal_images.rs b/crates/ide-host/tests/terminal_images.rs index 13605357a..8bb4a3e11 100644 --- a/crates/ide-host/tests/terminal_images.rs +++ b/crates/ide-host/tests/terminal_images.rs @@ -35,6 +35,7 @@ async fn storage_requires_a_registered_terminal() { rows: 24, shell_integration: false, }, + "main".into(), Arc::new(NullSink), Box::new(|_| {}), ) diff --git a/crates/ide-terminal/src/lib.rs b/crates/ide-terminal/src/lib.rs index 82df70cde..21ae833cf 100644 --- a/crates/ide-terminal/src/lib.rs +++ b/crates/ide-terminal/src/lib.rs @@ -53,6 +53,8 @@ pub struct TerminalInfo { } struct Session { + /// The window that opened it: closing that window's project closes its terminals. + key: String, info: TerminalInfo, master: Mutex>, writer: Mutex>, @@ -83,6 +85,7 @@ impl TerminalManager { pub fn spawn( &self, options: TerminalOptions, + key: String, sink: SharedSink, on_output: Box, ) -> IdeResult { @@ -121,6 +124,7 @@ impl TerminalManager { let info = TerminalInfo { id, title: shell_title(&shell), pid: child.process_id() }; let exited = Arc::new(AtomicBool::new(false)); let session = Arc::new(Session { + key, info: info.clone(), master: Mutex::new(pair.master), writer: Mutex::new(writer), @@ -160,6 +164,15 @@ impl TerminalManager { .map_err(|e| IdeError::internal(format!("resize failed: {e}"))) } + /// Closes the terminals `key` opened: the project of that window is closing. + pub fn kill_key(&self, key: &str) { + let ids: Vec = + self.sessions.lock().iter().filter(|(_, s)| s.key == key).map(|(id, _)| *id).collect(); + for id in ids { + let _ = self.kill(id); + } + } + pub fn kill(&self, id: TerminalId) -> IdeResult<()> { let session = self.sessions.lock().remove(&id).ok_or_else(|| not_found(id))?; terminate(&session); @@ -636,6 +649,7 @@ mod tests { .manager .spawn( opts, + "test".into(), h.sink.clone(), Box::new(move |s| { let _ = tx.send(s); @@ -793,7 +807,7 @@ mod tests { let h = harness(); let (other_sink, other_events) = channel_sink(); let (mine, _rx) = spawn_collect(&h, sh("exit 1")); - let theirs = h.manager.spawn(sh("exit 2"), other_sink, Box::new(|_| {})).expect("spawn"); + let theirs = h.manager.spawn(sh("exit 2"), "other".into(), other_sink, Box::new(|_| {})).expect("spawn"); match h.events.recv_timeout(Duration::from_secs(10)).expect("exit event") { IdeEvent::TerminalExited { terminal, code } => assert_eq!((terminal, code), (mine.id, Some(1))), e => panic!("unexpected {e:?}"), From 502a9dc7dd7fe8db44cf0fee924aaaa0307bf54e Mon Sep 17 00:00:00 2001 From: ntbang0901 <121003585+ntbang0901@users.noreply.github.com> Date: Mon, 2 Nov 2026 19:15:59 +0700 Subject: [PATCH 016/106] fix(kotlin): compile a folder of sibling Maven projects in the Kotlin bridge (#842) compile::plan only fell back to Gradle when the folder had no build file, so a folder of sibling Maven projects failed with "no supported build system" in the Kotlin editor compilation and Build Project. It now falls back to Maven when the model has Maven projects, after the existing Gradle fallback. Fixes #814 Co-authored-by: ntbang0901 <121003585+ntbang0901@users.noreply.github.com> --- crates/ide-build/src/compile/mod.rs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/crates/ide-build/src/compile/mod.rs b/crates/ide-build/src/compile/mod.rs index 9cd0ea26f..9cbd6e48e 100644 --- a/crates/ide-build/src/compile/mod.rs +++ b/crates/ide-build/src/compile/mod.rs @@ -185,8 +185,10 @@ pub fn plan(root: &Path, model: &BuildModel, kind: BuildKind, file: Option<&Path None => None, }; let dir = module_dir.clone().unwrap_or_else(|| root.to_path_buf()); + // A folder of sibling projects has no build file itself; its Maven projects are built one by one. let system = detect_dir(&dir, model, root) .or_else(|| if model.gradle.is_some() { Some(BuildSystem::Gradle) } else { None }) + .or_else(|| model.maven.as_ref().filter(|m| !m.projects.is_empty()).map(|_| BuildSystem::Maven)) .ok_or_else(|| IdeError::not_found("no supported build system (Maven, Gradle, Cargo, Go, TypeScript) found"))?; let module = module_dir.as_deref().filter(|d| *d != root); let title = match module { @@ -814,6 +816,17 @@ mod tests { ("pnpm", &["run".to_owned(), "build".to_owned()][..]) ); + let siblings = tmp.path().join("siblings"); + write(&siblings, "core/pom.xml", "core"); + write(&siblings, "system/pom.xml", "system"); + let model = crate::load(&siblings, &tmp.path().join("cache3"), None); + let p = plan(&siblings, &model, BuildKind::Build, None).unwrap(); + assert_eq!(p.system, BuildSystem::Maven); + assert_eq!( + p.steps.iter().map(|s| s.cwd.clone()).collect::>(), + [siblings.join("core"), siblings.join("system")] + ); + let none = tmp.path().join("none"); std::fs::create_dir_all(&none).unwrap(); assert!(plan(&none, &BuildModel::default(), BuildKind::Build, None).is_err()); From 4ea7d32074a984a315c44d5b8df767e07e84507b Mon Sep 17 00:00:00 2001 From: kevinh <45650311+kevinh458@users.noreply.github.com> Date: Mon, 2 Nov 2026 20:16:07 +0800 Subject: [PATCH 017/106] fix(settings): confirm before exiting the IDE by default (#834) Confirm before exiting the IDE now defaults to on in both the Rust settings and the frontend schema; the exit dialog's "Don't ask again" still turns it off. Fixes #749 Co-authored-by: kevinh458 <45650311+kevinh458@users.noreply.github.com> --- crates/ide-core/src/settings.rs | 2 +- src/features/settings/model/schema.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/ide-core/src/settings.rs b/crates/ide-core/src/settings.rs index 7c2c17a37..453b1433f 100644 --- a/crates/ide-core/src/settings.rs +++ b/crates/ide-core/src/settings.rs @@ -235,7 +235,7 @@ impl Default for GeneralSettings { fn default() -> Self { Self { reopen_last_project: true, - confirm_exit: false, + confirm_exit: true, open_project_in: "ask".into(), default_project_directory: String::new(), save_on_idle: false, diff --git a/src/features/settings/model/schema.ts b/src/features/settings/model/schema.ts index d92540d3a..f9400f4c9 100644 --- a/src/features/settings/model/schema.ts +++ b/src/features/settings/model/schema.ts @@ -118,7 +118,7 @@ function validateSpelling(raw: unknown): SpellingValid { export { ACCENT_PRESETS } from "@/workbench/theme"; export const DEFAULT_SETTINGS: ValidSettings = { - general: { reopenLastProject: true, confirmExit: false, ...DEFAULT_GENERAL_EXTRAS }, + general: { reopenLastProject: true, confirmExit: true, ...DEFAULT_GENERAL_EXTRAS }, appearance: { theme: "light", accent: "#3871e1", uiFontSize: 13, locale: "en", ...DEFAULT_APPEARANCE_EXTRAS }, editor: { fontFamily: "JetBrains Mono", From 53b5052bbbf3e0cce2fdf788e0c5c3861d708486 Mon Sep 17 00:00:00 2001 From: HEOJUNFO <100739943+HEOJUNFO@users.noreply.github.com> Date: Mon, 2 Nov 2026 21:17:29 +0900 Subject: [PATCH 018/106] fix(editor): let the scrollbar thumb drag where overview marks cover it (#807) The overview lane lies over the vertical scrollbar and its marks take the pointer, so a mark under the thumb swallowed the press; a new or fully changed file is one lane-high mark, so its thumb could not be dragged at all. While the pointer is over the thumb (computed from the scroller's metrics and the 32px minimum thumb) the marks now let the pointer through; elsewhere clicking a mark still jumps to its line. Fixes #752 Co-authored-by: HEOJUNFO <100739943+HEOJUNFO@users.noreply.github.com> --- e2e/overviewLane.spec.ts | 30 +++++++++++++ src/editor/core/cm/ideaParity.test.ts | 12 +++++- src/editor/core/cm/lanes.ts | 61 +++++++++++++++++++++++++++ src/editor/core/cm/theme.ts | 2 + 4 files changed, 104 insertions(+), 1 deletion(-) create mode 100644 e2e/overviewLane.spec.ts diff --git a/e2e/overviewLane.spec.ts b/e2e/overviewLane.spec.ts new file mode 100644 index 000000000..6c51df7c8 --- /dev/null +++ b/e2e/overviewLane.spec.ts @@ -0,0 +1,30 @@ +import { expect, openFromTree, test } from "./fixtures"; + +// Headless Chromium hides scrollbars by default; this test drags one. +test.use({ launchOptions: { ignoreDefaultArgs: ["--hide-scrollbars"] } }); + +test("the scrollbar thumb drags even where overview marks cover it (#752)", async ({ workbench: page }) => { + const editor = await openFromTree(page, "build.gradle.kts"); + await editor.locator(".cm-content").click(); + await page.keyboard.press("Meta+a"); + await page.keyboard.insertText(Array.from({ length: 400 }, (_, i) => `val mark${i} = ${i}`).join("\n")); + // A match on every line covers the overview lane, as a new file's change mark does. + await page.keyboard.press("Meta+f"); + await page.getByRole("textbox", { name: "Find" }).fill("mark"); + await expect(editor.locator(".cm-openidea-overviewMark").first()).toBeVisible(); + const scroller = editor.locator(".cm-scroller"); + await scroller.evaluate((s) => (s.scrollTop = 0)); + const box = (await scroller.boundingBox())!; + // Over a mark and inside the thumb, which is at the top of the track. + const x = box.x + box.width - 5; + const y = box.y + 12; + await page.mouse.move(x - 40, y); + await page.mouse.move(x, y); + await page.mouse.down(); + await page.mouse.move(x, y + 150, { steps: 10 }); + await page.mouse.up(); + expect(await scroller.evaluate((s) => s.scrollTop)).toBeGreaterThan(0); + // Away from the thumb the marks take the pointer again. + await page.mouse.move(x, box.y + box.height - 20); + await expect(editor.locator(".cm-openidea-overview")).not.toHaveClass(/cm-openidea-overview-onThumb/); +}); diff --git a/src/editor/core/cm/ideaParity.test.ts b/src/editor/core/cm/ideaParity.test.ts index 38640f8fd..bad82ca40 100644 --- a/src/editor/core/cm/ideaParity.test.ts +++ b/src/editor/core/cm/ideaParity.test.ts @@ -23,7 +23,7 @@ import { foldSelectionEffects, } from "./folding"; import { columnModeExtension, extendBlock, toggleColumnMode } from "./columnMode"; -import { nearestMark, unfoldAround } from "./lanes"; +import { nearestMark, scrollbarThumb, unfoldAround } from "./lanes"; import { lineRangeSelection } from "./gutterDrag"; import { matchBraceTarget } from "./caretCommands"; import { overwriteField, toggleOverwrite } from "./overwrite"; @@ -291,6 +291,16 @@ describe("error stripe", () => { expect(nearestMark(marks, 21)).toBeNull(); }); + it("finds the scrollbar thumb the marks must let through", () => { + // Nothing to scroll: no thumb. + expect(scrollbarThumb(0, 400, 400)).toBeNull(); + // A quarter of the document visible: a 100px thumb, at the top, then at the bottom. + expect(scrollbarThumb(0, 1600, 400)).toEqual({ top: 0, bottom: 100 }); + expect(scrollbarThumb(1200, 1600, 400)).toEqual({ top: 300, bottom: 400 }); + // A long document keeps the 32px minimum thumb; halfway down it sits mid-track. + expect(scrollbarThumb(49800, 100000, 400)).toEqual({ top: 184, bottom: 216 }); + }); + it("unfolds what hides the target line", () => { const s = EditorState.create({ doc: "f {\n x\n}", extensions: [codeFolding()] }).update({ effects: foldEffect.of({ from: 3, to: 8 }), diff --git a/src/editor/core/cm/lanes.ts b/src/editor/core/cm/lanes.ts index cb876831c..9e6ac7c62 100644 --- a/src/editor/core/cm/lanes.ts +++ b/src/editor/core/cm/lanes.ts @@ -27,6 +27,27 @@ export function nearestMark(marks: re return best; } +/** The smallest scrollbar thumb (`::-webkit-scrollbar-thumb` min-height in global.css). */ +const MIN_THUMB = 32; + +/** + * Where the vertical scrollbar thumb of a scroller is, in px from the top of its track, or null + * when nothing scrolls. The overview lane lies over the scrollbar, so its marks must not take + * the pointer where the thumb is (#752). + */ +export function scrollbarThumb( + scrollTop: number, + scrollHeight: number, + clientHeight: number, + minThumb = MIN_THUMB, +): { top: number; bottom: number } | null { + const max = scrollHeight - clientHeight; + if (max <= 0 || clientHeight <= 0) return null; + const length = Math.min(clientHeight, Math.max(minThumb, (clientHeight * clientHeight) / scrollHeight)); + const top = (Math.min(Math.max(scrollTop, 0), max) / max) * (clientHeight - length); + return { top, bottom: top + length }; +} + /** Unfolds the folds hiding `pos` or the rest of its line (navigating to a mark shows it). */ export function unfoldAround(state: EditorState, pos: number): StateEffect[] { const lineEnd = state.doc.lineAt(pos).to; @@ -41,12 +62,17 @@ class OverviewLane { dom: HTMLElement; private scheduled = false; private lastKey = ""; + /** The last pointer position over the editor, to re-check the thumb when it scrolls. */ + private pointer: { x: number; y: number } | null = null; constructor(private view: EditorView) { this.dom = document.createElement("div"); this.dom.className = "cm-openidea-overview"; this.dom.setAttribute("aria-hidden", "true"); this.dom.addEventListener("mousedown", this.onMouseDown); + view.dom.addEventListener("mousemove", this.onPointerMove); + view.dom.addEventListener("mouseleave", this.onPointerLeave); + view.scrollDOM.addEventListener("scroll", this.onScroll, { passive: true }); view.dom.appendChild(this.dom); this.schedule(); } @@ -132,6 +158,38 @@ class OverviewLane { }); } + /** + * Over the scrollbar thumb the marks let the pointer through, so the thumb can be dragged + * even where a mark covers it (a new file is one mark the height of the lane, #752). + */ + private updateThumbPassThrough() { + const p = this.pointer; + let over = false; + if (p) { + const lane = this.dom.getBoundingClientRect(); + const s = this.view.scrollDOM; + const thumb = scrollbarThumb(s.scrollTop, s.scrollHeight, s.clientHeight); + const y = p.y - s.getBoundingClientRect().top; + over = !!thumb && p.x >= lane.left && p.x <= lane.right && y >= thumb.top && y <= thumb.bottom; + } + this.dom.classList.toggle("cm-openidea-overview-onThumb", over); + } + + private onPointerMove = (e: MouseEvent) => { + this.pointer = { x: e.clientX, y: e.clientY }; + // A thumb drag in progress keeps the marks out of the way until the button is released. + if (e.buttons === 0) this.updateThumbPassThrough(); + }; + + private onPointerLeave = () => { + this.pointer = null; + this.updateThumbPassThrough(); + }; + + private onScroll = () => { + if (this.pointer) this.updateThumbPassThrough(); + }; + private onMouseDown = (e: MouseEvent) => { if (e.button !== 0) return; const marks = [...this.dom.querySelectorAll(".cm-openidea-overviewMark")].map((el) => { @@ -153,6 +211,9 @@ class OverviewLane { destroy() { this.dom.removeEventListener("mousedown", this.onMouseDown); + this.view.dom.removeEventListener("mousemove", this.onPointerMove); + this.view.dom.removeEventListener("mouseleave", this.onPointerLeave); + this.view.scrollDOM.removeEventListener("scroll", this.onScroll); this.dom.remove(); } } diff --git a/src/editor/core/cm/theme.ts b/src/editor/core/cm/theme.ts index 0a95f9177..0390d1f1f 100644 --- a/src/editor/core/cm/theme.ts +++ b/src/editor/core/cm/theme.ts @@ -283,6 +283,8 @@ export const openideaTheme = EditorView.theme({ cursor: "pointer", }, ".cm-openidea-overviewMark-thin": { left: "4px", width: "4px", minHeight: "2px" }, + // The scrollbar thumb wins over the marks under it (cm/lanes.ts, #752). + ".cm-openidea-overview-onThumb .cm-openidea-overviewMark": { pointerEvents: "none" }, ".cm-openidea-overviewCaret": { position: "absolute", left: "2px", From f8105e5473111013f5818a20a9519874eb3a15c1 Mon Sep 17 00:00:00 2001 From: misswell Date: Mon, 2 Nov 2026 20:17:37 +0800 Subject: [PATCH 019/106] fix(maven): list every goal a plugin declares, not just the bound one (#841) Plugin goals came from a built-in table plus the goals bound in , so a plugin outside the table (maven-checkstyle-plugin) showed only its bound goal. The Plugins section now also reads META-INF/maven/plugin.xml from the plugin jar in the local repository (honouring of the settings file in use), cached per jar, with the table as fallback when the jar is not downloaded yet. Co-authored-by: misswell <28730815+misswell@users.noreply.github.com> --- Cargo.lock | 1 + crates/ide-build/Cargo.toml | 1 + crates/ide-build/src/maven.rs | 229 ++++++++++++++++++++++++++++++++-- crates/ide-build/src/sync.rs | 78 +++++++++++- 4 files changed, 294 insertions(+), 15 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b7590a3f9..423882bf7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4086,6 +4086,7 @@ dependencies = [ "specta", "tempfile", "tracing", + "zip", ] [[package]] diff --git a/crates/ide-build/Cargo.toml b/crates/ide-build/Cargo.toml index eff2f2485..946863725 100644 --- a/crates/ide-build/Cargo.toml +++ b/crates/ide-build/Cargo.toml @@ -8,6 +8,7 @@ license.workspace = true [dependencies] dunce.workspace = true ide-core.workspace = true +zip.workspace = true serde.workspace = true serde_json.workspace = true specta.workspace = true diff --git a/crates/ide-build/src/maven.rs b/crates/ide-build/src/maven.rs index f9b926a9d..b467c95ac 100644 --- a/crates/ide-build/src/maven.rs +++ b/crates/ide-build/src/maven.rs @@ -11,7 +11,9 @@ use crate::xml::{self, Element}; use serde::{Deserialize, Serialize}; use specta::Type; use std::collections::{BTreeMap, HashMap, HashSet}; +use std::io::Read; use std::path::{Path, PathBuf}; +use std::sync::{Mutex, OnceLock}; /// Phases shown under "Lifecycle" (IDEA's default list). pub const LIFECYCLE: &[&str] = @@ -456,13 +458,20 @@ struct Resolver { /// `~/.m2/repository`, or `` from `~/.m2/settings.xml`. pub(crate) fn default_local_repo() -> Option { let m2 = dirs::home_dir()?.join(".m2"); - if let Some(settings) = std::fs::read_to_string(m2.join("settings.xml")).ok().and_then(|t| xml::parse(&t)) - && let Some(repo) = settings.text_of("localRepository") - { - let home = dirs::home_dir().map(|h| h.to_string_lossy().into_owned()).unwrap_or_default(); - return Some(PathBuf::from(repo.replace("${user.home}", &home))); + local_repository_of(&m2.join("settings.xml")).or(Some(m2.join("repository"))) +} + +/// `` of a settings file, when it names one: the repository a build +/// with `-s ` resolves into, which is not necessarily the user's default. +pub fn local_repository_of(settings: &Path) -> Option { + let text = std::fs::read_to_string(settings).ok()?; + let root = xml::parse(&text)?; + let repo = root.text_of("localRepository")?.trim().to_owned(); + if repo.is_empty() { + return None; } - Some(m2.join("repository")) + let home = dirs::home_dir().map(|h| h.to_string_lossy().into_owned()).unwrap_or_default(); + Some(PathBuf::from(repo.replace("${user.home}", &home))) } impl Resolver { @@ -688,7 +697,76 @@ pub fn goal_prefix(artifact_id: &str) -> String { artifact_id.strip_suffix("-plugin").unwrap_or(artifact_id).to_owned() } -/// Goals of well-known plugins (their plugin descriptors are not read). +/// Goals of every plugin jar read so far, by the path of that jar. +static DESCRIPTOR: OnceLock>>> = OnceLock::new(); + +/// `` names of the plugin descriptor inside its jar (`META-INF/maven/plugin.xml`), +/// which is where Maven itself takes the list `plugin:help` prints. Empty when the jar +/// is not in the local repository yet. +fn descriptor_goals(repo: &Path, group_id: &str, artifact_id: &str, version: &str) -> Vec { + // Keyed by the jar itself, not by its coordinates: a repository can be replaced + // between runs, and a test's empty repository must not answer for another's. + // One `push` per group segment: the layout is `//…` on + // every platform, Windows' separator included. + let mut dir = repo.to_path_buf(); + dir.extend(group_id.split('.')); + dir.push(artifact_id); + dir.push(version); + let Some(jar) = plugin_jar(&dir, artifact_id, version) else { return Vec::new() }; + let key = jar.to_string_lossy().into_owned(); + let cache = DESCRIPTOR.get_or_init(|| Mutex::new(HashMap::new())); + if let Some(cached) = cache.lock().unwrap_or_else(|e| e.into_inner()).get(&key) { + return cached.clone(); + } + // A failed read is not remembered: the jar may be half written by Maven right now. + let Some(goals) = read_descriptor(&jar) else { return Vec::new() }; + cache.lock().unwrap_or_else(|e| e.into_inner()).insert(key, goals.clone()); + goals +} + +/// The plugin's jar in its version directory: `-.jar`, or — for a +/// SNAPSHOT installed from a repository, whose file name carries a timestamp — the newest +/// `-*.jar` that is neither sources nor javadoc. +fn plugin_jar(dir: &Path, artifact_id: &str, version: &str) -> Option { + let exact = dir.join(format!("{artifact_id}-{version}.jar")); + if exact.is_file() { + return Some(exact); + } + let prefix = format!("{artifact_id}-"); + let mut best: Option<(std::time::SystemTime, PathBuf)> = None; + for entry in std::fs::read_dir(dir).ok()?.flatten() { + let path = entry.path(); + let Some(name) = path.file_name().and_then(|n| n.to_str()) else { continue }; + if !name.starts_with(&prefix) || !name.ends_with(".jar") || name.ends_with("-sources.jar") { + continue; + } + if name.ends_with("-javadoc.jar") { + continue; + } + let Ok(modified) = entry.metadata().and_then(|m| m.modified()) else { continue }; + if best.as_ref().is_none_or(|(at, _)| modified > *at) { + best = Some((modified, path)); + } + } + best.map(|(_, p)| p) +} + +fn read_descriptor(jar: &Path) -> Option> { + let file = std::fs::File::open(jar).ok()?; + let mut archive = zip::ZipArchive::new(file).ok()?; + let mut entry = archive.by_name("META-INF/maven/plugin.xml").ok()?; + let mut text = String::new(); + entry.read_to_string(&mut text).ok()?; + let root = xml::parse(&text)?; + let mut goals: Vec = + root.list("mojos", "mojo").iter().filter_map(|m| m.text_of("goal")).map(|g| g.trim().to_owned()).collect(); + goals.retain(|g| !g.is_empty()); + goals.sort(); + goals.dedup(); + Some(goals) +} + +/// Goals of well-known plugins, for a jar that is not in the local repository yet. fn known_goals(artifact_id: &str) -> &'static [&'static str] { match artifact_id { "maven-clean-plugin" => &["clean", "help"], @@ -840,9 +918,27 @@ fn lifecycle_plugins(packaging: &str) -> &'static [&'static str] { } } -/// A plugin as shown in the tree: execution goals plus the plugin's known goals, sorted. -pub(crate) fn plugin(group_id: &str, artifact_id: &str, version: Option, goals: &[String]) -> MavenPlugin { +/// A plugin as shown in the tree: the goals its jar declares, the ones known by name for +/// the common plugins, and the ones the project binds in ``, sorted. IDEA lists +/// a plugin's goals the same way, which is why a plugin outside the table (checkstyle, say) +/// still shows every goal it offers instead of only the bound one. +pub(crate) fn plugin( + group_id: &str, + artifact_id: &str, + version: Option, + goals: &[String], + local_repo: Option<&Path>, +) -> MavenPlugin { let mut all: Vec = known_goals(artifact_id).iter().map(|g| (*g).to_owned()).collect(); + let repo = local_repo.map(Path::to_path_buf).or_else(default_local_repo); + // An unresolved `${…}` version names no jar: the table and the bound goals stand in. + if let (Some(repo), Some(version)) = (repo.as_deref(), version.as_deref().filter(|v| !v.contains("${"))) { + for g in descriptor_goals(repo, group_id, artifact_id, version) { + if !all.contains(&g) { + all.push(g); + } + } + } for g in goals { if !all.contains(g) { all.push(g.clone()); @@ -1007,7 +1103,7 @@ pub(crate) fn load_with_repo(root: &Path, local_repo: Option) -> MavenM .or_else(|| ctx.managed_plugins.iter().find(|m| m.key() == p.key()).and_then(|m| m.version.clone())) .map(|v| i(&v)) .filter(|v| !v.contains("${")); - plugin(&i(&p.group_id), &i(&p.artifact_id), version, &p.goals) + plugin(&i(&p.group_id), &i(&p.artifact_id), version, &p.goals, resolver.local_repo.as_deref()) }) .collect(); @@ -1119,6 +1215,16 @@ pub fn build_files(root: &Path, model: &MavenModel) -> Vec { /// Projects and their plugins / dependencies from `mvn help:effective-pom` output /// (`` or ``), keyed `groupId:artifactId`. pub fn parse_effective_pom(text: &str) -> Vec<(String, Vec, Vec)> { + parse_effective_pom_in(text, None) +} + +/// The same, with the local repository the project actually builds against (a +/// `-Dmaven.repo.local` of the build, say): the plugin goals come from jars there, +/// rather than from the user's default one. +pub fn parse_effective_pom_in( + text: &str, + local_repo: Option<&Path>, +) -> Vec<(String, Vec, Vec)> { let Some(root) = xml::parse(text) else { return Vec::new() }; let projects: Vec<&Element> = if root.name == "projects" { root.children_named("project").collect() } else { vec![&root] }; @@ -1130,7 +1236,7 @@ pub fn parse_effective_pom(text: &str) -> Vec<(String, Vec, Vec Vec<(String, Vec) mod tests { use super::*; + /// A plugin jar in `repo` whose descriptor declares `goals`. + fn write_plugin_jar(repo: &Path, group: &str, artifact: &str, version: &str, goals: &[&str]) { + let dir = repo.join(group.replace('.', "/")).join(artifact).join(version); + std::fs::create_dir_all(&dir).unwrap(); + let mojos: String = goals.iter().map(|g| format!("{g}")).collect(); + let descriptor = format!("{mojos}"); + let file = std::fs::File::create(dir.join(format!("{artifact}-{version}.jar"))).unwrap(); + let mut zip = zip::ZipWriter::new(file); + zip.start_file("META-INF/maven/plugin.xml", zip::write::SimpleFileOptions::default()).unwrap(); + std::io::Write::write_all(&mut zip, descriptor.as_bytes()).unwrap(); + zip.finish().unwrap(); + } + + #[test] + fn a_plugin_lists_the_goals_its_jar_declares() { + let tmp = tempfile::tempdir().unwrap(); + let repo = tmp.path(); + write_plugin_jar( + repo, + "org.apache.maven.plugins", + "maven-checkstyle-plugin", + "3.3.1", + &["checkstyle", "checkstyle-aggregate", "help", "check"], + ); + let p = plugin( + "org.apache.maven.plugins", + "maven-checkstyle-plugin", + Some("3.3.1".into()), + &["check".to_owned()], + Some(repo), + ); + assert_eq!(p.goals, ["check", "checkstyle", "checkstyle-aggregate", "help"], "every goal the plugin offers"); + assert_eq!(p.prefix, "checkstyle"); + } + + #[test] + fn a_settings_file_names_the_repository_to_read_goals_from() { + let tmp = tempfile::tempdir().unwrap(); + let settings = tmp.path().join("settings.xml"); + std::fs::write( + &settings, + "${user.home}/repo-from-settings", + ) + .unwrap(); + let repo = local_repository_of(&settings).unwrap(); + assert!(repo.ends_with("repo-from-settings"), "{}", repo.display()); + assert!(!repo.to_string_lossy().contains("${user.home}")); + // A settings file that names none leaves Maven's default alone. + let plain = tmp.path().join("plain.xml"); + std::fs::write(&plain, "true").unwrap(); + assert!(local_repository_of(&plain).is_none()); + assert!(local_repository_of(&tmp.path().join("missing.xml")).is_none()); + } + + #[test] + fn effective_pom_goals_come_from_the_local_repository_it_is_given() { + let tmp = tempfile::tempdir().unwrap(); + write_plugin_jar(tmp.path(), "org.apache.maven.plugins", "maven-demo-plugin", "1.0", &["run", "help"]); + let text = "ga\ + org.apache.maven.pluginsmaven-demo-plugin\ + 1.0run\ + "; + let with_repo = parse_effective_pom_in(text, Some(tmp.path())); + assert_eq!(with_repo[0].1[0].goals, ["help", "run"], "the jar's own goals come first"); + // Without that repository the plugin is unknown: only what the project binds. + assert_eq!(parse_effective_pom(text)[0].1[0].goals, ["run"]); + } + + #[test] + fn a_snapshot_plugin_is_found_by_its_timestamped_jar() { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path().join("g/p/maven-demo-plugin/1.0-SNAPSHOT"); + std::fs::create_dir_all(&dir).unwrap(); + let descriptor = "run"; + let file = std::fs::File::create(dir.join("maven-demo-plugin-1.0-20240101.120000-3.jar")).unwrap(); + let mut zip = zip::ZipWriter::new(file); + zip.start_file("META-INF/maven/plugin.xml", zip::write::SimpleFileOptions::default()).unwrap(); + std::io::Write::write_all(&mut zip, descriptor.as_bytes()).unwrap(); + zip.finish().unwrap(); + // A sibling sources jar must not win. + std::fs::File::create(dir.join("maven-demo-plugin-1.0-20240101.120000-3-sources.jar")).unwrap(); + let p = plugin("g.p", "maven-demo-plugin", Some("1.0-SNAPSHOT".into()), &[], Some(tmp.path())); + assert_eq!(p.goals, ["run"]); + } + + #[test] + fn a_plugin_without_its_jar_keeps_the_table_and_the_bound_goals() { + let tmp = tempfile::tempdir().unwrap(); + let p = plugin( + "org.apache.maven.plugins", + "maven-checkstyle-plugin", + Some("3.3.1".into()), + &["check".to_owned()], + Some(tmp.path()), + ); + assert_eq!(p.goals, ["check"], "nothing beyond what the project binds"); + let known = + plugin("org.apache.maven.plugins", "maven-compiler-plugin", Some("3.13.0".into()), &[], Some(tmp.path())); + assert_eq!(known.goals, ["compile", "help", "testCompile"], "the common plugins keep their table"); + } + fn write(dir: &Path, rel: &str, text: &str) { let p = dir.join(rel); std::fs::create_dir_all(p.parent().unwrap()).unwrap(); diff --git a/crates/ide-build/src/sync.rs b/crates/ide-build/src/sync.rs index cbeed656f..815c46313 100644 --- a/crates/ide-build/src/sync.rs +++ b/crates/ide-build/src/sync.rs @@ -48,6 +48,11 @@ struct CachedMavenProject { #[derive(Clone, Debug, Default, Serialize, Deserialize)] struct MavenCache { + /// Bumped whenever what the cache holds stops matching what the model would be + /// built as now (plugin goals read from a descriptor, say): an older cache is + /// dropped instead of shadowing the model. + #[serde(default)] + version: u32, hash: String, at_ms: f64, error: Option, @@ -66,6 +71,7 @@ struct GradleCache { } const MAVEN_CACHE: &str = "maven.json"; +const MAVEN_CACHE_VERSION: u32 = 1; /// `dependency:tree` of a plugin version whose `-Dverbose` lists the occurrences Maven /// omitted (conflicts, duplicates): verbose output was dropped in 3.0 and is back since 3.2. @@ -121,6 +127,9 @@ fn now_ms() -> f64 { /// Merges the cached effective build into `model`. pub fn apply_maven_cache(root: &Path, cache_dir: &Path, model: &mut MavenModel) { let Some(cache) = read_cache::(cache_dir, MAVEN_CACHE) else { return }; + if cache.version != MAVEN_CACHE_VERSION { + return; // built by an older IDE: what it holds no longer matches the model + } model.synced_at = Some(cache.at_ms); model.sync_error = cache.error; let changed = cache.hash != hash_files(&maven::build_files(root, model)); @@ -211,8 +220,31 @@ pub fn sync_maven( let mut model = maven::load(root); maven::UnlinkedProjects::from_model(&model, &options.maven_settings).filter(&mut model); std::fs::create_dir_all(cache_dir)?; - let mut cache = - MavenCache { hash: hash_files(&maven::build_files(root, &model)), verbose: true, ..MavenCache::default() }; + let mut cache = MavenCache { + version: MAVEN_CACHE_VERSION, + hash: hash_files(&maven::build_files(root, &model)), + verbose: true, + ..MavenCache::default() + }; + // Where the build resolves its jars, and therefore where the plugin goals are read + // from: `-Dmaven.repo.local=` first, then `` of the `-s` settings, + // then the user's default repository (which `plugin()` resolves itself). + let local_repo: Option = + options.maven_options.iter().find_map(|a| a.strip_prefix("-Dmaven.repo.local=")).map(PathBuf::from).or_else( + || { + let mut args = options.maven_options.iter(); + while let Some(arg) = args.next() { + let path = match arg.as_str() { + "-s" | "--settings" => args.next().map(String::as_str), + _ => arg.strip_prefix("-s=").or_else(|| arg.strip_prefix("--settings=")), + }; + if let Some(path) = path { + return maven::local_repository_of(Path::new(path)); + } + } + None + }, + ); let mut errors: Vec = Vec::new(); for (n, top) in model.projects.iter().filter(|p| !p.aggregated).enumerate() { let effective = cache_dir.join(format!("effective-pom-{n}.xml")); @@ -241,8 +273,9 @@ pub fn sync_maven( }); } })?; - let effective_projects = - std::fs::read_to_string(&effective).map(|t| maven::parse_effective_pom(&t)).unwrap_or_default(); + let effective_projects = std::fs::read_to_string(&effective) + .map(|t| maven::parse_effective_pom_in(&t, local_repo.as_deref())) + .unwrap_or_default(); let trees = maven::parse_dependency_tree(&out.text); for (key, plugins, deps) in effective_projects { let dependencies = trees.iter().find(|(k, _)| *k == key).map(|(_, t)| t.clone()).unwrap_or(deps); @@ -332,6 +365,7 @@ mod tests { let mut model = maven::load_with_repo(&root, None); assert!(model.synced_at.is_none()); let cache = MavenCache { + version: MAVEN_CACHE_VERSION, hash: hash_files(&maven::build_files(&root, &model)), at_ms: 5.0, error: None, @@ -343,6 +377,7 @@ mod tests { "maven-clean-plugin", Some("3.2.0".into()), &[], + None, )], dependencies: vec![MavenDependency { artifact_id: "dep".into(), ..MavenDependency::default() }], }], @@ -368,6 +403,41 @@ mod tests { assert_eq!(model.projects[0].dependencies[0].artifact_id, "fresh"); } + #[test] + fn a_cache_from_an_older_ide_is_ignored() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join("p"); + let cache_dir = tmp.path().join("cache"); + std::fs::create_dir_all(&root).unwrap(); + std::fs::write(root.join("pom.xml"), "ga") + .unwrap(); + let mut model = maven::load_with_repo(&root, None); + let cache = MavenCache { + // What an older build wrote: no plugin descriptors were read back then, so + // its goal lists would shadow the ones the model now knows. + version: 0, + hash: hash_files(&maven::build_files(&root, &model)), + at_ms: 5.0, + error: None, + verbose: true, + projects: vec![CachedMavenProject { + key: "g:a".into(), + plugins: vec![maven::plugin( + "org.apache.maven.plugins", + "maven-clean-plugin", + Some("3.2.0".into()), + &[], + None, + )], + dependencies: vec![MavenDependency { artifact_id: "cached".into(), ..MavenDependency::default() }], + }], + }; + write_cache(&cache_dir, MAVEN_CACHE, &cache).unwrap(); + apply_maven_cache(&root, &cache_dir, &mut model); + assert!(model.synced_at.is_none(), "the cache is dropped, the model stays unsynced"); + assert!(model.projects[0].dependencies.iter().all(|d| d.artifact_id != "cached")); + } + #[test] fn gradle_cache_replaces_tasks_per_project() { let tmp = tempfile::tempdir().unwrap(); From 91c15ff31bbf137fcd14977fee66f155a82fb886 Mon Sep 17 00:00:00 2001 From: Bernard Ograh <69661398+bograh@users.noreply.github.com> Date: Mon, 2 Nov 2026 12:21:07 +0000 Subject: [PATCH 020/106] refactor(core): share process-tree kill helpers across tool crates (#853) Five crates (tools, build, vcs, github, docker) each carried their own copy of killing a child and what it started, and only the tools installer killed the whole tree on Windows, so cancelling git, gh, docker or Maven/Gradle there left descendants running. ide-core now provides own_group, kill_tree and terminate_tree (process-group signals on Unix, taskkill /T /F on Windows) and the copies are removed. Unix behaviour is unchanged; the Windows path was not run locally. Co-authored-by: bograh <69661398+bograh@users.noreply.github.com> --- Cargo.lock | 4 +- crates/ide-build/Cargo.toml | 3 - crates/ide-build/src/process.rs | 23 +----- crates/ide-core/Cargo.toml | 3 + crates/ide-core/src/process.rs | 137 ++++++++++++++++++++++++++++++- crates/ide-docker/Cargo.toml | 3 - crates/ide-docker/src/process.rs | 38 +++------ crates/ide-github/Cargo.toml | 3 - crates/ide-github/src/process.rs | 38 +++------ crates/ide-tools/Cargo.toml | 6 +- crates/ide-tools/src/process.rs | 46 ++--------- crates/ide-vcs/src/cli.rs | 44 +++------- 12 files changed, 185 insertions(+), 163 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 423882bf7..5275a63ff 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4080,7 +4080,6 @@ dependencies = [ "dirs 6.0.0", "dunce", "ide-core", - "libc", "serde", "serde_json", "specta", @@ -4104,6 +4103,7 @@ version = "0.1.33" dependencies = [ "dunce", "encoding_rs", + "libc", "serde", "serde_json", "specta", @@ -4173,7 +4173,6 @@ name = "ide-docker" version = "0.1.33" dependencies = [ "ide-core", - "libc", "serde", "serde_json", "specta", @@ -4200,7 +4199,6 @@ version = "0.1.33" dependencies = [ "ide-core", "ide-vcs", - "libc", "serde", "serde_json", "specta", diff --git a/crates/ide-build/Cargo.toml b/crates/ide-build/Cargo.toml index 946863725..bda630cf4 100644 --- a/crates/ide-build/Cargo.toml +++ b/crates/ide-build/Cargo.toml @@ -15,8 +15,5 @@ specta.workspace = true tracing.workspace = true dirs.workspace = true -[target.'cfg(unix)'.dependencies] -libc = "0.2" - [dev-dependencies] tempfile.workspace = true diff --git a/crates/ide-build/src/process.rs b/crates/ide-build/src/process.rs index 8177089f0..db169b4c6 100644 --- a/crates/ide-build/src/process.rs +++ b/crates/ide-build/src/process.rs @@ -2,11 +2,12 @@ //! line to a callback, and cancellation kills the whole process group (the //! `mvnw` / `gradlew` wrapper scripts start the JVM as a child). +use ide_core::process::kill_tree; use ide_core::{CancellationToken, IdeError, IdeResult}; use std::collections::BTreeMap; use std::io::{BufRead, BufReader, Read}; use std::path::Path; -use std::process::{Child, Command, Stdio}; +use std::process::{Command, Stdio}; use std::sync::mpsc; use std::time::Duration; @@ -22,18 +23,6 @@ pub(crate) struct Output { pub text: String, } -fn kill_tree(child: &mut Child) { - #[cfg(unix)] - if let Ok(pid) = i32::try_from(child.id()) { - // SAFETY: plain syscall; the group was created by `process_group(0)` at spawn. - unsafe { - libc::kill(-pid, libc::SIGKILL); - } - } - let _ = child.kill(); - let _ = child.wait(); -} - /// `program` as spawnable: a bare name (`mvn`, `mvn.cmd`) resolves on the IDE's /// search path — the login shell's `PATH` plus version-manager shims — since the /// app's own `PATH` can be minimal and `Command` would not see `mvn` there. @@ -80,12 +69,8 @@ pub(crate) fn run_with_env( let mut command = Command::new(&program); command.args(args).current_dir(cwd).envs(env).envs(encoding.env.iter().map(|(k, v)| (k, v))).stdin(Stdio::null()); command.stdout(Stdio::piped()).stderr(Stdio::piped()); - #[cfg(unix)] - { - use std::os::unix::process::CommandExt; - command.process_group(0); - } - ide_core::process::prepare(&mut command); + // Its own group: `mvnw` / `gradlew` start the JVM as a child, and cancelling kills both. + ide_core::process::prepare(ide_core::process::own_group(&mut command)); if clear_env { command.env_clear().envs(env).envs(encoding.env); } diff --git a/crates/ide-core/Cargo.toml b/crates/ide-core/Cargo.toml index 81e06570d..4f9b43a6a 100644 --- a/crates/ide-core/Cargo.toml +++ b/crates/ide-core/Cargo.toml @@ -18,5 +18,8 @@ tokio-util.workspace = true [dev-dependencies] tempfile.workspace = true +[target.'cfg(unix)'.dependencies] +libc = "0.2" + [target.'cfg(windows)'.dependencies] windows-sys = { version = "0.61", features = ["Win32_Globalization", "Win32_Security", "Win32_System_Registry", "Win32_Foundation"] } diff --git a/crates/ide-core/src/process.rs b/crates/ide-core/src/process.rs index 9aaaff28f..b7a74e44f 100644 --- a/crates/ide-core/src/process.rs +++ b/crates/ide-core/src/process.rs @@ -1,8 +1,11 @@ //! Child-process helpers shared by every crate that spawns tools. use std::ffi::{OsStr, OsString}; -use std::process::Command; +#[cfg(windows)] +use std::process::Stdio; +use std::process::{Child, Command}; use std::sync::{Arc, RwLock}; +use std::time::Duration; /// Windows `CREATE_NO_WINDOW` creation flag, for call sites that combine it /// with other flags (and then call [`host_env`] themselves). Prefer [`prepare`] otherwise. @@ -25,6 +28,70 @@ pub fn prepare(cmd: &mut Command) -> &mut Command { host_env(cmd) } +/// Starts the child as the leader of its own process group on Unix, so [`kill_tree`] and +/// [`terminate_tree`] reach what it starts in turn (`gh` → `git`, `npm` → `node`, `mvnw` → +/// the JVM). A no-op elsewhere: on Windows the tree is found from the child's process id. +pub fn own_group(cmd: &mut Command) -> &mut Command { + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + cmd.process_group(0); + } + cmd +} + +/// Kills the child and everything it started, then reaps it. +/// +/// - Unix: `SIGKILL` to its process group, so spawn it with [`own_group`]. +/// - Windows: `taskkill /T /F`, which walks the tree from the child's process id. +pub fn kill_tree(child: &mut Child) { + #[cfg(unix)] + signal_group(child, libc::SIGKILL); + #[cfg(windows)] + taskkill_tree(child); + let _ = child.kill(); + let _ = child.wait(); +} + +/// [`kill_tree`] that first asks the group to exit: `SIGTERM`, then `grace`, then `SIGKILL`. +/// Git removes its `index.lock` on `SIGTERM`, where `SIGKILL` leaves it behind and the next +/// git command fails. Windows has no such signal to send from outside the child's console, +/// so there it is [`kill_tree`]. +pub fn terminate_tree(child: &mut Child, grace: Duration) { + #[cfg(unix)] + { + signal_group(child, libc::SIGTERM); + // Sleep rather than `try_wait`: the child stays unreaped, so its process id (and the + // group id) cannot be reused before the `SIGKILL` below. + std::thread::sleep(grace); + } + #[cfg(not(unix))] + let _ = grace; + kill_tree(child); +} + +#[cfg(unix)] +fn signal_group(child: &Child, signal: libc::c_int) { + if let Ok(pid) = libc::pid_t::try_from(child.id()) { + // SAFETY: plain syscall. The child is not reaped yet, so `-pid` is still its group (or + // no group at all when it was not spawned with `own_group`, and the call fails). + unsafe { + libc::kill(-pid, signal); + } + } +} + +#[cfg(windows)] +fn taskkill_tree(child: &Child) { + let mut taskkill = Command::new("taskkill"); + prepare(&mut taskkill) + .args(["/T", "/F", "/PID", &child.id().to_string()]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + let _ = taskkill.status(); +} + /// Gives `cmd` the host's environment back when the IDE runs from an AppImage. `AppRun` and /// its hooks point `LD_LIBRARY_PATH`, `PATH`, `XDG_DATA_DIRS`, `GIO_MODULE_DIR`, `GTK_PATH`, /// `GST_PLUGIN_*`, ... into the mounted image for the IDE itself; a host program that @@ -597,3 +664,71 @@ mod tests { assert_eq!(find_executable("nope", &search), None); } } + +#[cfg(all(test, unix))] +mod kill_tests { + use super::*; + use std::path::Path; + use std::time::Instant; + + /// Runs `script` in `sh` as its own group's leader and waits for it to write `ready`. + fn start(script: &str, ready: &Path) -> Child { + let mut cmd = Command::new("sh"); + cmd.arg("-c").arg(script); + let child = own_group(&mut cmd).spawn().unwrap(); + let deadline = Instant::now() + Duration::from_secs(5); + while !ready.exists() { + assert!(Instant::now() < deadline, "the script never got ready"); + std::thread::sleep(Duration::from_millis(10)); + } + child + } + + fn assert_gone(pid: libc::pid_t) { + let deadline = Instant::now() + Duration::from_secs(5); + // SAFETY: signal 0 only checks that the process exists. + while unsafe { libc::kill(pid, 0) } == 0 { + assert!(Instant::now() < deadline, "process {pid} survived"); + std::thread::sleep(Duration::from_millis(20)); + } + } + + #[test] + fn kill_tree_kills_what_the_child_started() { + let dir = tempfile::tempdir().unwrap(); + let pid_file = dir.path().join("pid"); + let script = format!("sleep 30 & echo $! > '{0}.tmp' && mv '{0}.tmp' '{0}'; wait", pid_file.display()); + let mut child = start(&script, &pid_file); + let grandchild: libc::pid_t = std::fs::read_to_string(&pid_file).unwrap().trim().parse().unwrap(); + kill_tree(&mut child); + assert!(child.try_wait().unwrap().is_some(), "the child is reaped"); + assert_gone(grandchild); + } + + #[test] + fn terminate_tree_lets_the_child_clean_up() { + let dir = tempfile::tempdir().unwrap(); + let (ready, cleaned) = (dir.path().join("ready"), dir.path().join("cleaned")); + let script = format!( + "trap 'touch \"{}\"; exit 0' TERM; touch \"{}\"; sleep 30 & wait", + cleaned.display(), + ready.display() + ); + let mut child = start(&script, &ready); + terminate_tree(&mut child, Duration::from_millis(500)); + assert!(cleaned.exists(), "the SIGTERM handler ran before the SIGKILL"); + assert!(child.try_wait().unwrap().is_some(), "the child is reaped"); + } + + #[test] + fn terminate_tree_still_kills_a_child_that_ignores_sigterm() { + let dir = tempfile::tempdir().unwrap(); + let ready = dir.path().join("ready"); + let script = format!("trap '' TERM; touch \"{}\"; while :; do sleep 1; done", ready.display()); + let mut child = start(&script, &ready); + let started = Instant::now(); + terminate_tree(&mut child, Duration::from_millis(50)); + assert!(child.try_wait().unwrap().is_some(), "the child is reaped"); + assert!(started.elapsed() < Duration::from_secs(5)); + } +} diff --git a/crates/ide-docker/Cargo.toml b/crates/ide-docker/Cargo.toml index 1431fa547..ae06863d1 100644 --- a/crates/ide-docker/Cargo.toml +++ b/crates/ide-docker/Cargo.toml @@ -13,8 +13,5 @@ specta.workspace = true tracing.workspace = true which = "8" -[target.'cfg(unix)'.dependencies] -libc = "0.2" - [dev-dependencies] tempfile.workspace = true diff --git a/crates/ide-docker/src/process.rs b/crates/ide-docker/src/process.rs index 6c08c0050..3941991dd 100644 --- a/crates/ide-docker/src/process.rs +++ b/crates/ide-docker/src/process.rs @@ -1,11 +1,16 @@ //! Child processes of the docker CLI: bounded runs and cancellable line streams. +use ide_core::process::terminate_tree; use ide_core::{CancellationToken, IdeError, IdeResult}; use std::io::Read; use std::process::{Child, Command, Stdio}; use std::sync::mpsc; use std::time::{Duration, Instant}; +/// How long a cancelled or timed-out child's process group (CLI plugins such as compose) +/// gets to exit on `SIGTERM`, and clean up, before `SIGKILL`. +const KILL_GRACE: Duration = Duration::from_millis(20); + pub(crate) struct Output { pub stdout: String, pub stderr: String, @@ -20,36 +25,13 @@ pub(crate) enum Stream { fn prepare(cmd: &mut Command) { cmd.stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped()); - #[cfg(unix)] - { - use std::os::unix::process::CommandExt; - cmd.process_group(0); - } - ide_core::process::prepare(cmd); + ide_core::process::prepare(ide_core::process::own_group(cmd)); } fn spawn(cmd: &mut Command) -> IdeResult { cmd.spawn().map_err(|e| IdeError::unavailable(format!("could not run docker: {e}"))) } -/// Kills the child and, on Unix, its whole process group (CLI plugins such as compose). -pub(crate) fn kill(child: &mut Child) { - #[cfg(unix)] - { - let pid = child.id() as libc::pid_t; - // SAFETY: plain syscall; the child leads its own process group (`prepare`). - unsafe { - libc::kill(-pid, libc::SIGTERM); - } - std::thread::sleep(Duration::from_millis(20)); - unsafe { - libc::kill(-pid, libc::SIGKILL); - } - } - let _ = child.kill(); - let _ = child.wait(); -} - fn read_all(mut pipe: impl Read + Send + 'static) -> std::thread::JoinHandle { std::thread::spawn(move || { let mut buf = Vec::new(); @@ -88,11 +70,11 @@ pub(crate) fn run_with_input( break status; } if cancel.is_some_and(CancellationToken::is_cancelled) { - kill(&mut child); + terminate_tree(&mut child, KILL_GRACE); return Err(IdeError::cancelled()); } if Instant::now() >= deadline { - kill(&mut child); + terminate_tree(&mut child, KILL_GRACE); return Err(IdeError::unavailable("docker did not respond in time")); } std::thread::sleep(Duration::from_millis(10)); @@ -166,7 +148,7 @@ pub(crate) fn stream( let mut stderr_tail: Vec = Vec::new(); while open > 0 { if cancel.is_cancelled() { - kill(&mut child); + terminate_tree(&mut child, KILL_GRACE); return Err(IdeError::cancelled()); } match rx.recv_timeout(Duration::from_millis(50)) { @@ -189,7 +171,7 @@ pub(crate) fn stream( break status; } if cancel.is_cancelled() { - kill(&mut child); + terminate_tree(&mut child, KILL_GRACE); return Err(IdeError::cancelled()); } std::thread::sleep(Duration::from_millis(10)); diff --git a/crates/ide-github/Cargo.toml b/crates/ide-github/Cargo.toml index 0e33a8e80..5d67e56a2 100644 --- a/crates/ide-github/Cargo.toml +++ b/crates/ide-github/Cargo.toml @@ -13,6 +13,3 @@ serde_json.workspace = true specta.workspace = true tracing.workspace = true which = "8" - -[target.'cfg(unix)'.dependencies] -libc = "0.2" diff --git a/crates/ide-github/src/process.rs b/crates/ide-github/src/process.rs index d5eec750a..e945645b8 100644 --- a/crates/ide-github/src/process.rs +++ b/crates/ide-github/src/process.rs @@ -1,11 +1,16 @@ //! Child processes of the gh CLI: bounded runs and cancellable line streams. +use ide_core::process::terminate_tree; use ide_core::{CancellationToken, IdeError, IdeResult}; use std::io::Read; use std::process::{Child, Command, Stdio}; use std::sync::mpsc; use std::time::{Duration, Instant}; +/// How long a cancelled or timed-out child's process group (gh spawns git and helpers) +/// gets to exit on `SIGTERM` before `SIGKILL`: git removes its lock files on `SIGTERM`. +const KILL_GRACE: Duration = Duration::from_millis(20); + pub(crate) struct Output { pub stdout: String, pub stderr: String, @@ -20,36 +25,13 @@ pub(crate) enum Stream { fn prepare(cmd: &mut Command) { cmd.stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped()); - #[cfg(unix)] - { - use std::os::unix::process::CommandExt; - cmd.process_group(0); - } - ide_core::process::prepare(cmd); + ide_core::process::prepare(ide_core::process::own_group(cmd)); } fn spawn(cmd: &mut Command) -> IdeResult { cmd.spawn().map_err(|e| IdeError::unavailable(format!("could not run gh: {e}"))) } -/// Kills the child and, on Unix, its whole process group (gh spawns git and helpers). -pub(crate) fn kill(child: &mut Child) { - #[cfg(unix)] - { - let pid = child.id() as libc::pid_t; - // SAFETY: plain syscall; the child leads its own process group (`prepare`). - unsafe { - libc::kill(-pid, libc::SIGTERM); - } - std::thread::sleep(Duration::from_millis(20)); - unsafe { - libc::kill(-pid, libc::SIGKILL); - } - } - let _ = child.kill(); - let _ = child.wait(); -} - fn read_all(mut pipe: impl Read + Send + 'static) -> std::thread::JoinHandle { std::thread::spawn(move || { let mut buf = Vec::new(); @@ -92,11 +74,11 @@ pub(crate) fn run_with_input( break status; } if cancel.is_some_and(CancellationToken::is_cancelled) { - kill(&mut child); + terminate_tree(&mut child, KILL_GRACE); return Err(IdeError::cancelled()); } if Instant::now() >= deadline { - kill(&mut child); + terminate_tree(&mut child, KILL_GRACE); return Err(IdeError::unavailable("gh did not respond in time")); } std::thread::sleep(Duration::from_millis(10)); @@ -170,7 +152,7 @@ pub(crate) fn stream( let mut stderr_tail: Vec = Vec::new(); while open > 0 { if cancel.is_cancelled() { - kill(&mut child); + terminate_tree(&mut child, KILL_GRACE); return Err(IdeError::cancelled()); } match rx.recv_timeout(Duration::from_millis(50)) { @@ -193,7 +175,7 @@ pub(crate) fn stream( break status; } if cancel.is_cancelled() { - kill(&mut child); + terminate_tree(&mut child, KILL_GRACE); return Err(IdeError::cancelled()); } std::thread::sleep(Duration::from_millis(10)); diff --git a/crates/ide-tools/Cargo.toml b/crates/ide-tools/Cargo.toml index dadce8946..dea069bd2 100644 --- a/crates/ide-tools/Cargo.toml +++ b/crates/ide-tools/Cargo.toml @@ -20,8 +20,8 @@ zip.workspace = true sha2 = "0.10" hex = "0.4" -[target.'cfg(unix)'.dependencies] -libc = "0.2" - [dev-dependencies] tempfile.workspace = true + +[target.'cfg(unix)'.dev-dependencies] +libc = "0.2" diff --git a/crates/ide-tools/src/process.rs b/crates/ide-tools/src/process.rs index 11bc37b45..8a7694fe2 100644 --- a/crates/ide-tools/src/process.rs +++ b/crates/ide-tools/src/process.rs @@ -4,48 +4,16 @@ use crate::InstallEvent; use ide_core::error::ErrorKind; use ide_core::output_encoding::decode_tool_output; +use ide_core::process::{kill_tree, own_group}; use ide_core::{CancellationToken, IdeError, IdeResult}; use std::io::{BufRead, BufReader, Read}; use std::path::Path; -use std::process::{Child, Command, Stdio}; +use std::process::{Command, Stdio}; use std::sync::mpsc; use std::time::{Duration, Instant}; pub type Emit<'a> = &'a dyn Fn(InstallEvent); -/// Starts the child as the leader of its own process group, so [`kill`] reaches what it -/// starts in turn (`npm` → `node`, `go install` → the compiler, `pip` → build backends). -fn own_group(cmd: &mut Command) -> &mut Command { - #[cfg(unix)] - { - use std::os::unix::process::CommandExt; - cmd.process_group(0); - } - cmd -} - -/// Kills the child and everything it started. -fn kill(child: &mut Child) { - #[cfg(unix)] - if let Ok(pid) = i32::try_from(child.id()) { - // SAFETY: plain syscall; the child leads its own process group (`own_group`) and is not reaped yet. - unsafe { - libc::kill(-pid, libc::SIGKILL); - } - } - #[cfg(windows)] - { - let mut taskkill = Command::new("taskkill"); - ide_core::process::prepare(&mut taskkill) - .args(["/T", "/F", "/PID", &child.id().to_string()]) - .stdout(Stdio::null()) - .stderr(Stdio::null()); - let _ = taskkill.status(); - } - let _ = child.kill(); - let _ = child.wait(); -} - fn describe(cmd: &Command) -> String { let mut s = Path::new(cmd.get_program()).file_name().unwrap_or_default().to_string_lossy().into_owned(); for a in cmd.get_args() { @@ -100,7 +68,7 @@ pub fn run_collect(mut cmd: Command, cancel: &CancellationToken, emit: Emit) -> let mut output = String::new(); let status = loop { if cancel.is_cancelled() { - kill(&mut child); + kill_tree(&mut child); return Err(IdeError::cancelled()); } match rx.recv_timeout(Duration::from_millis(100)) { @@ -144,7 +112,7 @@ pub fn run_quiet(mut cmd: Command, timeout: Duration) -> Option<(bool, String)> Ok(Some(s)) => break s, Ok(None) if Instant::now() < deadline => std::thread::sleep(Duration::from_millis(15)), _ => { - kill(&mut child); + kill_tree(&mut child); return None; } } @@ -256,7 +224,7 @@ impl Http { }); let status = loop { if cancel.is_cancelled() { - kill(&mut child); + kill_tree(&mut child); return Err(IdeError::cancelled()); } match child.try_wait()? { @@ -308,7 +276,7 @@ impl Http { let mut last_bytes = 0; let status = loop { if cancel.is_cancelled() { - kill(&mut child); + kill_tree(&mut child); let _ = std::fs::remove_file(dest); return Err(IdeError::cancelled()); } @@ -413,7 +381,7 @@ pub(crate) fn run_input( } }; if result.is_err() { - kill(&mut child); + kill_tree(&mut child); } result?; let output = output.unwrap()?; diff --git a/crates/ide-vcs/src/cli.rs b/crates/ide-vcs/src/cli.rs index 051984ed4..0f865cea7 100644 --- a/crates/ide-vcs/src/cli.rs +++ b/crates/ide-vcs/src/cli.rs @@ -1,13 +1,18 @@ //! Thin wrapper around the `git` executable used for every mutating operation. +use ide_core::process::{own_group, terminate_tree}; use ide_core::{CancellationToken, IdeError, IdeResult}; use std::io::{Read, Write}; use std::path::{Path, PathBuf}; -use std::process::{Child, Command, Stdio}; +use std::process::{Command, Stdio}; use std::sync::OnceLock; use std::sync::mpsc; use std::time::{Duration, Instant}; +/// How long a cancelled git's process group (remote helpers, `ssh`, credential helpers) gets +/// to exit on `SIGTERM` before `SIGKILL`: git removes its `index.lock` on `SIGTERM`. +const KILL_GRACE: Duration = Duration::from_millis(20); + /// Explicit git executable from Settings › Version Control › Git (`None` = auto-detect). static CONFIGURED: std::sync::RwLock> = std::sync::RwLock::new(None); @@ -361,11 +366,7 @@ pub(crate) fn run_cancellable_raw(root: &Path, args: &[&str], cancel: &Cancellat } let mut cmd = command(root)?; cmd.args(args).stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped()); - #[cfg(unix)] - { - use std::os::unix::process::CommandExt; - cmd.process_group(0); - } + own_group(&mut cmd); let mut session = CommandSession::start(&mut cmd); let mut child = cmd.spawn().map_err(spawn_error)?; let capture = |mut pipe: Box| { @@ -378,8 +379,7 @@ pub(crate) fn run_cancellable_raw(root: &Path, args: &[&str], cancel: &Cancellat let stderr = capture(Box::new(child.stderr.take().expect("piped stderr"))); let status = loop { if cancel.is_cancelled() { - kill(&mut child); - let _ = child.wait(); + terminate_tree(&mut child, KILL_GRACE); let _ = stdout.join(); let _ = stderr.join(); return Err(IdeError::cancelled()); @@ -504,11 +504,7 @@ pub(crate) fn run_streaming_env( cmd.env(k, v); } cmd.args(args).stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped()); - #[cfg(unix)] - { - use std::os::unix::process::CommandExt; - cmd.process_group(0); - } + own_group(&mut cmd); tracing::debug!(?args, "git (streaming)"); let mut session = CommandSession::start(&mut cmd); let mut child = cmd.spawn().map_err(spawn_error)?; @@ -529,8 +525,7 @@ pub(crate) fn run_streaming_env( let mut open = readers.len(); while open > 0 { if cancel.is_cancelled() { - kill(&mut child); - let _ = child.wait(); + terminate_tree(&mut child, KILL_GRACE); for r in readers { let _ = r.join(); } @@ -559,8 +554,7 @@ pub(crate) fn run_streaming_env( break status; } if cancel.is_cancelled() { - kill(&mut child); - let _ = child.wait(); + terminate_tree(&mut child, KILL_GRACE); return Err(IdeError::cancelled()); } std::thread::sleep(Duration::from_millis(10)); @@ -644,22 +638,6 @@ fn spawn_reader( }) } -fn kill(child: &mut Child) { - #[cfg(unix)] - { - let pid = child.id() as libc::pid_t; - // SAFETY: plain syscall; the child was spawned as leader of its own process group. - unsafe { - libc::kill(-pid, libc::SIGTERM); - } - std::thread::sleep(Duration::from_millis(20)); - unsafe { - libc::kill(-pid, libc::SIGKILL); - } - } - let _ = child.kill(); -} - #[cfg(test)] mod command_session_tests { use super::*; From df9a9e0be0dadb159d7793f3481d1a0567a04fa0 Mon Sep 17 00:00:00 2001 From: kevinh <45650311+kevinh458@users.noreply.github.com> Date: Mon, 2 Nov 2026 20:21:14 +0800 Subject: [PATCH 021/106] feat(terminal): reorder terminal tabs by dragging them (#836) The Terminal tool window's tab strip uses the drag and drop Tabs already supports for editor tabs, with its own drag type so terminal and editor tabs don't mix; dropping moves the session tab to the insertion point. Fixes #775 Co-authored-by: kevinh458 <45650311+kevinh458@users.noreply.github.com> --- src/features/terminal/TerminalView.tsx | 10 ++++++++++ src/features/terminal/store.ts | 14 ++++++++++++++ src/features/terminal/terminal.test.ts | 16 ++++++++++++++++ 3 files changed, 40 insertions(+) diff --git a/src/features/terminal/TerminalView.tsx b/src/features/terminal/TerminalView.tsx index 12d5b0ae9..37ec86b0d 100644 --- a/src/features/terminal/TerminalView.tsx +++ b/src/features/terminal/TerminalView.tsx @@ -16,9 +16,11 @@ import { Tabs, type MenuItem, type TabItem, + type TabsDnd, } from "@/ui"; import { getSession, + moveTab, newTab, publishFocus, renameTab, @@ -36,6 +38,13 @@ import styles from "./Terminal.module.css"; const TAB_PREFIX = "openidea-terminal"; +/** Dragging a tab reorders the sessions, like IDEA's terminal tabs. */ +const TAB_DND: TabsDnd = { + type: "application/x-openidea-terminal-tab", + payload: (id) => id, + onDrop: (id, index) => moveTab(id, index), +}; + /** Full title when the label is trimmed, then the working directory. */ function tabTooltip(tab: TerminalTab, sessions: Parameters[1]): string | undefined { const full = tabFullTitle(tab, sessions); @@ -336,6 +345,7 @@ export function TerminalTabs() { onClose={(id) => void onClose(id)} onContextMenu={tabMenu} panelIdPrefix={TAB_PREFIX} + dnd={TAB_DND} trailing={
diff --git a/src/features/terminal/store.ts b/src/features/terminal/store.ts index 463bfb6b8..16c02e9b2 100644 --- a/src/features/terminal/store.ts +++ b/src/features/terminal/store.ts @@ -205,6 +205,20 @@ export function selectTab(id: string) { useTerminals.setState({ activeTab: id }); } +/** `tabs` with tab `id` moved to insertion `index`, an index into `tabs` before the move. */ +export function reorderTabs(tabs: readonly T[], id: string, index: number): T[] { + const from = tabs.findIndex((t) => t.id === id); + if (from < 0) return [...tabs]; + const rest = tabs.filter((t) => t.id !== id); + rest.splice(Math.max(0, Math.min(index > from ? index - 1 : index, rest.length)), 0, tabs[from]!); + return rest; +} + +/** Drag & drop in the tab strip. */ +export function moveTab(id: string, index: number) { + useTerminals.setState((s) => ({ tabs: reorderTabs(s.tabs, id, index) })); +} + export function renameTab(id: string, name: string) { const trimmed = name.trim(); useTerminals.setState((s) => ({ diff --git a/src/features/terminal/terminal.test.ts b/src/features/terminal/terminal.test.ts index 548cb5ed5..1a5edf3ce 100644 --- a/src/features/terminal/terminal.test.ts +++ b/src/features/terminal/terminal.test.ts @@ -8,6 +8,7 @@ import { layoutPanes, nextTabName, removeFromLayout, + reorderTabs, splitLayout, tabFullTitle, tabTitle, @@ -230,6 +231,21 @@ describe("tab names", () => { }); }); +describe("tab order", () => { + const ids = (tabs: { id: string }[]) => tabs.map((t) => t.id).join(""); + const tabs = [{ id: "a" }, { id: "b" }, { id: "c" }]; + + it("moves a dragged tab to the insertion index", () => { + expect(ids(reorderTabs(tabs, "a", 3))).toBe("bca"); + expect(ids(reorderTabs(tabs, "a", 2))).toBe("bac"); + expect(ids(reorderTabs(tabs, "c", 0))).toBe("cab"); + expect(ids(reorderTabs(tabs, "b", 1))).toBe("abc"); + expect(ids(reorderTabs(tabs, "b", 2))).toBe("abc"); + }); + + it("ignores unknown tabs", () => expect(ids(reorderTabs(tabs, "x", 0))).toBe("abc")); +}); + describe("terminal find", () => { it("formats the match counter", () => { expect(matchCounter(null, "x")).toBe(""); From e584f044d7213715d63cc14587d35a8552b515cd Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Mon, 2 Nov 2026 20:21:21 +0800 Subject: [PATCH 022/106] fix(java): say when a configured JDK path no longer exists (#764) A JDK path chosen in settings that was later removed (a version manager replacing it on upgrade) was reported as "is not a JDK home", which reads as a wrong folder layout. A missing path is now reported as missing, with how to fix it; the project JDK's log warning uses the same wording. Fixes #741 --- crates/ide-sdk/src/apply.rs | 19 ++++++++++++++----- crates/ide-sdk/src/tests.rs | 7 ++++++- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/crates/ide-sdk/src/apply.rs b/crates/ide-sdk/src/apply.rs index 8f60fa5d3..8c484f9fd 100644 --- a/crates/ide-sdk/src/apply.rs +++ b/crates/ide-sdk/src/apply.rs @@ -78,7 +78,7 @@ fn jdk_setting(root: &Path, value: &str) -> Option Option { match jdk_setting(root, &s.languages.java.jdk) { - Some(jdk) => jdk.inspect_err(|p| tracing::warn!("project JDK {} is not a JDK home", p.display())).ok(), + Some(jdk) => jdk.inspect_err(|p| tracing::warn!("project JDK {}", not_a_jdk(p))).ok(), None => pinned::pinned_install(root, Tool::Java).and_then(|(dir, _)| pinned::jdk_in_install(&dir)), } } @@ -92,10 +92,19 @@ pub fn jdtls_jdk(root: &Path, s: &Settings) -> Result, Err(crate::java::server_jdk_incomplete_message(&jdk, jdk.major.max(crate::java::JDTLS_MIN_JAVA))) } Some(Ok(jdk)) => Ok(Some(jdk)), - Some(Err(p)) => Err(format!( - "The language server JDK {} is not a JDK home (Settings › Languages & Frameworks › Java).", - p.display() - )), + Some(Err(p)) => { + Err(format!("The language server JDK {} (Settings › Languages & Frameworks › Java).", not_a_jdk(&p))) + } + } +} + +/// Why the configured JDK path `p` is not used: gone (e.g. a version manager removed +/// it on upgrade) or not a JDK. +fn not_a_jdk(p: &Path) -> String { + if p.exists() { + format!("{} is not a JDK home", p.display()) + } else { + format!("{} does not exist; choose another JDK or clear the setting to detect one", p.display()) } } diff --git a/crates/ide-sdk/src/tests.rs b/crates/ide-sdk/src/tests.rs index 5f3ed5a0a..dd4628b2e 100644 --- a/crates/ide-sdk/src/tests.rs +++ b/crates/ide-sdk/src/tests.rs @@ -319,8 +319,13 @@ fn jdtls_jdk_setting() { .unwrap(); assert!(jdtls_jdk(tmp.path(), &s).unwrap_err().contains("full JDK")); // A chosen path that is not a JDK is an error, not a silent fallback. - s.languages.java.jdtls_jdk = "missing".into(); + std::fs::create_dir(tmp.path().join("not-a-jdk")).unwrap(); + s.languages.java.jdtls_jdk = "not-a-jdk".into(); assert!(jdtls_jdk(tmp.path(), &s).unwrap_err().contains("is not a JDK home")); + // A JDK removed after it was chosen (a version manager upgraded it) says so. + s.languages.java.jdtls_jdk = "jdk17.0.1".into(); + let err = jdtls_jdk(tmp.path(), &s).unwrap_err(); + assert!(err.contains("jdk17.0.1 does not exist") && err.contains("Languages & Frameworks › Java"), "{err}"); } #[test] From a623e5d3851f91e723cfd4ec05af04e1e9d28d86 Mon Sep 17 00:00:00 2001 From: Andrea Basile Date: Mon, 2 Nov 2026 13:27:24 +0100 Subject: [PATCH 023/106] feat(python): add ruff language server for lint, format and organize imports (#859) In projects configured for Ruff (ruff.toml, .ruff.toml, or pyproject.toml with a [tool.ruff] table), ruff server runs next to basedpyright as a managed pip tool: its lint diagnostics, Reformat Code / format on save, Optimize Imports and Code Cleanup's fix-all. It is listed after the type checkers, so hover, navigation and rename stay with them; projects without a Ruff configuration never start or install it. Root markers accept file#table to require a TOML table, and a hand-defined server named ruff now only overrides how the built-in one launches. Verified with the real-servers end-to-end test against ruff and basedpyright. Co-authored-by: andreabasile <7715018+andreabasile@users.noreply.github.com> --- crates/ide-language/src/lib.rs | 4 +- crates/ide-language/src/markers.rs | 45 ++++++++++++++++++- crates/ide-language/src/registry.rs | 26 +++++++++++ crates/ide-lsp/tests/real_servers.rs | 56 ++++++++++++++++++++++++ crates/ide-sdk/src/tests.rs | 37 +++++++++++++--- crates/ide-tools/src/registry.rs | 11 +++++ crates/ide-tools/src/tests.rs | 1 + languages/python.toml | 10 +++++ src/features/tools/ServerSettings.tsx | 4 +- src/platform/locales/textCatalog.test.ts | 2 +- 10 files changed, 184 insertions(+), 12 deletions(-) diff --git a/crates/ide-language/src/lib.rs b/crates/ide-language/src/lib.rs index a42587cc8..908e8315c 100644 --- a/crates/ide-language/src/lib.rs +++ b/crates/ide-language/src/lib.rs @@ -96,7 +96,9 @@ pub struct LanguageServerConfig { pub args: Vec, #[serde(default)] pub env: BTreeMap, - /// Files whose presence marks a workspace root, e.g. `Cargo.toml`. + /// Files whose presence marks a workspace root, e.g. `Cargo.toml`; + /// `file#table` requires the TOML file to contain that dotted table, e.g. + /// `pyproject.toml#tool.ruff`. #[serde(default)] pub root_markers: Vec, /// Serve only files below a root marker, not the whole project: a framework's diff --git a/crates/ide-language/src/markers.rs b/crates/ide-language/src/markers.rs index 832dc0c63..05eb9b0ff 100644 --- a/crates/ide-language/src/markers.rs +++ b/crates/ide-language/src/markers.rs @@ -19,9 +19,13 @@ pub fn marked_root(project_root: Option<&Path>, file: &Path, markers: &[String]) best } -/// `dir` contains `marker`: a relative path, or a file name pattern with one -/// `*` (`*.csproj`). +/// `dir` contains `marker`: a relative path, a file name pattern with one +/// `*` (`*.csproj`), or a TOML file name (no pattern) and a dotted table it +/// must contain (`pyproject.toml#tool.ruff`). fn has_marker(dir: &Path, marker: &str) -> bool { + if let Some((file, table)) = marker.split_once('#') { + return has_toml_table(&dir.join(file), table); + } let Some((prefix, suffix)) = marker.split_once('*') else { return dir.join(marker).exists(); }; @@ -30,9 +34,23 @@ fn has_marker(dir: &Path, marker: &str) -> bool { }) } +/// `file` parses as TOML and has the dotted `table` (`tool.ruff`). +fn has_toml_table(file: &Path, table: &str) -> bool { + let Ok(text) = std::fs::read_to_string(file) else { return false }; + let value = match toml::from_str::(&text) { + Ok(value) => value, + Err(e) => { + tracing::debug!("root marker {}: {e}", file.display()); + return false; + } + }; + table.split('.').try_fold(&value, |v, key| v.get(key)).is_some_and(toml::Value::is_table) +} + /// `path` is (or, after its creation or removal, was) a root marker of the /// directory above it. pub(crate) fn is_marker_path(path: &Path, marker: &str) -> bool { + let marker = marker.split_once('#').map_or(marker, |(file, _)| file); match marker.split_once('*') { None => path.ends_with(marker), Some((prefix, suffix)) => { @@ -74,6 +92,29 @@ mod tests { assert_eq!(marked_root(None, &file, &markers), Some(home.to_path_buf())); } + #[test] + fn toml_markers_need_their_table() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let file = root.join("svc/app/main.py"); + std::fs::create_dir_all(file.parent().unwrap()).unwrap(); + let markers = vec!["ruff.toml".to_string(), "pyproject.toml#tool.ruff".to_string()]; + std::fs::write(root.join("pyproject.toml"), "[project]\nname = \"x\"\n[tool.black]\n").unwrap(); + assert_eq!(marked_root(Some(root), &file, &markers), None); + std::fs::write(root.join("svc/pyproject.toml"), "[tool.ruff.lint]\nselect = [\"E\"]\n").unwrap(); + assert_eq!(marked_root(Some(root), &file, &markers), Some(root.join("svc"))); + // Outermost wins: an inline table counts; a value that is no table, or a + // file that does not parse, does not. + std::fs::write(root.join("pyproject.toml"), "tool = { ruff = { line-length = 100 } }\n").unwrap(); + assert_eq!(marked_root(Some(root), &file, &markers), Some(root.to_path_buf())); + std::fs::write(root.join("pyproject.toml"), "tool = { ruff = 1 }\n").unwrap(); + assert_eq!(marked_root(Some(root), &file, &markers), Some(root.join("svc"))); + std::fs::write(root.join("pyproject.toml"), "[tool.ruff]\n[broken").unwrap(); + assert_eq!(marked_root(Some(root), &file, &markers), Some(root.join("svc"))); + assert!(is_marker_path(&root.join("svc/pyproject.toml"), "pyproject.toml#tool.ruff")); + assert!(!is_marker_path(&root.join("svc/setup.cfg"), "pyproject.toml#tool.ruff")); + } + #[test] fn patterns_match_file_names() { let tmp = tempfile::tempdir().unwrap(); diff --git a/crates/ide-language/src/registry.rs b/crates/ide-language/src/registry.rs index 584943590..d777ba370 100644 --- a/crates/ide-language/src/registry.rs +++ b/crates/ide-language/src/registry.rs @@ -604,6 +604,32 @@ mod tests { } } + #[test] + fn ruff_serves_python_files_only_below_its_config() { + let reg = reg(); + let cfg = reg.get(&LanguageId::new("python")).unwrap(); + let names: Vec<&str> = cfg.language_servers.iter().map(|s| s.name.as_str()).collect(); + // After the fallbacks: hover, rename and symbols stay with the type checker. + assert_eq!(names, ["basedpyright", "pyright", "ty", "ruff"]); + let ruff = cfg.language_servers.iter().find(|s| s.name == "ruff").unwrap(); + assert_eq!((ruff.command.as_str(), ruff.args.as_slice()), ("ruff", ["server".to_string()].as_slice())); + assert!(ruff.require_root_marker && !ruff.fallback); + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + for dir in ["lint", "dot", "pyproject", "plain"] { + std::fs::create_dir_all(root.join(dir)).unwrap(); + } + std::fs::write(root.join("lint/ruff.toml"), "").unwrap(); + std::fs::write(root.join("dot/.ruff.toml"), "").unwrap(); + std::fs::write(root.join("pyproject/pyproject.toml"), "[tool.ruff]\nline-length = 100\n").unwrap(); + std::fs::write(root.join("plain/pyproject.toml"), "[project]\nname = \"plain\"\n").unwrap(); + let served = |file: &str| crate::marked_root(Some(root), &root.join(file), &ruff.root_markers); + for dir in ["lint", "dot", "pyproject"] { + assert_eq!(served(&format!("{dir}/pkg/main.py")), Some(root.join(dir)), "{dir}"); + } + assert_eq!(served("plain/main.py"), None); + } + #[test] fn html_of_angular_projects_is_an_angular_template() { let reg = reg(); diff --git a/crates/ide-lsp/tests/real_servers.rs b/crates/ide-lsp/tests/real_servers.rs index f25f77745..3ce021f39 100644 --- a/crates/ide-lsp/tests/real_servers.rs +++ b/crates/ide-lsp/tests/real_servers.rs @@ -194,6 +194,62 @@ async fn ty_python_end_to_end() { .await; } +/// Ruff next to basedpyright in a project configured for Ruff: its lint +/// diagnostics, the only formatter and the only Organize Imports and Fix All. +#[tokio::test(flavor = "multi_thread")] +#[ignore = "requires ruff and basedpyright on PATH"] +async fn ruff_next_to_basedpyright_end_to_end() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().canonicalize().unwrap(); + std::fs::write(root.join("pyproject.toml"), "[tool.ruff.lint]\nselect = [\"F\", \"I\"]\n").unwrap(); + let path = root.join("example.py"); + let source = "import sys\nimport os\n\nx = os.getcwd( )\n"; + std::fs::write(&path, source).unwrap(); + let mut python = LanguageRegistry::load(None).unwrap().get(&LanguageId::new("python")).unwrap(); + python.language_servers.retain(|server| matches!(server.name.as_str(), "basedpyright" | "ruff")); + assert_eq!(python.language_servers.len(), 2); + let (mgr, events) = manager(&root, LanguageRegistry::from_configs(vec![python])); + let doc = DocId::next(); + mgr.did_open(doc, &path, &LanguageId::new("python"), 1, source); + for name in ["basedpyright", "ruff"] { + retry(name, Duration::from_secs(60), || { + let status = last_status(&events, name); + if let Some((ServerStatus::Crashed | ServerStatus::Missing | ServerStatus::Misconfigured, msg)) = &status { + panic!("{name} failed: {msg:?}"); + } + async move { matches!(status, Some((ServerStatus::Ready | ServerStatus::Busy, _))).then_some(()) } + }) + .await; + } + // F401 (`sys` unused) and I001 (unsorted imports). + let codes = retry("ruff diagnostics", Duration::from_secs(30), || { + let codes: Option> = events.lock().iter().rev().find_map(|e| match e { + IdeEvent::Diagnostics { source, items, .. } if source == "ruff" && !items.is_empty() => { + Some(items.iter().filter_map(|d| d.code.clone()).collect()) + } + _ => None, + }); + async move { codes } + }) + .await; + assert!(codes.iter().any(|c| c == "F401") && codes.iter().any(|c| c == "I001"), "{codes:?}"); + + let options = ide_lsp::types::FormatOptions { tab_size: 4, insert_spaces: true, on_save: false }; + let edits = mgr.format(doc, None, options).await.unwrap(); + assert!(!edits.is_empty() && edits.iter().all(|e| e.range.start.line == 3), "{edits:?}"); + + let whole = Range::new(Pos::new(0, 0), Pos::new(4, 0)); + for kind in ["source.organizeImports", "source.fixAll"] { + let actions = mgr.code_actions(doc, whole, vec![kind.into()]).await.unwrap(); + let kinds: Vec<&str> = actions.iter().filter_map(|a| a.kind.as_deref()).collect(); + assert_eq!(kinds, [format!("{kind}.ruff")], "{actions:?}"); + let action = mgr.resolve_code_action(doc, actions[0].clone()).await.unwrap(); + let edit = action.edit.expect("an edit"); + assert!(!edited_lines(&edit, &path).is_empty(), "{edit:?}"); + } + tokio::time::timeout(Duration::from_secs(10), mgr.shutdown_all()).await.unwrap(); +} + #[tokio::test(flavor = "multi_thread")] #[ignore = "requires rust-analyzer"] async fn rust_analyzer_end_to_end() { diff --git a/crates/ide-sdk/src/tests.rs b/crates/ide-sdk/src/tests.rs index dd4628b2e..3e121f241 100644 --- a/crates/ide-sdk/src/tests.rs +++ b/crates/ide-sdk/src/tests.rs @@ -254,10 +254,10 @@ fn language_server_settings_disable_add_and_override() { let mut s = Settings::default(); s.language_servers.insert("pylsp".into(), LanguageServerSettings { enabled: false, ..Default::default() }); s.language_servers.insert( - "ruff".into(), + "pylyzer".into(), LanguageServerSettings { - command: "ruff".into(), - args: Some(vec!["server".into()]), + command: "pylyzer".into(), + args: Some(vec!["--server".into()]), languages: vec!["python".into()], root_markers: vec!["pyproject.toml".into()], ..Default::default() @@ -265,17 +265,42 @@ fn language_server_settings_disable_add_and_override() { ); let defined = [server("basedpyright"), server("pylsp")]; let names = |v: Vec| v.into_iter().map(|c| c.name).collect::>(); - assert_eq!(names(language_servers("python", &defined, &s)), ["basedpyright", "ruff"]); + assert_eq!(names(language_servers("python", &defined, &s)), ["basedpyright", "pylyzer"]); assert_eq!(names(language_servers("rust", &[server("rust-analyzer")], &s)), ["rust-analyzer"]); // Listed with the tools even while disabled. let mut langs = ide_language::LanguageRegistry::load(None).unwrap().all(); with_custom_servers(&mut langs, &s); let python = langs.iter().find(|l| l.id.as_str() == "python").unwrap(); + let mut pylyzer = python.language_servers.iter().find(|c| c.name == "pylyzer").unwrap().clone(); + assert_eq!(pylyzer.root_markers, ["pyproject.toml"]); + server_launch_overrides(&mut pylyzer, &s); + assert_eq!((pylyzer.command.as_str(), pylyzer.args.as_slice()), ("pylyzer", &["--server".to_string()][..])); + + // A hand-defined server named like a built-in one only overrides how it + // launches: languages and root markers stay the built-in definition's. + s.language_servers.insert( + "ruff".into(), + LanguageServerSettings { + command: "/opt/ruff".into(), + args: Some(vec!["server".into(), "--preview".into()]), + languages: vec!["python".into()], + root_markers: vec!["pyproject.toml".into()], + ..Default::default() + }, + ); + let mut langs = ide_language::LanguageRegistry::load(None).unwrap().all(); + with_custom_servers(&mut langs, &s); + let python = langs.iter().find(|l| l.id.as_str() == "python").unwrap(); + assert_eq!(python.language_servers.iter().filter(|c| c.name == "ruff").count(), 1); let mut ruff = python.language_servers.iter().find(|c| c.name == "ruff").unwrap().clone(); - assert_eq!(ruff.root_markers, ["pyproject.toml"]); + assert_eq!(ruff.root_markers, ["ruff.toml", ".ruff.toml", "pyproject.toml#tool.ruff"]); + assert!(ruff.require_root_marker); server_launch_overrides(&mut ruff, &s); - assert_eq!((ruff.command.as_str(), ruff.args.as_slice()), ("ruff", &["server".to_string()][..])); + assert_eq!( + (ruff.command.as_str(), ruff.args.as_slice()), + ("/opt/ruff", &["server".to_string(), "--preview".to_string()][..]) + ); s.language_servers.insert( "rust-analyzer".into(), diff --git a/crates/ide-tools/src/registry.rs b/crates/ide-tools/src/registry.rs index 661a71705..755da795b 100644 --- a/crates/ide-tools/src/registry.rs +++ b/crates/ide-tools/src/registry.rs @@ -233,6 +233,17 @@ pub static TOOLS: &[ToolSpec] = &[ homepage: "https://docs.astral.sh/ty/", needs: &[], }, + ToolSpec { + name: "ruff", + display: "Ruff", + kind: LanguageServer, + commands: &["ruff"], + method: Method::PipVenv { package: "ruff" }, + fallback: None, + version: ver("ruff"), + homepage: "https://docs.astral.sh/ruff/", + needs: &[], + }, ToolSpec { name: "bash-language-server", display: "Bash Language Server", diff --git a/crates/ide-tools/src/tests.rs b/crates/ide-tools/src/tests.rs index 00a3c55ea..ccb35e2be 100644 --- a/crates/ide-tools/src/tests.rs +++ b/crates/ide-tools/src/tests.rs @@ -157,6 +157,7 @@ fn interchangeable_servers_list_their_alternatives() { assert_eq!(find(&list, "vtsls").alternatives, ["vtsls", "typescript-language-server"]); // A regular server after a fallback starts no group with it. assert!(find(&list, "angular-language-server").alternatives.is_empty()); + assert!(find(&list, "ruff").alternatives.is_empty()); assert!(find(&list, "rust-analyzer").alternatives.is_empty()); } diff --git a/languages/python.toml b/languages/python.toml index b450c3dd7..1d3b9504b 100644 --- a/languages/python.toml +++ b/languages/python.toml @@ -40,6 +40,16 @@ args = ["server"] root-markers = ["ty.toml", "pyproject.toml", "setup.py", "setup.cfg", "requirements.txt", "Pipfile"] install-hint = "uv tool install ty (or: pip install ty)" +# Lint, format, organize imports and fix all next to the type checker, in +# projects configured for Ruff (formatting is Ruff's alone: basedpyright has none). +[[language-servers]] +name = "ruff" +command = "ruff" +args = ["server"] +root-markers = ["ruff.toml", ".ruff.toml", "pyproject.toml#tool.ruff"] +require-root-marker = true +install-hint = "pip install ruff (or: uv tool install ruff)" + [debugger] adapter = "debugpy" command = "python3" diff --git a/src/features/tools/ServerSettings.tsx b/src/features/tools/ServerSettings.tsx index 2e9710e45..3f10ea893 100644 --- a/src/features/tools/ServerSettings.tsx +++ b/src/features/tools/ServerSettings.tsx @@ -553,7 +553,7 @@ export function NewServerDialog({ const [commandLine, setCommandLine] = useState(""); const [name, setName] = useState(null); const [languages, setLanguages] = useState([]); - // "ruff server": the executable, then its arguments. + // "pylyzer --server": the executable, then its arguments. let words: string[] = []; let quoteError: string | null = null; try { @@ -600,7 +600,7 @@ export function NewServerDialog({ autoFocus className={styles.grow} value={commandLine} - placeholder="ruff server" + placeholder="pylyzer --server" onChange={(e) => setCommandLine(e.target.value)} /> ([ "NAME=value; OTHER=value", "node_modules/typescript", "PascalCase", + "pylyzer --server", "pyproject.toml, .git", - "ruff server", "run pytest", "snake_case", "tests", From 8ea8c98a39c5d862e030fc2faea93c282cb9d05b Mon Sep 17 00:00:00 2001 From: ntbang0901 <121003585+ntbang0901@users.noreply.github.com> Date: Mon, 2 Nov 2026 19:37:03 +0700 Subject: [PATCH 024/106] feat(maven): reload a stale Maven build after external changes to its files (#780) When a pom.xml changes outside the IDE (a VCS update, another tool) and the model is stale afterwards, the Maven build now reloads on its own; edits saved from the editor still only show "Load Maven Changes". A change counts as external unless the IDE itself saved that file moments before, so a build file open in a tab that a tool rewrites also reloads. Co-authored-by: ntbang0901 <121003585+ntbang0901@users.noreply.github.com> --- src/features/build-tools/autoReload.test.ts | 68 +++++++++++++++++++++ src/features/build-tools/index.ts | 14 ++++- src/features/build-tools/store.ts | 40 ++++++++++++ 3 files changed, 121 insertions(+), 1 deletion(-) create mode 100644 src/features/build-tools/autoReload.test.ts diff --git a/src/features/build-tools/autoReload.test.ts b/src/features/build-tools/autoReload.test.ts new file mode 100644 index 000000000..90eaa015f --- /dev/null +++ b/src/features/build-tools/autoReload.test.ts @@ -0,0 +1,68 @@ +import { beforeEach, expect, it, vi } from "vitest"; +import type { BuildModel, MavenModel, ProjectInfo } from "@/platform/ipc"; +import { useProject } from "@/platform/project"; +import { changedOutsideIde, loadModel, noteExternalBuildChange, noteIdeSave, useBuild } from "./store"; + +const mocks = vi.hoisted(() => ({ model: vi.fn(), sync: vi.fn() })); +vi.mock("@/platform/ipc", async (original) => { + const real = await original(); + return { + ...real, + isTauri: () => true, + call: (value: unknown) => value, + commands: { + ...real.commands, + buildModel: mocks.model, + runConfigs: () => Promise.resolve([]), + buildSync: mocks.sync, + cancelRequest: () => Promise.resolve(), + }, + }; +}); +vi.mock("@tauri-apps/api/core", async (original) => ({ + ...(await original()), + Channel: class { + onmessage: unknown = null; + }, +})); + +const maven = (stale: boolean) => + ({ syncedAt: 1, stale, syncError: null, profiles: [], projects: [] }) as unknown as MavenModel; +const model = (stale: boolean): BuildModel => ({ maven: maven(stale), gradle: null }); + +beforeEach(() => { + vi.clearAllMocks(); + mocks.sync.mockImplementation(() => new Promise(() => {})); + useBuild.setState({ syncing: {} }); + useProject.setState({ project: { root: "/p", trusted: true } as ProjectInfo }); +}); + +it("reloads a build made stale by a change outside the editor, not by an edit in it", async () => { + mocks.model.mockResolvedValue(model(true)); + await loadModel(); + expect(mocks.sync).not.toHaveBeenCalled(); + + noteExternalBuildChange(); + await loadModel(); + expect(mocks.sync).toHaveBeenCalledTimes(1); + expect(mocks.sync.mock.calls[0]![0]).toBe("maven"); + expect(useBuild.getState().model?.maven?.stale).toBe(true); +}); + +it("leaves an up-to-date build alone after an external change", async () => { + mocks.model.mockResolvedValue(model(false)); + noteExternalBuildChange(); + await loadModel(); + expect(mocks.sync).not.toHaveBeenCalled(); +}); + +it("tells the IDE's own saves of a build file apart from changes made outside it", () => { + noteIdeSave("/p/pom.xml", 1000); + expect(changedOutsideIde(["/p/pom.xml"], 2000)).toBe(false); + // A tool rewrites the same file later, while it is still open in a tab. + expect(changedOutsideIde(["/p/pom.xml"], 10_000)).toBe(true); + // Never saved by the IDE: a VCS update. + expect(changedOutsideIde(["/p/core/pom.xml"], 2000)).toBe(true); + noteIdeSave("/p/pom.xml", 20_000); + expect(changedOutsideIde(["/p/pom.xml", "/p/core/pom.xml"], 20_500)).toBe(true); +}); diff --git a/src/features/build-tools/index.ts b/src/features/build-tools/index.ts index e80e103e2..172d3e3af 100644 --- a/src/features/build-tools/index.ts +++ b/src/features/build-tools/index.ts @@ -3,6 +3,7 @@ * project has that build system, like IDEA. The model is re-read when build * files change; running goals / tasks goes through the run feature's actions. */ +import { onEditorEvent } from "@/editor/api"; import { executeAction, registerActions } from "@/platform/actions"; import { setContext } from "@/platform/context"; import { onIdeEvent, type BuildModel, type BuildTool } from "@/platform/ipc"; @@ -25,6 +26,9 @@ import { unlinkProjects, cancelSync, loadModel, + changedOutsideIde, + noteExternalBuildChange, + noteIdeSave, openAnalyzer, resetBuild, setExpanded, @@ -158,8 +162,16 @@ export function activate(): () => void { resetBuild(); if (s.project) void loadModel(); }), + onEditorEvent((e) => { + if (e.type === "docSaved" && BUILD_FILE.test(e.path)) noteIdeSave(e.path); + }), onIdeEvent("fsChanged", (e) => { - if ([...e.created, ...e.modified, ...e.removed].some((p) => BUILD_FILE.test(p))) scheduleLoad(); + const changed = [...e.created, ...e.modified, ...e.removed].filter((p) => BUILD_FILE.test(p)); + if (!changed.length) return; + // Changes the IDE's own saves didn't make came from outside (a VCS update, another tool), + // also for a build file open in a tab. + if (changedOutsideIde(changed)) noteExternalBuildChange(); + scheduleLoad(); }), onIdeEvent("projectRootsChanged", scheduleLoad), // Project JDK / Maven home and options are applied to the model's launchers. diff --git a/src/features/build-tools/store.ts b/src/features/build-tools/store.ts index 2b6c8a5d8..f3555e4c6 100644 --- a/src/features/build-tools/store.ts +++ b/src/features/build-tools/store.ts @@ -142,6 +142,41 @@ export function pushHistory(tool: BuildTool, line: string) { // Model let generation = 0; +let externalChange = false; + +/** + * A build file changed outside the editor (VCS update, another tool): the next model load + * reloads a build it finds stale, IDEA's default "Reload after external changes". Edits in + * the editor only show "Load … Changes". + */ +export function noteExternalBuildChange() { + externalChange = true; +} + +/** How long after the IDE saves a build file its file-system events still count as that save. */ +const IDE_SAVE_WINDOW_MS = 5000; +/** Build files the IDE saved, with when: their file-system events are edits made in the IDE. */ +const savedByIde = new Map(); + +/** The IDE saved `path` (an editor save, not a tool rewriting it). */ +export function noteIdeSave(path: string, now = Date.now()) { + savedByIde.set(path, now); +} + +/** + * Whether any of the changed build files was written by something other than the IDE's own + * save: a VCS update or a tool, also when the file is open in an editor tab. + */ +export function changedOutsideIde(paths: readonly string[], now = Date.now()): boolean { + let external = false; + for (const path of paths) { + const at = savedByIde.get(path); + if (at !== undefined && now - at <= IDE_SAVE_WINDOW_MS) continue; + savedByIde.delete(path); + external = true; + } + return external; +} /** Re-reads the build model (and the run configurations) of the open project. */ export async function loadModel(): Promise { @@ -167,6 +202,11 @@ export async function loadModel(): Promise { ]); if (gen !== generation) return; useBuild.setState({ model, configs, loading: false, error: null }); + if (externalChange) { + externalChange = false; + const maven = model.maven; + if (maven?.stale && maven.syncedAt !== null && !useBuild.getState().syncing.maven) void syncTool("maven"); + } } catch (e) { if (gen === generation) useBuild.setState({ loading: false, error: e instanceof Error ? e.message : String(e) }); } From 795f8e85f1598ce1c7f4a35e60db9df7e63b27ef Mon Sep 17 00:00:00 2001 From: arookieofc <2128194521@qq.com> Date: Sun, 1 Nov 2026 02:25:48 +0800 Subject: [PATCH 025/106] fix(windows): de-parent settings window on Windows so DWM paints its shadow An owned child (WS_POPUP) of a borderless parent gets no DWM shadow, leaving the frameless settings window flat against its project window. Ownership is already tracked by the settings- label (parent-close cleanup in on_window_event, menu modality), so make the settings window top-level on Windows; macOS and Linux keep the native parent link. --- src-tauri/src/windows.rs | 10 +++++++--- src/features/settings/window/bridge.ts | 6 ++++-- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/src-tauri/src/windows.rs b/src-tauri/src/windows.rs index b9d14c122..a47340ade 100644 --- a/src-tauri/src/windows.rs +++ b/src-tauri/src/windows.rs @@ -796,9 +796,13 @@ pub fn open_settings_window( #[cfg(target_os = "linux")] crate::linux_window_state::prepare(app, &mut config); let builder = WebviewWindowBuilder::from_config(app, &config); - // AppKit child windows can disappear when dragged to another display's Space. - // The settings label already tracks ownership and parent-close cleanup. - #[cfg(not(target_os = "macos"))] + // Windows: an owned child (WS_POPUP) of a borderless parent gets no DWM + // shadow, leaving the frameless settings window flat against its parent. + // Ownership is tracked by the `settings-` label instead + // (parent-close cleanup, menu modality), so only Linux keeps the native + // parent link. AppKit child windows can disappear when dragged to another + // display's Space, so macOS keeps it too. + #[cfg(target_os = "linux")] let builder = builder.and_then(|b| b.parent(&parent)); let window = builder .and_then(|b| dev_font_preferences(b).build()) diff --git a/src/features/settings/window/bridge.ts b/src/features/settings/window/bridge.ts index 3aa202099..e78722cf2 100644 --- a/src/features/settings/window/bridge.ts +++ b/src/features/settings/window/bridge.ts @@ -1,8 +1,10 @@ /** * Settings window ↔ its project window. * - * Settings live in a native child window `settings-` (see - * src-tauri/src/windows.rs) that only runs the settings pages. The action + * Settings live in a separate window `settings-` (see + * src-tauri/src/windows.rs) that only runs the settings pages (a native + * child of the parent on macOS/Linux; top-level on Windows so DWM paints + * its shadow). The action * registry stays in the project window: it sends the settings window a * description of every action (Keymap page, links that run actions) and runs * the actions the settings window forwards. From 58ff3e93d04dbce5091ff2b9984147086d9a4ed7 Mon Sep 17 00:00:00 2001 From: arookieofc <2128194521@qq.com> Date: Mon, 2 Nov 2026 19:04:25 +0800 Subject: [PATCH 026/106] fix(linux): paint a hairline edge on the frameless settings window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Linux compositors draw no shadow for undecorated windows (Wayland has no server-side decorations at all), so the frameless settings window is indistinguishable from the desktop there — #595's follow-up after the Windows DWM-shadow fix. Paint a 1px --border-window inset ring around the window root: the webview draws the same hairline on every windowing system. --- .../settings/__tests__/dialog.test.tsx | 20 +++++++++++++++++++ .../settings/dialog/SettingsDialog.module.css | 5 +++++ .../settings/window/SettingsWindow.tsx | 6 ++++-- 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/src/features/settings/__tests__/dialog.test.tsx b/src/features/settings/__tests__/dialog.test.tsx index a72f47525..a187bf413 100644 --- a/src/features/settings/__tests__/dialog.test.tsx +++ b/src/features/settings/__tests__/dialog.test.tsx @@ -22,6 +22,7 @@ vi.mock("@/platform/ipc", async (importOriginal) => { }); import { registerAction, executeAction } from "@/platform/actions"; +import { usePlatform } from "@/platform/platformInfo"; import { useProject } from "@/platform/project"; import { useKeymap } from "@/platform/keymap"; import { ContextMenuHost } from "@/ui"; @@ -29,6 +30,8 @@ import { PageContext, useSetting } from "../components/context"; import { DEFAULT_SETTINGS, setAt } from "../model/schema"; import { openSettings, stage, useSettingsDialog } from "../model/staging"; import { SettingsDialog } from "../dialog/SettingsDialog"; +import { SettingsWindow } from "../window/SettingsWindow"; +import dialogStyles from "../dialog/SettingsDialog.module.css"; import { activate } from ".."; import { MessagesHost } from "@/workbench/messages"; @@ -471,6 +474,23 @@ describe("Settings dialog", () => { }); }); +describe("native Settings window root", () => { + it("marks the window edge on Linux, whose frameless windows get no compositor shadow", () => { + const saved = usePlatform.getState(); + usePlatform.setState({ os: "linux" }); + let view = render(); + let root = document.querySelector(`.${dialogStyles.window}`); + expect(root).toHaveClass(dialogStyles.windowEdge!); + view.unmount(); + usePlatform.setState({ os: "windows" }); + view = render(); + root = document.querySelector(`.${dialogStyles.window}`); + expect(root).not.toHaveClass(dialogStyles.windowEdge!); + view.unmount(); + usePlatform.setState(saved); + }); +}); + describe("native Settings window background", () => { it.each([ ["a hex frame", "#26282c", "", "#26282c"], diff --git a/src/features/settings/dialog/SettingsDialog.module.css b/src/features/settings/dialog/SettingsDialog.module.css index 24fdea935..0a1105abb 100644 --- a/src/features/settings/dialog/SettingsDialog.module.css +++ b/src/features/settings/dialog/SettingsDialog.module.css @@ -203,6 +203,11 @@ background: var(--bg-dialog, var(--bg-elev)); color: var(--fg); } +/* Linux draws no shadow for a frameless window (Wayland/KDE give undecorated + windows no CSD), so mark its edge in CSS instead. */ +.windowEdge { + box-shadow: inset 0 0 0 1px var(--border-window); +} .windowBody { flex: 1; min-height: 0; diff --git a/src/features/settings/window/SettingsWindow.tsx b/src/features/settings/window/SettingsWindow.tsx index e55094905..231a60640 100644 --- a/src/features/settings/window/SettingsWindow.tsx +++ b/src/features/settings/window/SettingsWindow.tsx @@ -1,4 +1,5 @@ /** Content of the native Settings window: title bar, page tree + page, buttons. */ +import clsx from "clsx"; import { useEffect, useRef } from "react"; import { OverlayHost } from "@/workbench/OverlayHost"; import { usePlatform } from "@/platform/platformInfo"; @@ -10,6 +11,7 @@ import styles from "../dialog/SettingsDialog.module.css"; export function SettingsWindow() { useApplyTheme(); + const os = usePlatform((s) => s.os); const ownTitleBar = usePlatform((s) => s.overlayTitleBar || s.framelessTitleBar); const open = useSettingsDialog((s) => s.open); const title = useSettingsTitle(); @@ -20,8 +22,8 @@ export function SettingsWindow() { }, [open]); return ( -
- {/* macOS overlays traffic lights on it; Windows is frameless. Linux keeps the OS title bar. */} +
+ {/* macOS overlays traffic lights on it; Windows and Linux are frameless (tauri..conf.json). */} {ownTitleBar && } {open && ( <> From 4e9442b499079d85794d207729f4d8b4d7f369dd Mon Sep 17 00:00:00 2001 From: misswell Date: Mon, 2 Nov 2026 22:18:11 +0800 Subject: [PATCH 027/106] feat(search): find a Spring endpoint by its request path (#820) Search Everywhere lists Spring Web endpoints matched by request path (context path tolerated) or handler method name, and Enter opens the handler. The host scans Java/Kotlin sources that name a mapping; http-client parses and indexes them and hands the entries to search through an endpoint contribution, so search does not import http-client. The index is invalidated when a Java/Kotlin file changes. Route source path assertions compare by path component so they hold on Windows. Co-authored-by: misswell <28730815+misswell@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 --- crates/ide-host/src/ops/mod.rs | 1 + crates/ide-host/src/ops/routes.rs | 134 ++++++++++++++++++ src-tauri/src/commands/mod.rs | 1 + src-tauri/src/commands/nav.rs | 10 ++ src/bindings.ts | 7 + src/features/http-client/index.ts | 2 + .../http-client/spring/routeIndex.test.ts | 78 ++++++++++ src/features/http-client/spring/routeIndex.ts | 80 +++++++++++ .../search/__tests__/controller.test.ts | 27 ++++ .../search/__tests__/everywhereModel.test.ts | 2 +- src/features/search/__tests__/routes.test.ts | 61 ++++++++ src/features/search/everywhere/controller.ts | 16 ++- src/features/search/everywhere/deps.ts | 3 +- src/features/search/everywhere/model.ts | 11 +- src/features/search/everywhere/routes.ts | 79 +++++++++++ src/workbench/contributions.ts | 35 +++++ src/workbench/endpointProviders.test.ts | 46 ++++++ 17 files changed, 587 insertions(+), 6 deletions(-) create mode 100644 crates/ide-host/src/ops/routes.rs create mode 100644 src/features/http-client/spring/routeIndex.test.ts create mode 100644 src/features/http-client/spring/routeIndex.ts create mode 100644 src/features/search/__tests__/routes.test.ts create mode 100644 src/features/search/everywhere/routes.ts create mode 100644 src/workbench/endpointProviders.test.ts diff --git a/crates/ide-host/src/ops/mod.rs b/crates/ide-host/src/ops/mod.rs index 93a0b28fe..c29dc4e95 100644 --- a/crates/ide-host/src/ops/mod.rs +++ b/crates/ide-host/src/ops/mod.rs @@ -22,6 +22,7 @@ pub mod notebook; pub mod project; mod python; pub mod refactor; +pub mod routes; pub mod run; pub mod sdk; pub mod search; diff --git a/crates/ide-host/src/ops/routes.rs b/crates/ide-host/src/ops/routes.rs new file mode 100644 index 000000000..cbf2965e0 --- /dev/null +++ b/crates/ide-host/src/ops/routes.rs @@ -0,0 +1,134 @@ +//! Spring Web mappings, source side: the project's Java and Kotlin files that could +//! declare a route, with their text. The UI owns the annotation parser and the index +//! it feeds, so this side stays a cheap scan. + +use crate::Host; +use ide_core::IdeResult; +use serde::{Deserialize, Serialize}; +use specta::Type; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; +use std::sync::atomic::{AtomicUsize, Ordering}; + +/// Files handed out at most, and bytes in total: a project's controllers fit well +/// inside these, and the UI indexes them once. +const MAX_FILES: usize = 1024; +const MAX_BYTES: usize = 48 << 20; +/// A single source bigger than this is not a controller. +const MAX_FILE_BYTES: u64 = 4 << 20; + +#[derive(Clone, Debug, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct RouteSource { + pub path: String, + pub text: String, +} + +/// The files Spring mappings can live in: Java and Kotlin sources. +fn declares_mappings(path: &Path) -> bool { + matches!(path.extension().and_then(|e| e.to_str()), Some("java" | "kt" | "kts")) +} + +/// Files under `root` whose text names a mapping: `ignore` rules and `excludes` +/// decide what counts, and the UI ranks the matches itself. +pub(crate) fn sources_in(root: &Path, excludes: &[String]) -> Vec { + let found: Mutex> = Mutex::new(Vec::new()); + let bytes = AtomicUsize::new(0); + ide_vfs::walk_files(root, excludes, |path| { + if !declares_mappings(path) || bytes.load(Ordering::Relaxed) >= MAX_BYTES { + return; + } + if std::fs::metadata(path).map_or(true, |m| m.len() > MAX_FILE_BYTES) { + return; + } + let Ok(text) = std::fs::read_to_string(path) else { return }; + // Only a file that names a mapping annotation is worth parsing. + if !text.contains("Mapping") { + return; + } + let Ok(mut found) = found.lock() else { return }; + if found.len() >= MAX_FILES { + return; + } + bytes.fetch_add(text.len(), Ordering::Relaxed); + found.push(RouteSource { path: path.to_string_lossy().into_owned(), text }); + }); + let mut files = found.into_inner().unwrap_or_default(); + files.sort_by(|a, b| a.path.cmp(&b.path)); + files +} + +/// The folders to scan: the project folder itself first, then content roots outside it +/// (`content_roots` lists only the ones outside, so the project folder is never implied — +/// forgetting it scans nothing). +fn scan_roots(root: &Path, content_roots: Vec) -> Vec { + let mut roots = vec![root.to_path_buf()]; + roots.extend(content_roots); + roots +} + +/// Every mapping source of the project, for the request-path index. +pub fn spring_route_sources(host: &Host, window: &str) -> IdeResult> { + let project = host.project(window)?; + let excludes = project.search_excludes(); + let mut out = Vec::new(); + for root in scan_roots(&project.root, project.content_roots()) { + out.extend(sources_in(&root, &excludes)); + } + out.sort_by(|a, b| a.path.cmp(&b.path)); + out.dedup_by(|a, b| a.path == b.path); + match out.len() { + 0 => tracing::warn!("spring routes: no mapping source under {:?} (excludes: {})", project.root, excludes.len()), + n => tracing::info!("spring routes: {n} mapping sources under {:?}", project.root), + } + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_project_folder_is_scanned_even_without_other_content_roots() { + let root = Path::new("/p/demo"); + assert_eq!(scan_roots(root, Vec::new()), [PathBuf::from("/p/demo")]); + assert_eq!( + scan_roots(root, vec![PathBuf::from("/p/elsewhere")]), + [PathBuf::from("/p/demo"), PathBuf::from("/p/elsewhere")] + ); + } + + #[test] + fn only_java_and_kotlin_sources_are_read() { + for (name, expected) in + [("A.java", true), ("B.kt", true), ("C.kts", true), ("D.txt", false), ("pom.xml", false), ("noext", false)] + { + assert_eq!(declares_mappings(Path::new(name)), expected, "{name}"); + } + } + + #[test] + fn files_that_name_no_mapping_are_left_out() { + let tmp = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(tmp.path().join("src")).unwrap(); + std::fs::write(tmp.path().join("src/Ctl.java"), "class Ctl { @GetMapping(\"/a\") void a() {} }").unwrap(); + std::fs::write(tmp.path().join("src/Plain.java"), "class Plain { void a() {} }").unwrap(); + std::fs::write(tmp.path().join("src/Notes.txt"), "@GetMapping(\"/a\")").unwrap(); + let files = sources_in(tmp.path(), &[]); + assert_eq!(files.len(), 1, "{:?}", files.iter().map(|f| &f.path).collect::>()); + assert!(Path::new(&files[0].path).ends_with("src/Ctl.java"), "{}", files[0].path); + assert!(files[0].text.contains("@GetMapping")); + } + + #[test] + fn excluded_folders_are_skipped() { + let tmp = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(tmp.path().join("module/src")).unwrap(); + std::fs::create_dir_all(tmp.path().join("build")).unwrap(); + std::fs::write(tmp.path().join("module/src/A.java"), "@RequestMapping(\"/a\")").unwrap(); + std::fs::write(tmp.path().join("build/B.java"), "@RequestMapping(\"/b\")").unwrap(); + let files = sources_in(tmp.path(), &["build/**".to_owned()]); + assert_eq!(files.len(), 1, "{:?}", files.iter().map(|f| &f.path).collect::>()); + assert!(Path::new(&files[0].path).ends_with("module/src/A.java"), "{}", files[0].path); + } +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 4457d1381..1ef4d111b 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -182,6 +182,7 @@ pub fn builder() -> tauri_specta::Builder { caches::caches_schedule_invalidation, caches::app_restart, nav::index_status, + nav::spring_route_sources, nav::find_files, nav::find_symbols, nav::mark_recent, diff --git a/src-tauri/src/commands/nav.rs b/src-tauri/src/commands/nav.rs index 669fe850a..dd1e1d5f4 100644 --- a/src-tauri/src/commands/nav.rs +++ b/src-tauri/src/commands/nav.rs @@ -22,6 +22,16 @@ pub fn find_files(window: Window, state: State<'_, AppState>, query: String, lim ide_host::ops::nav::find_files(&state.host, window.label(), query, limit) } +/// Spring Web mapping sources for the request-path index of Search Everywhere. +#[tauri::command] +#[specta::specta] +pub fn spring_route_sources( + window: Window, + state: State<'_, AppState>, +) -> IdeResult> { + ide_host::ops::routes::spring_route_sources(&state.host, window.label()) +} + #[tauri::command] #[specta::specta] pub fn find_symbols( diff --git a/src/bindings.ts b/src/bindings.ts index 7c4a2d955..e76c5885f 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -310,6 +310,8 @@ export const commands = { /** Quits through every window's unsaved-changes check, then starts again. */ appRestart: () => __TAURI_INVOKE("app_restart"), indexStatus: () => typedError(__TAURI_INVOKE("index_status")), + /** Spring Web mapping sources for the request-path index of Search Everywhere. */ + springRouteSources: () => typedError(__TAURI_INVOKE("spring_route_sources")), findFiles: (query: string, limit: number) => typedError(__TAURI_INVOKE("find_files", { query, limit })), findSymbols: (query: string, filter: SymbolFilter, limit: number) => typedError(__TAURI_INVOKE("find_symbols", { query, filter, limit })), markRecent: (path: string) => typedError(__TAURI_INVOKE("mark_recent", { path })), @@ -5312,6 +5314,11 @@ export type RevisionChange = { export type RootKind = "source" | "test" | "resources" | "testResources" | "excluded"; +export type RouteSource = { + path: string, + text: string, +}; + export type RunConfig = RunConfig_Serialize | RunConfig_Deserialize; export type RunConfig_Deserialize = { diff --git a/src/features/http-client/index.ts b/src/features/http-client/index.ts index c4ec7c327..a745a95a8 100644 --- a/src/features/http-client/index.ts +++ b/src/features/http-client/index.ts @@ -28,6 +28,7 @@ import { menuGroupItems, springEditorExtension, } from "./spring/editor"; +import { registerSpringEndpointProvider } from "./spring/routeIndex"; const SPRING_WHEN = "editorLangId == java || editorLangId == kotlin"; @@ -112,6 +113,7 @@ async function newRequest(directory?: string) { export function activate(): () => void { const disposers = [ + registerSpringEndpointProvider(), useProject.subscribe((s, prev) => { if (s.project?.root !== prev.project?.root) resetHttpClient(); }), diff --git a/src/features/http-client/spring/routeIndex.test.ts b/src/features/http-client/spring/routeIndex.test.ts new file mode 100644 index 000000000..6299973a5 --- /dev/null +++ b/src/features/http-client/spring/routeIndex.test.ts @@ -0,0 +1,78 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { commands, emitLocalIdeEvent } from "@/platform/ipc"; +import { ipcPath } from "@/platform/paths"; +import { searchEndpoints } from "@/workbench/contributions"; +import { clearRouteIndex, isRouteSource, registerSpringEndpointProvider, routeIndex } from "./routeIndex"; + +const scanSources = vi.spyOn(commands, "springRouteSources"); + +const source = (method = "searchMenus", url = "/menu/search") => ({ + path: "/p/DeepLinkController.java", + text: `@RestController +@RequestMapping("/deeplink") +class DeepLinkController { + @PostMapping("${url}") + String ${method}() { return "ok"; } +}`, +}); + +let dispose: (() => void) | undefined; +afterEach(() => { + dispose?.(); + dispose = undefined; + clearRouteIndex(); + scanSources.mockReset(); +}); + +describe("the request-path index follows the workspace", () => { + it("is invalidated by a change to a mapping source", () => { + for (const [path, expected] of [ + ["/p/A.java", true], + ["/p/B.kt", true], + ["/p/C.kts", true], + ["/p/pom.xml", false], + ["/p/notes.md", false], + ] as const) { + expect(isRouteSource(path), path).toBe(expected); + } + }); +}); + +describe("the Spring endpoint provider", () => { + it("scans once per root with method coordinates and rescans for another root", async () => { + scanSources.mockResolvedValue({ status: "ok", data: [source()] }); + expect(await routeIndex(null)).toEqual([]); + const first = await routeIndex("/p"); + expect(first).toEqual([ + { + method: "POST", + url: "/deeplink/menu/search", + controller: "DeepLinkController", + methodName: "searchMenus", + path: "/p/DeepLinkController.java", + rel: "DeepLinkController.java", + line: 4, + col: 11, + }, + ]); + expect(await routeIndex("/p")).toBe(first); + expect(scanSources).toHaveBeenCalledTimes(1); + expect(await routeIndex("/other")).toEqual([]); + expect(scanSources).toHaveBeenCalledTimes(2); + }); + + it.each(["created", "modified", "removed"] as const)("invalidates on %s mapping sources", async (kind) => { + scanSources.mockResolvedValue({ status: "ok", data: [source()] }); + dispose = registerSpringEndpointProvider(); + expect((await searchEndpoints("/p"))[0]?.methodName).toBe("searchMenus"); + emitLocalIdeEvent({ type: "fsChanged", created: [], modified: [ipcPath("/p/pom.xml")], removed: [] }); + await searchEndpoints("/p"); + expect(scanSources).toHaveBeenCalledTimes(1); + scanSources.mockResolvedValue({ status: "ok", data: [source("listMenus", "/menu/list")] }); + emitLocalIdeEvent({ type: "fsChanged", created: [], modified: [], removed: [], [kind]: [ipcPath("/p/A.java")] }); + expect((await searchEndpoints("/p"))[0]?.url).toBe("/deeplink/menu/list"); + expect(scanSources).toHaveBeenCalledTimes(2); + dispose(); + expect(await searchEndpoints("/p")).toEqual([]); + }); +}); diff --git a/src/features/http-client/spring/routeIndex.ts b/src/features/http-client/spring/routeIndex.ts new file mode 100644 index 000000000..edbb84048 --- /dev/null +++ b/src/features/http-client/spring/routeIndex.ts @@ -0,0 +1,80 @@ +/** Spring request-path index, cached per project until a mapping source changes. */ +import { call, commands, onIdeEvent } from "@/platform/ipc"; +import { registerEndpointProvider, type EndpointEntry } from "@/workbench/contributions"; +import { springControllers } from "./endpoints"; + +export function registerSpringEndpointProvider(): () => void { + clearRouteIndex(); + const unregister = registerEndpointProvider({ id: "http.spring", endpoints: routeIndex }); + const unsubscribe = onIdeEvent("fsChanged", (e) => { + if ([...e.created, ...e.modified, ...e.removed].some((p) => isRouteSource(String(p)))) clearRouteIndex(); + }); + return () => { + unsubscribe(); + unregister(); + clearRouteIndex(); + }; +} + +let cached: { root: string; promise: Promise } | null = null; + +/** Whether `path` is a source a mapping can live in (a Java or Kotlin file). */ +export function isRouteSource(path: string): boolean { + return /\.(java|kt|kts)$/i.test(path); +} + +/** Forgets the index, so the next search rescans the project. */ +export function clearRouteIndex() { + cached = null; +} + +/** The project's routes, scanned once per project and kept in memory. */ +export function routeIndex(root: string | null): Promise { + if (!root) return Promise.resolve([]); + if (cached?.root === root) return cached.promise; + const promise = scan(root); + cached = { root, promise }; + return promise; +} + +async function scan(root: string): Promise { + const sources = await call(commands.springRouteSources()); + const routes: EndpointEntry[] = []; + for (const source of sources) { + if (!source.path.startsWith(root)) continue; + for (const controller of springControllers(source.text)) { + for (const endpoint of controller.endpoints) { + const at = lineCol(source.text, endpoint.offset); + // A mapping without a path answers nothing to look up by URL; the method + // itself is still reachable through the symbol index. + for (const url of endpoint.paths) { + routes.push({ + method: endpoint.httpMethod, + url, + controller: controller.name, + methodName: endpoint.methodName, + path: source.path, + rel: source.path.slice(root.length + 1), + line: at.line, + col: at.col, + }); + } + } + } + } + return routes; +} + +function lineCol(text: string, offset: number): { line: number; col: number } { + let line = 0; + let col = 0; + for (let i = 0; i < offset && i < text.length; i++) { + if (text.charCodeAt(i) === 10 /* \n */) { + line++; + col = 0; + } else { + col++; + } + } + return { line, col }; +} diff --git a/src/features/search/__tests__/controller.test.ts b/src/features/search/__tests__/controller.test.ts index 65ba0ae31..dbd9061bc 100644 --- a/src/features/search/__tests__/controller.test.ts +++ b/src/features/search/__tests__/controller.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from "vitest"; import type { FileMatches, SearchQuery } from "@/platform/ipc"; import { SeController, type SeDeps, type SeSnapshot } from "../everywhere/controller"; +import { buildRows } from "../everywhere/model"; function deferred() { let resolve!: (v: T) => void; @@ -195,3 +196,29 @@ describe("SeController", () => { expect(searchText.mock.calls.at(-1)![1].scope).toEqual({ kind: "fileSet", set: "test" }); }); }); + +describe("Endpoints in the All tab", () => { + it("answers a request path with the controller method that serves it", async () => { + const routes = [ + { + method: "POST", + url: "/deeplink/menu/search", + controller: "DeepLinkController", + methodName: "searchMenus", + path: "/p/tg-tenant-server/src/main/java/DeepLinkController.java", + rel: "tg-tenant-server/src/main/java/DeepLinkController.java", + line: 45, + col: 4, + }, + ]; + const { c, last } = setup({ root: () => "/p", routes: async () => routes }); + c.run({ query: "/tg-tenant/deeplink/menu/search", tab: "all", includeNonProject: false, extra: {} }); + await flush(); + const section = last().sections.get("endpoints"); + expect(section?.items).toHaveLength(1); + expect(section?.items[0]).toMatchObject({ kind: "symbol", container: "DeepLinkController.searchMenus", line: 45 }); + // The All tab mixes its sections into one list; an endpoint has to be in it. + const rows = buildRows(last().sections, { headers: false, mix: "/tg-tenant/deeplink/menu/search" }); + expect(rows.map((r) => (r.kind === "symbol" ? r.container : r.kind))).toContain("DeepLinkController.searchMenus"); + }); +}); diff --git a/src/features/search/__tests__/everywhereModel.test.ts b/src/features/search/__tests__/everywhereModel.test.ts index 1d2eae940..f0616a027 100644 --- a/src/features/search/__tests__/everywhereModel.test.ts +++ b/src/features/search/__tests__/everywhereModel.test.ts @@ -250,7 +250,7 @@ describe("items and rows", () => { it("sectionsFor picks sources per tab and empty query", () => { expect(sectionsFor("all", true)).toEqual(["recent", "recentActions"]); expect(sectionsFor("classes", true)).toEqual([]); - expect(sectionsFor("all", false)).toEqual(["classes", "files", "symbols", "actions", "text"]); + expect(sectionsFor("all", false)).toEqual(["classes", "files", "symbols", "endpoints", "actions", "text"]); expect(sectionsFor("text", false)).toEqual(["text"]); }); }); diff --git a/src/features/search/__tests__/routes.test.ts b/src/features/search/__tests__/routes.test.ts new file mode 100644 index 000000000..efd0d1ecf --- /dev/null +++ b/src/features/search/__tests__/routes.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; +import type { EndpointEntry } from "@/workbench/contributions"; +import { matchRoutes } from "../everywhere/routes"; + +const route = (over: Partial = {}): EndpointEntry => ({ + method: "POST", + url: "/deeplink/menu/search", + controller: "DeepLinkController", + methodName: "searchMenus", + path: "/p/tg-tenant-server/src/main/java/DeepLinkController.java", + rel: "tg-tenant-server/src/main/java/DeepLinkController.java", + line: 45, + col: 4, + ...over, +}); + +const routes: EndpointEntry[] = [ + route(), + route({ + method: "GET", + url: "/deeplink/menu/{menuId}", + methodName: "getMenuDetail", + line: 52, + col: 4, + }), +]; + +describe("endpoints in Search Everywhere", () => { + it("finds a controller method by the request path it answers", () => { + for (const query of [ + "/deeplink/menu/search", + "deeplink/menu/search", + "/tg-tenant/deeplink/menu/search", + "POST /tg-tenant/deeplink/menu/search", + "post /deeplink/menu/search", + ]) { + const { items } = matchRoutes(routes, query, 5); + expect(items[0]?.container, query).toBe("DeepLinkController.searchMenus"); + expect(items[0]?.display, query).toBe("POST /deeplink/menu/search"); + } + }); + + it("honours an HTTP method prefix", () => { + expect(matchRoutes(routes, "GET /deeplink/menu/x", 5).items).toEqual([]); + expect(matchRoutes(routes, "GET /deeplink/menu", 5).items[0]?.container).toBe("DeepLinkController.getMenuDetail"); + }); + + it("finds it by controller or method name too", () => { + expect(matchRoutes(routes, "searchMenus", 5).items[0]?.container).toBe("DeepLinkController.searchMenus"); + expect(matchRoutes(routes, "DeepLinkController.getMenuDetail", 5).items[0]?.container).toBe( + "DeepLinkController.getMenuDetail", + ); + expect(matchRoutes(routes, "deeplink", 5).items).toHaveLength(2); + }); + + it("says nothing for an empty or unrelated query", () => { + expect(matchRoutes(routes, "", 5).items).toEqual([]); + expect(matchRoutes(routes, " ", 5).items).toEqual([]); + expect(matchRoutes(routes, "unrelated-thing", 5).items).toEqual([]); + }); +}); diff --git a/src/features/search/everywhere/controller.ts b/src/features/search/everywhere/controller.ts index 9c32a6edd..b63c8979f 100644 --- a/src/features/search/everywhere/controller.ts +++ b/src/features/search/everywhere/controller.ts @@ -16,8 +16,9 @@ import type { SymbolMatch, WorkspaceSymbol, } from "@/platform/ipc"; -import type { SettingsSearchEntry } from "@/workbench/contributions"; +import type { EndpointEntry, SettingsSearchEntry } from "@/workbench/contributions"; import { parseLocationQuery } from "../common/query"; +import { matchRoutes } from "./routes"; import { passesFilter, type ResolvedFilter } from "./filters"; import { ALL_TAB_LIMIT, @@ -56,6 +57,8 @@ export interface SeDeps { now(): number; /** Whether `path` is in a root-based named scope ("Project Test Files" …). */ inFileSet?(set: ProjectFileSet, path: string): boolean; + /** Endpoints of the project, for request-path matches. */ + routes?(): Promise; } export interface SeRequest { @@ -257,6 +260,9 @@ export class SeController { case "symbols": await this.startSymbols(id, gen, text, limit, req); break; + case "endpoints": + await this.startEndpoints(id, gen, text, limit, req); + break; case "actions": { const hits = matchActions(this.deps.actions(), text, { isEnabled: (a) => this.deps.isEnabled(a), @@ -284,6 +290,14 @@ export class SeController { } } + /** Project endpoints by request path (`/menu/search`), ranked in memory. */ + private async startEndpoints(id: SectionId, gen: number, text: string, limit: number, req: SeRequest) { + const routes = (await this.deps.routes?.().catch((): EndpointEntry[] => [])) ?? []; + const { items, hasMore } = matchRoutes(routes, text, limit); + const keep = this.scopeFilter(req); + this.set(id, gen, keep ? items.filter((i) => keep(i.path)) : items, hasMore, false); + } + private async startSymbols(id: "classes" | "symbols", gen: number, text: string, limit: number, req: SeRequest) { const typesOnly = id === "classes"; const opts = { diff --git a/src/features/search/everywhere/deps.ts b/src/features/search/everywhere/deps.ts index 73bff101c..f3c5c9e39 100644 --- a/src/features/search/everywhere/deps.ts +++ b/src/features/search/everywhere/deps.ts @@ -2,7 +2,7 @@ import { allActions, type Action } from "@/platform/actions"; import { evaluateWhen, type ContextKeys } from "@/platform/context"; import { call, commands, nextRequestId, type LanguageInfo } from "@/platform/ipc"; import { inFileSet } from "@/platform/projectRoots"; -import { searchableSettings } from "@/workbench/contributions"; +import { searchEndpoints, searchableSettings } from "@/workbench/contributions"; import { projectRoot } from "../common/open"; import { cancelRequest, streamSearch } from "../common/textSearch"; import type { SeDeps } from "./controller"; @@ -33,6 +33,7 @@ export function ipcDeps(ctx: ContextKeys): SeDeps { root: projectRoot, now: () => performance.now(), inFileSet: (set, path) => inFileSet(set, path), + routes: () => searchEndpoints(projectRoot()), }; } diff --git a/src/features/search/everywhere/model.ts b/src/features/search/everywhere/model.ts index 3b085f0c6..6f12712d4 100644 --- a/src/features/search/everywhere/model.ts +++ b/src/features/search/everywhere/model.ts @@ -10,7 +10,7 @@ import { basename, isTypeKind, relDir, symbolDisplayName } from "../common/open" import { byScore, matchText } from "../common/rank"; export type SeTab = "all" | "classes" | "files" | "symbols" | "actions" | "text"; -export type SectionId = "recent" | "recentActions" | "classes" | "files" | "symbols" | "actions" | "text"; +export type SectionId = "recent" | "recentActions" | "classes" | "files" | "symbols" | "endpoints" | "actions" | "text"; export const TABS: ReadonlyArray<{ id: SeTab; label: string }> = [ { id: "all", label: "All" }, @@ -27,6 +27,7 @@ export const SECTION_ORDER: readonly SectionId[] = [ "classes", "files", "symbols", + "endpoints", "actions", "text", ]; @@ -37,6 +38,7 @@ export const SECTION_TITLES: Record = { classes: "Classes", files: "Files", symbols: "Symbols", + endpoints: "Endpoints", actions: "Actions", text: "Text", }; @@ -55,7 +57,7 @@ export function sectionsFor(tab: SeTab, emptyQuery: boolean): SectionId[] { return []; } } - return tab === "all" ? ["classes", "files", "symbols", "actions", "text"] : [tab]; + return tab === "all" ? ["classes", "files", "symbols", "endpoints", "actions", "text"] : [tab]; } export const ALL_TAB_LIMIT = 6; @@ -90,6 +92,8 @@ export interface SymbolItem { indices: number[]; /** From the language server rather than the index. */ lsp?: boolean; + /** Pre-computed relevance (endpoints: the request path matched `n` segments up). */ + score?: number; } export interface ActionItem { @@ -376,7 +380,7 @@ export function textItems(path: string, rel: string, matches: readonly LineMatch // Rows /** Sections the All tab mixes into one relevance-ordered list. */ -const MIXED: readonly SectionId[] = ["classes", "files", "symbols", "actions", "text"]; +const MIXED: readonly SectionId[] = ["classes", "files", "symbols", "endpoints", "actions", "text"]; /** * Weight of a text hit: a literal substring in a line ranks below name @@ -391,6 +395,7 @@ export function itemWeight(item: SeItem, query: string): number { case "file": return name(item.name) ?? (name(item.rel) ?? 0) - 200; case "symbol": + if (item.score !== undefined) return item.score; return name(item.name) ?? (item.container ? name(`${item.container}.${item.name}`) : undefined) ?? 0; case "action": case "setting": diff --git a/src/features/search/everywhere/routes.ts b/src/features/search/everywhere/routes.ts new file mode 100644 index 000000000..f6c68a0fa --- /dev/null +++ b/src/features/search/everywhere/routes.ts @@ -0,0 +1,79 @@ +/** Request-path and controller-method ranking for Search Everywhere. */ +import type { EndpointEntry } from "@/workbench/contributions"; +import { matchText } from "../common/rank"; +import type { SymbolItem } from "./model"; + +const HTTP_METHODS = new Set(["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS", "TRACE"]); + +const strip = (s: string) => s.replace(/^\/+|\/+$/g, ""); + +function item(r: EndpointEntry, indices: number[], score: number): SymbolItem { + const display = `${r.method} ${r.url}`; + return { + kind: "symbol", + key: `r:${r.path}:${r.line}:${r.method}:${r.url}`, + name: display, + display, + symbolKind: "method", + container: `${r.controller}.${r.methodName}`, + path: r.path, + rel: r.rel, + line: r.line, + col: r.col, + indices, + score, + }; +} + +/** + * Routes matching `query`: a request path (`/deeplink/menu/search`, with or without + * the servlet context path), an optional `POST ` prefix, or the controller / method + * name. Scores: exact path, then a path suffix (a context path in front of it), then + * a substring, then the symbol names, then a fuzzy path hit. + */ +export function matchRoutes( + routes: readonly EndpointEntry[], + query: string, + limit: number, +): { items: SymbolItem[]; hasMore: boolean } { + const text = query.trim(); + if (!text || !routes.length) return { items: [], hasMore: false }; + const words = text.split(/\s+/); + const head = (words[0] ?? "").toUpperCase(); + const method = HTTP_METHODS.has(head) ? head : null; + const rest = (method ? words.slice(1).join(" ") : text).trim(); + const wantPath = strip(rest).toLowerCase(); + const wantName = rest.toLowerCase(); + + const hits: { item: SymbolItem; score: number }[] = []; + for (const r of routes) { + if (method && r.method !== method) continue; + const target = strip(r.url).toLowerCase(); + const qualified = `${r.controller}.${r.methodName}`.toLowerCase(); + let score = 0; + let indices: number[] = []; + if (wantPath) { + if (target === wantPath) score = 1000; + else if (wantPath.includes("/") && (target.endsWith(`/${wantPath}`) || wantPath.endsWith(`/${target}`))) + score = 900; + else if (wantPath.length >= 3 && target.length >= 3 && (target.includes(wantPath) || wantPath.includes(target))) + score = 700; + if (score) { + const at = `${r.method} ${r.url}`.toLowerCase().lastIndexOf(wantPath); + if (at >= 0) indices = Array.from({ length: wantPath.length }, (_, i) => at + i); + } + } + if (!score && wantName) { + if (r.methodName.toLowerCase() === wantName) score = 800; + else if (qualified === wantName || qualified.includes(wantName)) score = 600; + else if (wantName.length >= 3 && target.includes(wantName)) score = 500; + else { + const fuzzy = matchText(target, wantName); + if (fuzzy) score = 300 + fuzzy.score; + } + } + if (score) hits.push({ item: item(r, indices, score), score }); + } + hits.sort((a, b) => b.score - a.score || a.item.rel.localeCompare(b.item.rel) || a.item.line - b.item.line); + return { items: hits.slice(0, limit).map((h) => h.item), hasMore: hits.length > limit }; +} diff --git a/src/workbench/contributions.ts b/src/workbench/contributions.ts index 74f44beb4..6331f20a9 100644 --- a/src/workbench/contributions.ts +++ b/src/workbench/contributions.ts @@ -1317,6 +1317,41 @@ export function runAnythingProviderList(): RunAnythingProvider[] { ); } +// --------------------------------------------------------------------------- +// Project endpoints supplied by framework features for request-path search. + +export interface EndpointEntry { + /** `GET`, `POST`, … */ + method: string; + /** Class-level and method-level mapping joined; `{var}` kept. */ + url: string; + controller: string; + methodName: string; + path: string; + rel: string; + line: number; + col: number; +} + +export interface EndpointProvider { + id: string; + endpoints(root: string): Promise; +} + +const endpointProviders = new Map(); + +export function registerEndpointProvider(p: EndpointProvider): () => void { + endpointProviders.set(p.id, p); + return () => { + if (endpointProviders.get(p.id) === p) endpointProviders.delete(p.id); + }; +} + +export async function searchEndpoints(root: string | null): Promise { + if (!root) return []; + return (await Promise.all([...endpointProviders.values()].map((p) => p.endpoints(root)))).flat(); +} + // --------------------------------------------------------------------------- // Status bar info text (IDEA's status bar message, e.g. "3 of 12" after Next // Occurrence). Cleared by the next message or after a while. diff --git a/src/workbench/endpointProviders.test.ts b/src/workbench/endpointProviders.test.ts new file mode 100644 index 000000000..7884faff7 --- /dev/null +++ b/src/workbench/endpointProviders.test.ts @@ -0,0 +1,46 @@ +import { expect, it, vi } from "vitest"; +import { registerEndpointProvider, searchEndpoints, type EndpointEntry } from "./contributions"; + +const endpoint: EndpointEntry = { + method: "GET", + url: "/menu/list", + controller: "MenuController", + methodName: "listMenus", + path: "/p/MenuController.java", + rel: "MenuController.java", + line: 4, + col: 11, +}; + +it("collects endpoints from all providers for the requested root", async () => { + const first = vi.fn(async () => [endpoint]); + const second = vi.fn(async () => [{ ...endpoint, url: "/other" }]); + const disposers = [ + registerEndpointProvider({ id: "test.first", endpoints: first }), + registerEndpointProvider({ id: "test.second", endpoints: second }), + ]; + try { + expect(await searchEndpoints(null)).toEqual([]); + expect(first).not.toHaveBeenCalled(); + expect(second).not.toHaveBeenCalled(); + expect(await searchEndpoints("/p")).toEqual([endpoint, { ...endpoint, url: "/other" }]); + expect(first).toHaveBeenCalledWith("/p"); + expect(second).toHaveBeenCalledWith("/p"); + } finally { + disposers.forEach((dispose) => dispose()); + } + expect(await searchEndpoints("/p")).toEqual([]); +}); + +it("keeps a replacement provider registered when its predecessor is disposed", async () => { + const disposeOld = registerEndpointProvider({ id: "test.replaced", endpoints: async () => [] }); + const disposeNew = registerEndpointProvider({ id: "test.replaced", endpoints: async () => [endpoint] }); + try { + disposeOld(); + expect(await searchEndpoints("/p")).toEqual([endpoint]); + } finally { + disposeOld(); + disposeNew(); + } + expect(await searchEndpoints("/p")).toEqual([]); +}); From 7fdf1d62c3f68a8f162019687db5f34ac18b7b61 Mon Sep 17 00:00:00 2001 From: Shohruh Ismoilov Date: Mon, 2 Nov 2026 19:18:16 +0500 Subject: [PATCH 028/106] fix(refactor): preserve versions and recover multi-file workspace edits (#488) Multi-file refactorings could drop LSP document versions, overwrite content changed after a preview, or leave earlier files saved after a later write failed. Workspace edits now keep server versions and preview fingerprints, are validated on a staging copy, snapshot disk and open buffers, and roll back on commit failure (retaining recovery files if rollback fails). The whole edit is one Undo/Redo step in editors and in the notification. Version reads and transaction work run off the async workers. Closes #461. Co-authored-by: shohruh-genius <7658451+shohruh-genius@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 --- crates/ide-core/src/event.rs | 9 + crates/ide-core/src/text.rs | 52 +- crates/ide-host/src/lib.rs | 9 + crates/ide-host/src/ops/build.rs | 6 +- crates/ide-host/src/ops/doc/commands.rs | 2 +- crates/ide-host/src/ops/lsp.rs | 132 +- crates/ide-host/src/ops/refactor.rs | 18 +- crates/ide-host/src/project.rs | 40 +- crates/ide-host/src/rpc.rs | 9 +- crates/ide-host/src/trust.rs | 4 + crates/ide-host/tests/rpc.rs | 34 + crates/ide-host/tests/workspace_edits.rs | 177 +++ crates/ide-lsp/src/convert.rs | 54 +- crates/ide-lsp/tests/mock_server.rs | 10 +- crates/ide-lsp/tests/real_servers.rs | 2 +- crates/ide-workspace/Cargo.toml | 1 + crates/ide-workspace/src/documents.rs | 31 +- .../src/documents/workspace_edit.rs | 1187 +++++++++++++++++ crates/ide-workspace/src/file_moves.rs | 19 +- crates/ide-workspace/src/text_refs.rs | 6 +- crates/ide-workspace/tests/workspace_edits.rs | 463 +++++++ src-tauri/src/commands/doc.rs | 18 +- src-tauri/src/commands/lsp.rs | 26 +- src-tauri/src/commands/mod.rs | 3 + src-tauri/src/lib.rs | 1 + src/bindings.ts | 20 +- src/editor/api.ts | 34 + src/editor/core/backend.ts | 7 +- src/editor/core/model/attachment.ts | 13 + src/editor/core/model/document.ts | 16 +- src/editor/core/model/slot.ts | 14 + .../core/model/workspaceHistory.test.ts | 217 +++ src/editor/core/model/workspaceHistory.ts | 176 +++ src/editor/core/sync/DocSync.ts | 5 +- src/editor/lsp/rename.ts | 3 +- src/editor/lsp/syntaxFallback.test.ts | 23 + src/features/analysis/AnalysisDialogs.tsx | 33 +- src/features/analysis/replacePreview.test.tsx | 77 ++ src/features/analysis/structural.ts | 3 +- src/features/code-insight/index.ts | 2 + .../code-insight/workspaceUndo.test.ts | 66 + src/features/code-insight/workspaceUndo.ts | 55 + .../project-tree/RefactoringPreview.tsx | 2 +- .../project-tree/fileRefactor.test.tsx | 20 +- src/features/project-tree/fileRefactor.ts | 16 +- src/platform/locales/text/zh.json | 3 + 46 files changed, 2982 insertions(+), 136 deletions(-) create mode 100644 crates/ide-host/tests/workspace_edits.rs create mode 100644 crates/ide-workspace/src/documents/workspace_edit.rs create mode 100644 crates/ide-workspace/tests/workspace_edits.rs create mode 100644 src/editor/core/model/workspaceHistory.test.ts create mode 100644 src/editor/core/model/workspaceHistory.ts create mode 100644 src/features/analysis/replacePreview.test.tsx create mode 100644 src/features/code-insight/workspaceUndo.test.ts create mode 100644 src/features/code-insight/workspaceUndo.ts diff --git a/crates/ide-core/src/event.rs b/crates/ide-core/src/event.rs index e2d45fec6..6cef110cb 100644 --- a/crates/ide-core/src/event.rs +++ b/crates/ide-core/src/event.rs @@ -36,12 +36,21 @@ pub enum IdeEvent { base_version: u32, version: u32, changes: Vec, + /// Part of a grouped transaction; the editor must not record a file-local undo. + #[serde(default)] + #[specta(optional)] + workspace_edit: bool, }, /// A document's dirty flag changed (saved / modified externally). DocDirty { doc: DocId, dirty: bool, }, + /// A complete workspace edit can be undone as one operation. + WorkspaceEditApplied { + id: u32, + paths: Vec, + }, /// An open document's language changed (Settings › File Types, Override File /// Type), or its UI mode (a framework's root marker appeared or went). DocLanguage { diff --git a/crates/ide-core/src/text.rs b/crates/ide-core/src/text.rs index 456481283..6e21958dc 100644 --- a/crates/ide-core/src/text.rs +++ b/crates/ide-core/src/text.rs @@ -97,6 +97,16 @@ pub struct TextEdit { #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)] pub struct WorkspaceEdit { pub files: Vec, + /// Content fingerprints captured before presenting an edit for confirmation. + #[serde(default)] + #[specta(optional)] + pub preconditions: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] +pub struct WorkspaceEditPrecondition { + pub path: crate::IpcPath, + pub hash: String, } impl WorkspaceEdit { @@ -108,17 +118,26 @@ impl WorkspaceEdit { let mut resolve_aliases = true; for file in std::mem::take(&mut self.files) { match file { - FileEdit::Edit { path, edits } => { + FileEdit::Edit { path, edits, version } => { let path = if resolve_aliases { dunce::canonicalize(&path).unwrap_or_else(|_| path.to_path_buf()) } else { path.to_path_buf() }; let index = *batch.entry(path.clone()).or_insert_with(|| { - files.push(FileEdit::Edit { path: path.into(), edits: Vec::new() }); + files.push(FileEdit::Edit { path: path.clone().into(), edits: Vec::new(), version }); files.len() - 1 }); - let FileEdit::Edit { edits: own, .. } = &mut files[index] else { unreachable!() }; + let FileEdit::Edit { edits: own, version: expected, .. } = &mut files[index] else { + unreachable!() + }; + if expected.is_some() && version.is_some() && *expected != version { + // Keep incompatible constraints so application rejects them + // before changing any file instead of silently losing one. + files.push(FileEdit::Edit { path: path.into(), edits, version }); + continue; + } + *expected = expected.or(version); for edit in edits { if !own.contains(&edit) { own.push(edit); @@ -140,10 +159,26 @@ impl WorkspaceEdit { #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] #[serde(tag = "kind", rename_all = "camelCase")] pub enum FileEdit { - Edit { path: crate::IpcPath, edits: Vec }, - Create { path: crate::IpcPath, overwrite: bool }, - Rename { from: crate::IpcPath, to: crate::IpcPath }, - Delete { path: crate::IpcPath, recursive: bool }, + Edit { + path: crate::IpcPath, + edits: Vec, + /// LSP TextDocumentEdit version; null/unversioned edits use content preconditions. + #[serde(default)] + #[specta(optional)] + version: Option, + }, + Create { + path: crate::IpcPath, + overwrite: bool, + }, + Rename { + from: crate::IpcPath, + to: crate::IpcPath, + }, + Delete { + path: crate::IpcPath, + recursive: bool, + }, } #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Type)] @@ -254,7 +289,7 @@ mod workspace_edit_tests { std::fs::write(&real, "add()").unwrap(); std::os::unix::fs::symlink(&real, &alias).unwrap(); let change = TextEdit { range: Range::new(Pos::new(0, 0), Pos::new(0, 3)), new_text: "sum_values".into() }; - let edit = |path: PathBuf| FileEdit::Edit { path: path.into(), edits: vec![change.clone()] }; + let edit = |path: PathBuf| FileEdit::Edit { path: path.into(), edits: vec![change.clone()], version: None }; let mut workspace = WorkspaceEdit { files: vec![ edit(real.clone()), @@ -262,6 +297,7 @@ mod workspace_edit_tests { FileEdit::Create { path: dir.path().join("other").into(), overwrite: false }, edit(real), ], + ..Default::default() }; workspace.deduplicate(); assert_eq!(workspace.files.len(), 3); diff --git a/crates/ide-host/src/lib.rs b/crates/ide-host/src/lib.rs index d5fef1e5f..898b2884c 100644 --- a/crates/ide-host/src/lib.rs +++ b/crates/ide-host/src/lib.rs @@ -174,6 +174,7 @@ impl Host { if let Some(bridge) = &project.kotlin_bridge { bridge.stop(); } + project.documents.clear_workspace_edit_history(); project.agents.shutdown(); project.notebooks.stop_admission(); // What the closing window started goes with it — the builds and searches it @@ -251,6 +252,13 @@ impl Host { ); } + /// Called before desktop exit, which may bypass document destructors. + pub fn clear_workspace_edit_history_on_exit(&self) { + for project in self.projects() { + project.documents.clear_workspace_edit_history(); + } + } + /// Stops what this host started: notebook kernels, language servers, agents, runs and terminals. /// For a process that exits without dropping the host (the remote server). pub async fn shutdown(&self) { @@ -262,6 +270,7 @@ impl Host { if let Some(bridge) = &project.kotlin_bridge { bridge.stop(); } + project.documents.clear_workspace_edit_history(); project.notebooks.stop_admission(); project.agents.shutdown(); } diff --git a/crates/ide-host/src/ops/build.rs b/crates/ide-host/src/ops/build.rs index 767875df5..6ae0b0bd0 100644 --- a/crates/ide-host/src/ops/build.rs +++ b/crates/ide-host/src/ops/build.rs @@ -210,8 +210,10 @@ pub async fn maven_exclude_dependency( let (path, text, range) = declaring_pom(&project, &pom, &dependency)?; match ide_build::pom_edit::add_exclusion(&text, &dependency, &exclusion) { Some(Exclusion::Edit(edit)) => { - let edit = - WorkspaceEdit { files: vec![FileEdit::Edit { path: path.clone().into(), edits: vec![edit] }] }; + let edit = WorkspaceEdit { + files: vec![FileEdit::Edit { path: path.clone().into(), edits: vec![edit], version: None }], + ..Default::default() + }; apply_workspace_edit(&project.documents, &edit)?; } Some(Exclusion::Present) => {} diff --git a/crates/ide-host/src/ops/doc/commands.rs b/crates/ide-host/src/ops/doc/commands.rs index aa0f918bb..e1fab8f98 100644 --- a/crates/ide-host/src/ops/doc/commands.rs +++ b/crates/ide-host/src/ops/doc/commands.rs @@ -127,7 +127,7 @@ pub(crate) fn register(d: &mut Dispatcher) { doc_set_encoding(&c.host, &c.window, c.arg("doc")?, c.arg("encoding")?, c.arg("reload")?).await }); d.add("doc_set_line_ending", |c| async move { - doc_set_line_ending(&c.host, &c.window, c.arg("doc")?, c.arg("lineEnding")?) + blocking(move || doc_set_line_ending(&c.host, &c.window, c.arg("doc")?, c.arg("lineEnding")?)).await }); d.add("doc_outline", |c| async move { doc_outline(&c.host, &c.window, c.arg("doc")?).await }); d.add("doc_folding", |c| async move { doc_folding(&c.host, &c.window, c.arg("doc")?).await }); diff --git a/crates/ide-host/src/ops/lsp.rs b/crates/ide-host/src/ops/lsp.rs index b57f54efd..f84ab3c73 100644 --- a/crates/ide-host/src/ops/lsp.rs +++ b/crates/ide-host/src/ops/lsp.rs @@ -1,9 +1,33 @@ +use super::doc::blocking; use crate::Host; use crate::apply_workspace_edit as apply_edit; use crate::rpc::Dispatcher; use ide_core::{DocId, IdeResult, Location, Pos, Range, RequestId, ServerId, TextEdit, WorkspaceEdit}; use ide_lsp::types::*; +async fn prepare_actions( + project: std::sync::Arc, + doc: DocId, + version: u32, + mut actions: Vec, +) -> IdeResult> { + blocking(move || { + if project.documents.version(doc)? != version { + return Err(ide_core::IdeError::new( + ide_core::error::ErrorKind::OutOfSync, + "document changed while computing code actions; request them again", + )); + } + for action in &mut actions { + if let Some(edit) = &mut action.edit { + project.documents.prepare_workspace_edit(edit)?; + } + } + Ok(actions) + }) + .await +} + pub async fn lsp_completion( host: &Host, window: &str, @@ -138,7 +162,11 @@ pub async fn lsp_code_actions( range: Range, only: Vec, ) -> IdeResult> { - host.project(window)?.lsp.code_actions(doc, range, only).await + let project = host.project(window)?; + let documents = project.documents.clone(); + let version = blocking(move || documents.version(doc)).await?; + let actions = project.lsp.code_actions(doc, range, only).await?; + prepare_actions(project, doc, version, actions).await } /// Cancellable code actions with the kinds the servers advertise (Refactor This, refactoring availability). @@ -150,11 +178,15 @@ pub async fn lsp_code_action_query( range: Range, only: Vec, ) -> IdeResult { - let lsp = host.project(window)?.lsp.clone(); + let project = host.project(window)?; + let documents = project.documents.clone(); + let version = blocking(move || documents.version(doc)).await?; let cancel = host.begin_request(request); - let result = lsp.code_action_query(doc, range, only, cancel).await; + let result = project.lsp.code_action_query(doc, range, only, cancel).await; host.end_request(request); - result + let mut result = result?; + result.actions = prepare_actions(project, doc, version, result.actions).await?; + Ok(result) } /// Resolves a code action's edit without applying it (intention preview). @@ -167,19 +199,28 @@ pub async fn lsp_resolve_code_action( if action.edit.is_some() { return Ok(action); } - host.project(window)?.lsp.resolve_code_action(doc, action).await + let project = host.project(window)?; + let documents = project.documents.clone(); + let version = blocking(move || documents.version(doc)).await?; + let action = project.lsp.resolve_code_action(doc, action).await?; + Ok(prepare_actions(project, doc, version, vec![action]).await?.remove(0)) } /// Resolves (if needed), applies the edit, then runs the command of a code action. pub async fn lsp_apply_code_action(host: &Host, window: &str, doc: DocId, action: CodeAction) -> IdeResult<()> { let project = host.project(window)?; let action = if action.edit.is_none() && action.command.is_none() { - project.lsp.resolve_code_action(doc, action).await? + let documents = project.documents.clone(); + let version = blocking(move || documents.version(doc)).await?; + let action = project.lsp.resolve_code_action(doc, action).await?; + prepare_actions(project.clone(), doc, version, vec![action]).await?.remove(0) } else { action }; if let Some(edit) = &action.edit { - apply_edit(&project.documents, edit)?; + let documents = project.documents.clone(); + let edit = edit.clone(); + blocking(move || apply_edit(&documents, &edit)).await?; } if let Some(command) = action.command { project.lsp.execute_command(doc, command).await?; @@ -200,11 +241,24 @@ pub async fn lsp_rename( pos: Pos, new_name: String, ) -> IdeResult { - let lsp = host.project(window)?.lsp.clone(); + let project = host.project(window)?; + let documents = project.documents.clone(); + let version = blocking(move || documents.version(doc)).await?; let cancel = host.begin_request(request); - let result = lsp.rename(doc, pos, new_name, cancel).await; + let result = project.lsp.rename(doc, pos, new_name, cancel).await; host.end_request(request); - result + let mut renamed = result?; + blocking(move || { + if project.documents.version(doc)? != version { + return Err(ide_core::IdeError::new( + ide_core::error::ErrorKind::OutOfSync, + "document changed while computing rename; request it again", + )); + } + project.documents.prepare_workspace_edit(&mut renamed.edit)?; + Ok(renamed) + }) + .await } pub async fn lsp_format( @@ -279,21 +333,43 @@ pub async fn lsp_restart(host: &Host, window: &str, server: ServerId) -> IdeResu } /// Applies a (previewed) workspace edit, e.g. after rename confirmation. -pub fn apply_workspace_edit(host: &Host, window: &str, edit: WorkspaceEdit) -> IdeResult<()> { +pub async fn apply_workspace_edit(host: &Host, window: &str, edit: WorkspaceEdit) -> IdeResult<()> { let project = host.project(window)?; - // Multi-file refactorings leave a Local History point to go back to. - let paths: Vec<&std::path::Path> = edit - .files - .iter() - .filter_map(|f| match f { - ide_core::FileEdit::Edit { path, .. } => Some(&**path), - _ => None, - }) - .collect(); - if edit.files.len() > 1 { - project.record_before_refactoring(&paths, "Apply changes"); - } - apply_edit(&project.documents, &edit) + blocking(move || { + // Multi-file refactorings leave a Local History point to go back to. + let paths: Vec<&std::path::Path> = edit + .files + .iter() + .filter_map(|f| match f { + ide_core::FileEdit::Edit { path, .. } => Some(&**path), + _ => None, + }) + .collect(); + if edit.files.len() > 1 { + project.record_before_refactoring(&paths, "Apply changes"); + } + apply_edit(&project.documents, &edit) + }) + .await +} + +pub async fn prepare_workspace_edit(host: &Host, window: &str, mut edit: WorkspaceEdit) -> IdeResult { + let project = host.project(window)?; + blocking(move || { + project.documents.prepare_workspace_edit(&mut edit)?; + Ok(edit) + }) + .await +} + +pub async fn undo_workspace_edit(host: &Host, window: &str, id: u32) -> IdeResult<()> { + let project = host.project(window)?; + blocking(move || project.documents.undo_workspace_edit(id)).await +} + +pub async fn redo_workspace_edit(host: &Host, window: &str, id: u32) -> IdeResult<()> { + let project = host.project(window)?; + blocking(move || project.documents.redo_workspace_edit(id)).await } /// Runs a server command (`workspace/executeCommand`), e.g. a completion item's @@ -441,7 +517,7 @@ pub(crate) fn register(d: &mut Dispatcher) { d.add("lsp_servers", |c| async move { lsp_servers(&c.host, &c.window) }); d.add("lsp_resend_diagnostics", |c| async move { lsp_resend_diagnostics(&c.host, &c.window) }); d.add("lsp_restart", |c| async move { lsp_restart(&c.host, &c.window, c.arg("server")?).await }); - d.add("apply_workspace_edit", |c| async move { apply_workspace_edit(&c.host, &c.window, c.arg("edit")?) }); + d.add("apply_workspace_edit", |c| async move { apply_workspace_edit(&c.host, &c.window, c.arg("edit")?).await }); d.add("lsp_resolve_command", |c| async move { lsp_resolve_command( &c.host, @@ -465,6 +541,12 @@ pub(crate) fn register(d: &mut Dispatcher) { ) .await }); + d.add( + "prepare_workspace_edit", + |c| async move { prepare_workspace_edit(&c.host, &c.window, c.arg("edit")?).await }, + ); + d.add("redo_workspace_edit", |c| async move { redo_workspace_edit(&c.host, &c.window, c.arg("id")?).await }); + d.add("undo_workspace_edit", |c| async move { undo_workspace_edit(&c.host, &c.window, c.arg("id")?).await }); d.add("lsp_execute_command", |c| async move { lsp_execute_command(&c.host, &c.window, c.arg("doc")?, c.arg("command")?).await }); diff --git a/crates/ide-host/src/ops/refactor.rs b/crates/ide-host/src/ops/refactor.rs index 806f0f3dc..f9a91ad6d 100644 --- a/crates/ide-host/src/ops/refactor.rs +++ b/crates/ide-host/src/ops/refactor.rs @@ -1,7 +1,6 @@ //! File move / rename refactoring: reference updates from the language //! servers, the move itself (one undoable step) and its undo. use crate::Host; -use crate::apply_workspace_edit; use crate::rpc::Dispatcher; use ide_core::{IdeResult, RequestId, WorkspaceEdit}; @@ -26,7 +25,12 @@ pub async fn refactor_file_move_edits( let token = host.begin_request(request); let result = project.lsp.will_rename_files(&pairs(&moves), token).await; host.end_request(request); - result + let mut result = result?; + blocking(move || { + project.documents.prepare_workspace_edit(&mut result.edit)?; + Ok(result) + }) + .await } /// Applies `edit` (from [`refactor_file_move_edits`], possibly empty), moves the @@ -42,7 +46,7 @@ pub async fn refactor_move_files( let p = project.clone(); let moved = moves.clone(); let id = blocking(move || { - let apply = |e: &WorkspaceEdit| apply_workspace_edit(&p.documents, e); + let apply = |e: &WorkspaceEdit| p.documents.apply_file_move_edits(e); p.moves.perform(&p.documents, Some(&p.history), &moved, &edit, &label, &apply) }) .await?; @@ -77,13 +81,17 @@ pub async fn refactor_file_move_text_edits( let cancel = token.clone(); let result = blocking(move || { let replacements = ide_workspace::text_refs::move_replacements(&project.root, &project.roots(), &moves); - let edit = ide_workspace::text_refs::find_text_occurrences( + let mut edit = ide_workspace::text_refs::find_text_occurrences( &project.root, &project.exclude_globs(), &replacements, &|| cancel.is_cancelled(), ); - if cancel.is_cancelled() { Err(ide_core::IdeError::cancelled()) } else { Ok(edit) } + if cancel.is_cancelled() { + return Err(ide_core::IdeError::cancelled()); + } + project.documents.prepare_workspace_edit(&mut edit)?; + Ok(edit) }) .await; host.end_request(request); diff --git a/crates/ide-host/src/project.rs b/crates/ide-host/src/project.rs index 0e00ae8b9..a42889a19 100644 --- a/crates/ide-host/src/project.rs +++ b/crates/ide-host/src/project.rs @@ -881,37 +881,7 @@ impl Project { /// Applies a multi-file edit through the document layer so open editors update live. pub fn apply_workspace_edit(documents: &Documents, edit: &ide_core::WorkspaceEdit) -> IdeResult<()> { - use ide_core::FileEdit; - let mut edit = edit.clone(); - edit.deduplicate(); - for file in &edit.files { - match file { - FileEdit::Edit { path, edits } => { - // Files not open in an editor are opened transiently and closed again. - let (doc, transient) = match documents.by_path(path) { - Some(doc) => (doc, false), - None => (documents.open(path)?.id, true), - }; - let result = documents.apply_edits(doc, edits).and_then(|_| documents.save(doc)); - if transient { - documents.close(doc); - } - result?; - } - FileEdit::Create { path, overwrite } => { - if path.exists() { - if *overwrite { - ide_vfs::save_text(path, "", "utf-8", false)?; - } - } else { - ide_vfs::create_file(path)?; - } - } - FileEdit::Rename { from, to } => ide_vfs::rename(from, to)?, - FileEdit::Delete { path, .. } => ide_vfs::delete(path)?, - } - } - Ok(()) + documents.apply_workspace_edit(edit) } /// Files the watcher reports per batch that Local History snapshots. @@ -1253,10 +1223,14 @@ mod workspace_edit_tests { let doc = documents.open(&alias).unwrap(); let changes = |path: PathBuf| FileEdit::Edit { path: path.into(), + version: None, edits: vec![TextEdit { range: Range::new(Pos::new(0, 0), Pos::new(0, 3)), new_text: "sum_values".into() }], }; - apply_workspace_edit(&documents, &WorkspaceEdit { files: vec![changes(real.clone()), changes(alias)] }) - .unwrap(); + apply_workspace_edit( + &documents, + &WorkspaceEdit { files: vec![changes(real.clone()), changes(alias)], ..Default::default() }, + ) + .unwrap(); assert_eq!(std::fs::read_to_string(real).unwrap(), "sum_values();\n"); assert_eq!(documents.snapshot(doc.id).unwrap().text, "sum_values();\n"); } diff --git a/crates/ide-host/src/rpc.rs b/crates/ide-host/src/rpc.rs index 8b758d6b4..fb0cc28e9 100644 --- a/crates/ide-host/src/rpc.rs +++ b/crates/ide-host/src/rpc.rs @@ -215,9 +215,14 @@ impl Dispatcher { }) .add("fs_read_text", |c| async move { ops::fs::read_text(&c.host, &c.window, c.arg("path")?).await }) .add("doc_open", |c| async move { ops::doc::open(&c.host, &c.window, c.arg("path")?).await }) - .add("doc_close", |c| async move { ops::doc::close(&c.host, &c.window, c.arg("doc")?) }) + .add("doc_close", |c| async move { + ops::doc::blocking(move || ops::doc::close(&c.host, &c.window, c.arg("doc")?)).await + }) .add("doc_edit", |c| async move { - ops::doc::edit(&c.host, &c.window, c.arg("doc")?, c.arg("baseVersion")?, c.arg("changes")?) + ops::doc::blocking(move || { + ops::doc::edit(&c.host, &c.window, c.arg("doc")?, c.arg("baseVersion")?, c.arg("changes")?) + }) + .await }) .add("doc_save", |c| async move { ops::doc::save(&c.host, &c.window, c.arg("doc")?).await }) .add("doc_save_all", |c| async move { ops::doc::save_all(&c.host, &c.window).await }) diff --git a/crates/ide-host/src/trust.rs b/crates/ide-host/src/trust.rs index 5fd072bba..d0e0731dd 100644 --- a/crates/ide-host/src/trust.rs +++ b/crates/ide-host/src/trust.rs @@ -209,6 +209,10 @@ pub fn restricted_command_allowed(cmd: &str) -> bool { | "menu_model" | "menu_configure" | "menu_set_disabled" + | "prepare_workspace_edit" + | "apply_workspace_edit" + | "undo_workspace_edit" + | "redo_workspace_edit" | "lsp_servers" | "lsp_resend_diagnostics" | "recent_projects" diff --git a/crates/ide-host/tests/rpc.rs b/crates/ide-host/tests/rpc.rs index 2a101b01e..a0ec5004c 100644 --- a/crates/ide-host/tests/rpc.rs +++ b/crates/ide-host/tests/rpc.rs @@ -88,6 +88,40 @@ async fn serves_projects_files_documents_search_and_runs() { } let call = Invoke { window: "unknown".into(), cmd: "coverage_collect".into(), args: json!({}) }; assert!(peer.request::<_, Value>("invoke", call, None).await.is_err()); + // Manual workspace edits remain available in Restricted Mode, like doc/fs + // editing. They do not ask a language server to execute project commands. + let manual = project.join("src/manual.txt"); + std::fs::write(&manual, "old").unwrap(); + let edit = json!({ "files": [{ "kind": "edit", "path": manual, "edits": [{ + "range": { "start": { "line": 0, "col": 0 }, "end": { "line": 0, "col": 3 } }, + "new_text": "new" + }] }] }); + let edit = invoke(&peer, "prepare_workspace_edit", json!({ "edit": edit })).await.unwrap(); + assert_eq!(edit["preconditions"].as_array().unwrap().len(), 1); + invoke(&peer, "apply_workspace_edit", json!({ "edit": edit })).await.unwrap(); + until("workspace edit history", || { + client.events.lock().iter().any(|event| matches!(event.event, ide_core::IdeEvent::WorkspaceEditApplied { .. })) + }) + .await; + let undo_id = client + .events + .lock() + .iter() + .find_map(|event| match &event.event { + ide_core::IdeEvent::WorkspaceEditApplied { id, .. } => Some(*id), + _ => None, + }) + .unwrap(); + assert_eq!(std::fs::read_to_string(&manual).unwrap(), "new"); + invoke(&peer, "undo_workspace_edit", json!({ "id": undo_id })).await.unwrap(); + assert_eq!(std::fs::read_to_string(&manual).unwrap(), "old"); + invoke(&peer, "redo_workspace_edit", json!({ "id": undo_id })).await.unwrap(); + assert_eq!(std::fs::read_to_string(&manual).unwrap(), "new"); + assert_eq!(invoke(&peer, "project_current", json!({})).await.unwrap()["trusted"], false); + for command in ["lsp_execute_command", "lsp_apply_code_action"] { + let error = invoke(&peer, command, json!({})).await.unwrap_err(); + assert!(error.message.contains("Restricted Mode"), "{command}: {error:?}"); + } invoke(&peer, "project_trust_set", json!({"path": project, "trusted": true, "folder": false})).await.unwrap(); let current = invoke(&peer, "project_current", json!({})).await.unwrap(); assert_eq!(current["name"], "demo"); diff --git a/crates/ide-host/tests/workspace_edits.rs b/crates/ide-host/tests/workspace_edits.rs new file mode 100644 index 000000000..7bdc5c573 --- /dev/null +++ b/crates/ide-host/tests/workspace_edits.rs @@ -0,0 +1,177 @@ +use ide_core::{IdeEvent, SharedSink, WorkspaceEdit, error::ErrorKind}; +use ide_host::{Host, HostConfig, ops}; +use parking_lot::Mutex; +use serde_json::json; +use std::sync::Arc; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn preview_apply_and_grouped_undo_use_the_shared_host_commands() { + let tmp = tempfile::tempdir().unwrap(); + let first = tmp.path().join("first.txt"); + let second = tmp.path().join("second.txt"); + for path in [&first, &second] { + std::fs::write(path, "old\n").unwrap(); + } + let events: Arc>> = Arc::default(); + let sink: SharedSink = { + let events = events.clone(); + Arc::new(move |event| events.lock().push(event)) + }; + let host = Host::new(HostConfig { + config_dir: tmp.path().join("config"), + data_dir: tmp.path().join("data"), + local_data_dir: tmp.path().join("local-data"), + languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()), + syntax: Arc::default(), + sink_for: Box::new(move |_| sink.clone()), + owner: |window| window, + runtime: tokio::runtime::Handle::current(), + project_environment: false, + }); + host.open_light("main", &first).unwrap(); + let info = ops::doc::open(&host, "main", first.clone()).await.unwrap(); + let edit: WorkspaceEdit = serde_json::from_value(json!({ "files": [ + { "kind": "edit", "path": first, "version": info.version, "edits": [{ "range": { "start": { "line": 0, "col": 0 }, "end": { "line": 0, "col": 3 } }, "new_text": "new" }] }, + { "kind": "edit", "path": second, "edits": [{ "range": { "start": { "line": 0, "col": 0 }, "end": { "line": 0, "col": 3 } }, "new_text": "new" }] } + ] })).unwrap(); + let preview = ops::lsp::prepare_workspace_edit(&host, "main", edit.clone()).await.unwrap(); + assert_eq!(preview.preconditions.len(), 2); + std::fs::write(&second, "own\n").unwrap(); + let error = ops::lsp::apply_workspace_edit(&host, "main", preview).await.unwrap_err(); + assert_eq!(error.kind, ErrorKind::OutOfSync); + assert_eq!(std::fs::read_to_string(&first).unwrap(), "old\n"); + + let preview = ops::lsp::prepare_workspace_edit(&host, "main", edit).await.unwrap(); + ops::lsp::apply_workspace_edit(&host, "main", preview).await.unwrap(); + assert_eq!(std::fs::read_to_string(&first).unwrap(), "new\n"); + assert_eq!(std::fs::read_to_string(&second).unwrap(), "new\n"); + let id = events + .lock() + .iter() + .find_map(|event| match event { + IdeEvent::WorkspaceEditApplied { id, .. } => Some(*id), + _ => None, + }) + .unwrap(); + ops::lsp::undo_workspace_edit(&host, "main", id).await.unwrap(); + assert_eq!(std::fs::read_to_string(&first).unwrap(), "old\n"); + assert_eq!(std::fs::read_to_string(&second).unwrap(), "own\n"); + assert_eq!(ops::doc::snapshot(&host, "main", info.id).await.unwrap().text, "old\n"); + ops::lsp::redo_workspace_edit(&host, "main", id).await.unwrap(); + assert_eq!(std::fs::read_to_string(&first).unwrap(), "new\n"); + assert_eq!(std::fs::read_to_string(&second).unwrap(), "new\n"); + host.clear_workspace_edit_history_on_exit(); + assert_eq!(ops::lsp::undo_workspace_edit(&host, "main", id).await.unwrap_err().kind, ErrorKind::NotFound); +} + +#[tokio::test(flavor = "current_thread")] +async fn contended_version_reads_do_not_block_the_async_worker() { + use ide_core::{LanguageId, Pos, Range, RequestId}; + use ide_workspace::documents::DocumentObserver; + use std::sync::mpsc; + use std::time::Duration; + + struct PausedWrite { + entered: Mutex>>, + release: Mutex>, + } + impl DocumentObserver for PausedWrite { + fn changed( + &self, + _: ide_core::DocId, + _: &std::path::Path, + _: u32, + _: &[ide_text::AppliedChange], + _: &dyn Fn() -> String, + ) { + if let Some(entered) = self.entered.lock().take() { + let _ = entered.send(()); + self.release.lock().recv_timeout(Duration::from_secs(10)).unwrap(); + } + } + } + + for request_kind in 0..5 { + let tmp = tempfile::tempdir().unwrap(); + let file = tmp.path().join("main.txt"); + std::fs::write(&file, "old").unwrap(); + let host = Arc::new(Host::new(HostConfig { + config_dir: tmp.path().join("config"), + data_dir: tmp.path().join("data"), + local_data_dir: tmp.path().join("local-data"), + languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()), + syntax: Arc::default(), + sink_for: Box::new(|_| Arc::new(ide_core::NullSink)), + owner: |window| window, + runtime: tokio::runtime::Handle::current(), + project_environment: false, + })); + let project = host.open_light("main", &file).unwrap(); + let doc = project.documents.open(&file).unwrap().id; + assert_eq!(project.documents.snapshot(doc).unwrap().language, LanguageId::plain_text()); + let (entered_tx, entered_rx) = tokio::sync::oneshot::channel(); + let (release_tx, release_rx) = mpsc::channel(); + project.documents.add_observer(Arc::new(PausedWrite { + entered: Mutex::new(Some(entered_tx)), + release: Mutex::new(release_rx), + })); + let documents = project.documents.clone(); + let writer = std::thread::spawn(move || { + documents + .apply_workspace_edit( + &serde_json::from_value(json!({ "files": [{ + "kind": "edit", "path": file, "edits": [{ "range": { + "start": { "line": 0, "col": 0 }, "end": { "line": 0, "col": 3 } + }, "new_text": "new" }] + }] })) + .unwrap(), + ) + .unwrap(); + }); + entered_rx.await.unwrap(); + let (heartbeat_tx, heartbeat_rx) = mpsc::channel(); + // Always release the writer, even on the old blocking implementation. + let watchdog = std::thread::spawn(move || { + let responsive = heartbeat_rx.recv_timeout(Duration::from_secs(2)).is_ok(); + release_tx.send(()).unwrap(); + responsive + }); + let (started_tx, started_rx) = tokio::sync::oneshot::channel(); + let task = tokio::spawn(async move { + let _ = started_tx.send(()); + let action = + || serde_json::from_value(json!({ "title": "test", "isPreferred": false, "server": 1 })).unwrap(); + match request_kind { + 0 => { + let _ = ops::lsp::lsp_code_actions(&host, "main", doc, Range::point(Pos::new(0, 0)), vec![]).await; + } + 1 => { + let _ = ops::lsp::lsp_code_action_query( + &host, + "main", + RequestId(1), + doc, + Range::point(Pos::new(0, 0)), + vec![], + ) + .await; + } + 2 => { + let _ = ops::lsp::lsp_resolve_code_action(&host, "main", doc, action()).await; + } + 3 => { + let _ = ops::lsp::lsp_apply_code_action(&host, "main", doc, action()).await; + } + _ => { + let _ = + ops::lsp::lsp_rename(&host, "main", RequestId(1), doc, Pos::new(0, 0), "other".into()).await; + } + } + }); + started_rx.await.unwrap(); + let _ = heartbeat_tx.send(()); + task.await.unwrap(); + writer.join().unwrap(); + assert!(watchdog.join().unwrap(), "request {request_kind} blocked the only async worker"); + } +} diff --git a/crates/ide-lsp/src/convert.rs b/crates/ide-lsp/src/convert.rs index 2e29724c1..1071b6957 100644 --- a/crates/ide-lsp/src/convert.rs +++ b/crates/ide-lsp/src/convert.rs @@ -621,7 +621,7 @@ pub fn workspace_edit(conv: &Conv, e: lsp::WorkspaceEdit) -> WorkspaceEdit { }) .map(|e| conv.text_edit(&path, e)) .collect(); - files.push(FileEdit::Edit { path: path.into(), edits }); + files.push(FileEdit::Edit { path: path.into(), edits, version: e.text_document.version }); }; if let Some(changes) = e.document_changes { match changes { @@ -657,11 +657,11 @@ pub fn workspace_edit(conv: &Conv, e: lsp::WorkspaceEdit) -> WorkspaceEdit { for (uri, edits) in changes { if let Some(path) = uri_path(&uri) { let edits = conv.text_edits(&path, edits); - files.push(FileEdit::Edit { path: path.into(), edits }); + files.push(FileEdit::Edit { path: path.into(), edits, version: None }); } } } - let mut edit = WorkspaceEdit { files }; + let mut edit = WorkspaceEdit { files, ..Default::default() }; edit.deduplicate(); edit } @@ -670,28 +670,36 @@ pub fn workspace_edit(conv: &Conv, e: lsp::WorkspaceEdit) -> WorkspaceEdit { /// `edit` where they leave its own alone: what only the other server sees /// (Angular templates next to vtsls's TypeScript). pub fn merge_workspace_edit(edit: &mut WorkspaceEdit, extra: WorkspaceEdit) { + edit.preconditions.extend(extra.preconditions); let overlap = |a: &TextEdit, b: &TextEdit| a.range == b.range || (a.range.start < b.range.end && b.range.start < a.range.end); for file in extra.files { - let FileEdit::Edit { path, edits } = file else { + let FileEdit::Edit { path, edits, version } = file else { if !edit.files.contains(&file) { edit.files.push(file); } continue; }; let own = edit.files.iter_mut().find_map(|f| match f { - FileEdit::Edit { path: p, edits } if *p == path => Some(edits), + FileEdit::Edit { path: p, edits, version } if *p == path => Some((edits, version)), _ => None, }); match own { - Some(own) => { + Some((own, own_version)) => { + // Conflicting versions cannot describe one consistent edit. Keep both + // operations so transaction preflight rejects them instead of dropping a guard. + if own_version.is_some() && version.is_some() && *own_version != version { + edit.files.push(FileEdit::Edit { path, edits, version }); + continue; + } + *own_version = own_version.or(version); let added: Vec = edits.into_iter().filter(|e| !own.iter().any(|o| overlap(o, e))).collect(); if !added.is_empty() { own.extend(added); own.sort_by_key(|e| e.range.start); } } - None => edit.files.push(FileEdit::Edit { path, edits }), + None => edit.files.push(FileEdit::Edit { path, edits, version }), } } } @@ -1036,16 +1044,20 @@ mod tests { range: Range::new(Pos::new(l, c), Pos::new(l, c + 5)), new_text: text.into(), }; - let file = |p: &str, edits: Vec| FileEdit::Edit { path: PathBuf::from(p).into(), edits }; + let file = + |p: &str, edits: Vec| FileEdit::Edit { path: PathBuf::from(p).into(), edits, version: None }; // vtsls renames a shorthand property with an alias; the Angular server // renames it plainly and adds the template. - let mut merged = - WorkspaceEdit { files: vec![file("/a.ts", vec![edit(3, 2, "items: entries"), edit(9, 4, "entries")])] }; + let mut merged = WorkspaceEdit { + files: vec![file("/a.ts", vec![edit(3, 2, "items: entries"), edit(9, 4, "entries")])], + ..Default::default() + }; let angular = WorkspaceEdit { files: vec![ file("/a.ts", vec![edit(9, 4, "entries"), edit(3, 2, "entries"), edit(1, 14, "entries")]), file("/a.html", vec![edit(0, 7, "entries")]), ], + ..Default::default() }; merge_workspace_edit(&mut merged, angular); assert_eq!( @@ -1306,7 +1318,7 @@ mod tests { assert_eq!(w.files.len(), 4); assert_eq!(w.files[0], FileEdit::Create { path: "/n.rs".into(), overwrite: true }); match &w.files[1] { - FileEdit::Edit { path, edits } => { + FileEdit::Edit { path, edits, .. } => { assert_eq!(path, Path::new("/n.rs")); assert_eq!(edits.len(), 2); assert_eq!(edits[1].new_text, "y"); @@ -1322,6 +1334,26 @@ mod tests { assert!(matches!(&workspace_edit(&conv, e).files[0], FileEdit::Edit { edits, .. } if edits[0].new_text == "q")); } + #[test] + fn workspace_edits_preserve_document_versions_and_merge_guards() { + let conv = Conv::utf16(); + let incoming = |version| { + serde_json::from_value(json!({ + "documentChanges": [{ + "textDocument": { "uri": "file:///a.rs", "version": version }, + "edits": [{ "range": { "start": { "line": 0, "character": 0 }, "end": { "line": 0, "character": 1 } }, "newText": "x" }] + }] + })).unwrap() + }; + let mut edit = workspace_edit(&conv, incoming(Some(7))); + assert!(matches!(edit.files[0], FileEdit::Edit { version: Some(7), .. })); + merge_workspace_edit(&mut edit, workspace_edit(&conv, incoming(None::))); + assert!(matches!(edit.files[0], FileEdit::Edit { version: Some(7), .. })); + merge_workspace_edit(&mut edit, workspace_edit(&conv, incoming(Some(8)))); + assert_eq!(edit.files.len(), 2, "inconsistent version guards must not be discarded"); + assert!(matches!(edit.files[1], FileEdit::Edit { version: Some(8), .. })); + } + #[test] fn semantic_token_decoding() { let data = [1, 2, 3, 0, 1, 0, 5, 2, 1, 0, 2, 0, 4, 2, 3]; diff --git a/crates/ide-lsp/tests/mock_server.rs b/crates/ide-lsp/tests/mock_server.rs index d2136467e..a473e8cbc 100644 --- a/crates/ide-lsp/tests/mock_server.rs +++ b/crates/ide-lsp/tests/mock_server.rs @@ -650,7 +650,7 @@ async fn diagnostics_become_events_and_feed_code_actions() { assert_eq!(s.requested("codeAction/resolve")[0]["data"], json!({ "id": 7 })); let edit = resolved.edit.unwrap(); assert!( - matches!(&edit.files[0], FileEdit::Edit { path: p, edits } if p == &path && edits[0].range == rng(0, 0, 1, 0)) + matches!(&edit.files[0], FileEdit::Edit { path: p, edits, .. } if p == &path && edits[0].range == rng(0, 0, 1, 0)) ); h.mgr.execute_command(doc, actions[1].command.clone().unwrap()).await.unwrap(); @@ -695,7 +695,9 @@ async fn apply_edit_hook_and_server_requests() { let edits = applied.lock().clone(); assert_eq!(edits.len(), 1); assert_eq!(edits[0].files[0], FileEdit::Create { path: new_file.into(), overwrite: false }); - assert!(matches!(&edits[0].files[1], FileEdit::Edit { path: p, edits } if p == &path && edits[0].new_text == "X")); + assert!( + matches!(&edits[0].files[1], FileEdit::Edit { path: p, edits, .. } if p == &path && edits[0].new_text == "X") + ); let peer = s.peer(); let cfg: Value = peer @@ -1391,7 +1393,7 @@ fn edits_in(edit: &WorkspaceEdit, path: &Path) -> Vec<(Range, String)> { edit.files .iter() .filter_map(|f| match f { - FileEdit::Edit { path: p, edits } if p.to_path_buf() == path => { + FileEdit::Edit { path: p, edits, .. } if p.to_path_buf() == path => { Some(edits.iter().map(|e| (e.range, e.new_text.clone()))) } _ => None, @@ -1788,7 +1790,7 @@ async fn will_and_did_rename_files_honour_filters() { assert_eq!(result.servers, vec!["mock".to_owned()]); assert!(result.errors.is_empty()); match &result.edit.files[..] { - [FileEdit::Edit { path, edits }] => { + [FileEdit::Edit { path, edits, .. }] => { assert_eq!(path, &main); assert_eq!(edits[0].range, rng(0, 4, 0, 7)); assert_eq!(edits[0].new_text, "lib"); diff --git a/crates/ide-lsp/tests/real_servers.rs b/crates/ide-lsp/tests/real_servers.rs index 3ce021f39..ace064017 100644 --- a/crates/ide-lsp/tests/real_servers.rs +++ b/crates/ide-lsp/tests/real_servers.rs @@ -468,7 +468,7 @@ fn edited_lines(edit: &ide_core::WorkspaceEdit, path: &Path) -> Vec { .files .iter() .filter_map(|f| match f { - ide_core::FileEdit::Edit { path: p, edits } if p.to_path_buf() == path => { + ide_core::FileEdit::Edit { path: p, edits, .. } if p.to_path_buf() == path => { Some(edits.iter().map(|e| e.range.start.line)) } _ => None, diff --git a/crates/ide-workspace/Cargo.toml b/crates/ide-workspace/Cargo.toml index 9036108e6..bf51d7934 100644 --- a/crates/ide-workspace/Cargo.toml +++ b/crates/ide-workspace/Cargo.toml @@ -19,6 +19,7 @@ semver = "1.0.28" parking_lot.workspace = true blake3.workspace = true tracing.workspace = true +tempfile.workspace = true globset.workspace = true [dev-dependencies] diff --git a/crates/ide-workspace/src/documents.rs b/crates/ide-workspace/src/documents.rs index 2be270353..8ea241afb 100644 --- a/crates/ide-workspace/src/documents.rs +++ b/crates/ide-workspace/src/documents.rs @@ -4,7 +4,7 @@ use ide_core::error::ErrorKind; use ide_core::event::NotifyLevel; use ide_core::{ChangeSet, DocId, IdeError, IdeEvent, IdeResult, LanguageId, SharedSink, TextEdit}; use ide_text::{AppliedChange, ByteEdit, Document, LineEnding}; -use parking_lot::{Mutex, RwLock}; +use parking_lot::{Mutex, ReentrantMutex, RwLock}; use serde::{Deserialize, Serialize}; use specta::Type; use std::collections::HashMap; @@ -62,6 +62,11 @@ pub type DetectUiMode = dyn Fn(&Path, &LanguageId) -> Option + Send + Sy /// Every document has its own lock; operations on one document are serialized /// and observers see them in version order. pub struct Documents { + /// Workspace transactions hold this across preflight/commit/rollback; ordinary + /// operations re-enter it while accessing the individual document locks. + operations: ReentrantMutex<()>, + workspace_edits: Arc>, + workspace_edit_active: std::sync::atomic::AtomicBool, registry: Mutex, observers: RwLock>>, sink: SharedSink, @@ -96,6 +101,8 @@ pub trait DocumentObserver: Send + Sync { fn closed(&self, _doc: DocId, _path: &Path) {} } +mod workspace_edit; + type Slot = Arc>; #[derive(Default)] @@ -111,6 +118,7 @@ struct RegEntry { keys: Vec, } +#[derive(Clone)] struct DocState { path: PathBuf, doc: Document, @@ -163,6 +171,9 @@ fn read_only_error(path: &Path) -> IdeError { impl Documents { pub fn new(sink: SharedSink, detect_language: Box) -> Self { Self { + operations: ReentrantMutex::new(()), + workspace_edits: Arc::default(), + workspace_edit_active: std::sync::atomic::AtomicBool::new(false), registry: Mutex::default(), observers: RwLock::default(), sink, @@ -279,6 +290,7 @@ impl Documents { /// Locks a document, failing if it was closed concurrently. fn with_doc(&self, doc: DocId, f: impl FnOnce(&mut DocState) -> IdeResult) -> IdeResult { + let _operation = self.operations.lock(); let slot = self.slot(doc)?; let mut st = slot.lock(); if st.closed { @@ -326,6 +338,7 @@ impl Documents { /// Opens (or re-references) the document at `path`. pub fn open(&self, path: &Path) -> IdeResult { + let _operation = self.operations.lock(); if let Some(info) = self.reopen(path) { return Ok(info); } @@ -418,6 +431,7 @@ impl Documents { /// language server provides for a non-`file:` URI. It is keyed by the URI, /// never touches the disk and cannot be edited or saved. pub fn open_virtual(&self, uri: &str, language: LanguageId, text: &str) -> IdeResult { + let _operation = self.operations.lock(); let key = PathBuf::from(uri); if let Some(info) = self.reopen(&key) { return Ok(info); @@ -476,6 +490,7 @@ impl Documents { } pub fn close(&self, doc: DocId) { + let _operation = self.operations.lock(); let slot = { let mut reg = self.registry.lock(); let Some(entry) = reg.docs.get_mut(&doc) else { return }; @@ -558,7 +573,13 @@ impl Documents { edits: &[ByteEdit], ) { self.notify_changed(doc, st, applied, edits); - self.sink.emit(IdeEvent::DocPatched { doc, base_version, version: st.doc.version(), changes: set.changes }); + self.sink.emit(IdeEvent::DocPatched { + doc, + base_version, + version: st.doc.version(), + changes: set.changes, + workspace_edit: self.workspace_edit_active.load(std::sync::atomic::Ordering::Relaxed), + }); } /// Minimal-diff replacement; no event when the text is unchanged. @@ -693,6 +714,11 @@ impl Documents { self.with_doc(doc, |st| Ok(st.dirty)) } + /// Current generation without copying the document text. + pub fn version(&self, doc: DocId) -> IdeResult { + self.with_doc(doc, |st| Ok(st.doc.version())) + } + /// Ids of all open documents. pub fn open_documents(&self) -> Vec { self.registry.lock().docs.keys().copied().collect() @@ -705,6 +731,7 @@ impl Documents { /// for the observers, so it moves to the matching language servers and /// syntax tree. Returns the changed documents. pub fn redetect_languages(&self) -> Vec { + let _operation = self.operations.lock(); let slots: Vec<(DocId, Slot)> = self.registry.lock().docs.iter().map(|(id, e)| (*id, e.slot.clone())).collect(); let mut changed = Vec::new(); for (id, slot) in slots { diff --git a/crates/ide-workspace/src/documents/workspace_edit.rs b/crates/ide-workspace/src/documents/workspace_edit.rs new file mode 100644 index 000000000..466987087 --- /dev/null +++ b/crates/ide-workspace/src/documents/workspace_edit.rs @@ -0,0 +1,1187 @@ +//! Preflight, rollback and grouped undo for edits spanning documents and files. + +use super::{DocState, Documents, RegEntry, disk_len, keys_for}; +use ide_core::error::ErrorKind; +use ide_core::{DocId, FileEdit, IdeError, IdeEvent, IdeResult, NullSink, WorkspaceEdit, WorkspaceEditPrecondition}; +use parking_lot::Mutex; +use std::collections::VecDeque; +use std::fs; +use std::io::Read; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Once}; +use std::time::{Duration, SystemTime}; + +const MAX_HISTORY_BYTES: u64 = 256 * 1024 * 1024; +const MAX_HISTORY_AGE: Duration = Duration::from_secs(24 * 60 * 60); +const SNAPSHOT_PREFIX: &str = "openidea-workspace-edit-"; + +struct ActiveEdit<'a>(&'a AtomicBool); +impl<'a> ActiveEdit<'a> { + fn new(active: &'a AtomicBool) -> Self { + active.store(true, Ordering::Relaxed); + Self(active) + } +} +impl Drop for ActiveEdit<'_> { + fn drop(&mut self) { + self.0.store(false, Ordering::Relaxed); + } +} + +pub(super) struct History { + next: u32, + reaper: bool, + records: VecDeque<(u32, Record)>, +} + +impl Default for History { + fn default() -> Self { + static CLEANUP: Once = Once::new(); + CLEANUP.call_once(|| cleanup_abandoned(&std::env::temp_dir())); + Self { next: 0, reaper: false, records: VecDeque::new() } + } +} + +impl History { + fn prune(&mut self, now: SystemTime, max_bytes: u64, max_age: Duration) { + self.records.retain(|(_, record)| now.duration_since(record.created).unwrap_or_default() < max_age); + let mut bytes: u64 = self.records.iter().map(|(_, record)| record.bytes).sum(); + while self.records.len() > 20 || bytes > max_bytes { + let Some((_, record)) = self.records.pop_front() else { break }; + bytes = bytes.saturating_sub(record.bytes); + } + } +} + +/// A locked owner file distinguishes live snapshots from abandoned undo/staging +/// data. Unknown legacy directories may contain recovery data, so leave them alone. +fn cleanup_abandoned(root: &Path) { + let Ok(entries) = fs::read_dir(root) else { return }; + for entry in entries.flatten() { + let path = entry.path(); + if !entry.file_name().to_string_lossy().starts_with(SNAPSHOT_PREFIX) + || !entry.file_type().is_ok_and(|kind| kind.is_dir()) + || path.join("recovery").exists() + { + continue; + } + let Ok(owner) = fs::OpenOptions::new().read(true).write(true).open(path.join("owner.lock")) else { continue }; + if owner.try_lock().is_ok() && !path.join("recovery").exists() { + // Windows cannot remove an open file. Once unlocked the abandoned + // directory cannot be acquired by a new transaction (names are unique). + drop(owner); + let _ = fs::remove_dir_all(path); + } + } +} + +fn snapshot_bytes(path: &Path) -> IdeResult { + let meta = fs::symlink_metadata(path)?; + if meta.is_dir() { + fs::read_dir(path)?.try_fold(0u64, |bytes, entry| Ok(bytes.saturating_add(snapshot_bytes(&entry?.path())?))) + } else { + Ok(meta.len()) + } +} + +struct SavedPath { + path: PathBuf, + backup: Option, +} + +struct Record { + // Drop the lock before TempDir removes the directory on Windows. + _owner: fs::File, + directory: tempfile::TempDir, + created: SystemTime, + bytes: u64, + undone: bool, + paths: Vec, + documents: Vec<(DocId, DocState)>, + after: Vec, +} + +fn stale(path: &Path) -> IdeError { + IdeError::new(ErrorKind::OutOfSync, format!("{} changed; request the refactoring again", path.display())) +} + +/// Absolute lexical spelling, without resolving links that resource operations may replace. +fn lexical_path(path: &Path) -> IdeResult { + let path = if path.is_absolute() { path.to_owned() } else { std::env::current_dir()?.join(path) }; + let mut clean = PathBuf::new(); + for part in path.components() { + match part { + std::path::Component::CurDir => {} + std::path::Component::ParentDir => { + clean.pop(); + } + other => clean.push(other), + } + } + Ok(clean) +} + +/// Resolve parent aliases while retaining the last component of resource operations. +fn normalized(path: &Path) -> IdeResult { + let clean = lexical_path(path)?; + let Some(name) = clean.file_name() else { return Ok(clean) }; + let parent = clean.parent().expect("absolute path"); + let parent = dunce::canonicalize(parent).map_err(IdeError::from).or_else(|_| normalized(parent))?; + Ok(parent.join(name)) +} + +fn text_path(path: &Path) -> IdeResult { + dunce::canonicalize(path).map_err(IdeError::from).or_else(|_| normalized(path)) +} + +/// Resolve existing parent links one component at a time. Stop before entering +/// any resource operation's namespace, even through another spelling or link chain. +fn after_resources(path: &Path, changed: &[(PathBuf, PathBuf)], links: u32) -> IdeResult { + if links > 40 { + return Err(IdeError::invalid("too many symbolic links in workspace edit")); + } + let path = lexical_path(path)?; + let parts: Vec<_> = path.components().collect(); + let mut current = PathBuf::new(); + for (index, part) in parts.iter().enumerate() { + current.push(part); + if let Some((_, resolved)) = changed.iter().find(|(original, _)| *original == current) { + let suffix: PathBuf = parts[index + 1..].iter().collect(); + return Ok(if suffix.as_os_str().is_empty() { resolved.clone() } else { resolved.join(suffix) }); + } + if index + 1 == parts.len() || !matches!(part, std::path::Component::Normal(_)) { + continue; + } + match fs::symlink_metadata(¤t) { + Ok(meta) if meta.file_type().is_symlink() => { + let target = fs::read_link(¤t)?; + let target = if target.is_absolute() { target } else { current.parent().expect("parent").join(target) }; + let suffix: PathBuf = parts[index + 1..].iter().collect(); + return after_resources(&target.join(suffix), changed, links + 1); + } + Err(error) if !matches!(error.kind(), std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory) => { + return Err(error.into()); + } + _ => {} + } + if let Ok(canonical) = dunce::canonicalize(¤t) { + current = canonical; + } + } + Ok(current) +} + +fn normalize_edit(edit: &WorkspaceEdit) -> IdeResult { + // Later paths below a resource operation refer to its future filesystem + // state. Preserve that suffix, including links in the old directory tree. + let mut changed: Vec<(PathBuf, PathBuf)> = Vec::new(); + let mut files = Vec::new(); + for file in &edit.files { + let resolve = |path: &Path, text: bool| -> IdeResult { + if !changed.is_empty() { + return after_resources(path, &changed, 0); + } + if text { text_path(path) } else { normalized(path) } + }; + let next = match file { + FileEdit::Edit { path, edits, version } => { + FileEdit::Edit { path: resolve(path, true)?.into(), edits: edits.clone(), version: *version } + } + FileEdit::Create { path, overwrite } => { + FileEdit::Create { path: resolve(path, false)?.into(), overwrite: *overwrite } + } + FileEdit::Rename { from, to } => { + FileEdit::Rename { from: resolve(from, false)?.into(), to: resolve(to, false)?.into() } + } + FileEdit::Delete { path, recursive } => { + FileEdit::Delete { path: resolve(path, false)?.into(), recursive: *recursive } + } + }; + let resources: Vec<_> = match (file, &next) { + (FileEdit::Rename { from, to }, FileEdit::Rename { from: new_from, to: new_to }) => { + vec![(from, new_from), (to, new_to)] + } + (FileEdit::Create { path, .. }, FileEdit::Create { path: new, .. }) + | (FileEdit::Delete { path, .. }, FileEdit::Delete { path: new, .. }) => vec![(path, new)], + _ => Vec::new(), + }; + for (original, resolved) in resources { + changed.push((lexical_path(original)?, resolved.to_path_buf())); + changed.push((resolved.to_path_buf(), resolved.to_path_buf())); + } + files.push(next); + } + Ok(WorkspaceEdit { files, preconditions: edit.preconditions.clone() }) +} + +fn paths(edit: &WorkspaceEdit) -> IdeResult> { + let mut paths = Vec::new(); + for file in &edit.files { + match file { + FileEdit::Edit { path, .. } | FileEdit::Create { path, .. } | FileEdit::Delete { path, .. } => { + paths.push(path.to_path_buf()) + } + FileEdit::Rename { from, to } => { + if to != from && to.starts_with(from) { + return Err(IdeError::invalid("cannot move a directory into itself")); + } + paths.extend([from.to_path_buf(), to.to_path_buf()]); + } + } + } + // Include missing parents so rollback/undo also removes directories we create. + for path in &mut paths { + while let Some(parent) = path.parent() { + match fs::symlink_metadata(parent) { + Ok(_) => break, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => *path = parent.to_owned(), + Err(e) => return Err(e.into()), + } + } + if path.parent().is_none() { + return Err(IdeError::invalid("cannot refactor a filesystem root")); + } + } + paths.sort(); + paths.dedup(); + let mut roots: Vec = Vec::new(); + for path in paths { + if !roots.iter().any(|p| path.starts_with(p)) { + roots.push(path); + } + } + Ok(roots) +} + +fn hash_bytes(hash: &mut blake3::Hasher, bytes: &[u8]) { + hash.update(&(bytes.len() as u64).to_le_bytes()); + hash.update(bytes); +} + +fn hash_disk(hash: &mut blake3::Hasher, path: &Path) -> IdeResult<()> { + let meta = match fs::symlink_metadata(path) { + Ok(meta) => meta, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + hash.update(b"missing"); + return Ok(()); + } + Err(e) => return Err(e.into()), + }; + hash.update(&[u8::from(meta.permissions().readonly())]); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + hash.update(&meta.permissions().mode().to_le_bytes()); + } + if meta.file_type().is_symlink() { + hash.update(b"link"); + hash_bytes(hash, fs::read_link(path)?.as_os_str().as_encoded_bytes()); + } else if meta.is_file() { + hash.update(b"file"); + hash.update(&meta.len().to_le_bytes()); + let mut file = fs::File::open(path)?; + let mut buffer = [0; 64 * 1024]; + loop { + let count = file.read(&mut buffer)?; + if count == 0 { + break; + } + hash.update(&buffer[..count]); + } + } else if meta.is_dir() { + hash.update(b"directory"); + let mut entries = fs::read_dir(path)?.collect::, _>>()?; + entries.sort_by_key(|e| e.file_name()); + for entry in entries { + hash_bytes(hash, entry.file_name().as_encoded_bytes()); + hash_disk(hash, &entry.path())?; + } + } else { + return Err(IdeError::invalid(format!("{} is not a regular file or directory", path.display()))); + } + Ok(()) +} + +/// A reverted edit may leave the dirty flag set even when all saved bytes match. +fn differs_from_disk(state: &DocState) -> IdeResult { + let text = state.doc.text_for_save(); + let bytes = if state.native_to_ascii { + ide_vfs::encode_properties_ascii(&text).into_bytes() + } else { + ide_vfs::encode(&text, &state.encoding, state.has_bom)? + }; + match fs::read(&state.path) { + Ok(disk) => Ok(disk != bytes), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(true), + Err(error) => Err(error.into()), + } +} + +impl Documents { + /// Capture disk and unsaved-buffer contents before returning an edit for preview. + /// Existing preconditions must survive preview filtering and code-action resolution. + pub fn prepare_workspace_edit(&self, edit: &mut WorkspaceEdit) -> IdeResult<()> { + let _operation = self.operations.lock(); + if edit.preconditions.is_empty() { + edit.preconditions = self.fingerprints(&paths(&normalize_edit(edit)?)?)?; + } + Ok(()) + } + + fn fingerprints(&self, paths: &[PathBuf]) -> IdeResult> { + paths + .iter() + .map(|path| { + let mut hash = blake3::Hasher::new(); + hash_disk(&mut hash, path)?; + let mut dirty = Vec::new(); + for id in self.open_documents() { + self.with_doc(id, |state| { + let file = text_path(&state.path)?; + if state.dirty && file.starts_with(path) && differs_from_disk(state)? { + dirty.push(( + file, + state.doc.text(), + state.encoding.clone(), + state.doc.line_ending(), + state.has_bom, + )); + } + Ok(()) + })?; + } + dirty.sort_by(|a, b| a.0.cmp(&b.0)); + for (file, text, encoding, ending, bom) in dirty { + hash_bytes(&mut hash, file.as_os_str().as_encoded_bytes()); + hash_bytes(&mut hash, text.as_bytes()); + hash_bytes(&mut hash, encoding.as_bytes()); + hash_bytes(&mut hash, ending.as_str().as_bytes()); + hash.update(&[u8::from(bom)]); + } + Ok(WorkspaceEditPrecondition { path: path.clone().into(), hash: hash.finalize().to_hex().to_string() }) + }) + .collect() + } + + fn validate_preconditions(&self, expected: &[WorkspaceEditPrecondition]) -> IdeResult<()> { + let current = self.fingerprints(&expected.iter().map(|p| p.path.to_path_buf()).collect::>())?; + for (before, after) in expected.iter().zip(current) { + if before.hash != after.hash { + return Err(stale(&before.path)); + } + } + Ok(()) + } + + /// Preflight every operation on a private copy, then apply with rollback and grouped undo. + pub fn apply_workspace_edit(&self, edit: &WorkspaceEdit) -> IdeResult<()> { + self.apply_workspace_edit_inner(edit, true, &mut |_| Ok(())) + } + + /// File moves already own a combined move/reference undo record. + pub fn apply_file_move_edits(&self, edit: &WorkspaceEdit) -> IdeResult<()> { + self.apply_workspace_edit_inner(edit, false, &mut |_| Ok(())) + } + + fn apply_workspace_edit_inner( + &self, + edit: &WorkspaceEdit, + remember: bool, + before_operation: &mut dyn FnMut(usize) -> IdeResult<()>, + ) -> IdeResult<()> { + let _operation = self.operations.lock(); + if edit.files.is_empty() { + return Ok(()); + } + let mut edit = normalize_edit(edit)?; + self.prepare_workspace_edit(&mut edit)?; + self.validate_preconditions(&edit.preconditions)?; + for file in &edit.files { + if let FileEdit::Edit { path, version: Some(version), .. } = file { + let doc = self.by_path(path).ok_or_else(|| stale(path))?; + if i64::from(self.version(doc)?) != i64::from(*version) { + return Err(stale(path)); + } + } + } + edit.deduplicate(); + let roots = paths(&edit)?; + // The watcher may not have delivered an external write yet. Never save + // an edit over disk contents that differ from a supposedly clean buffer. + for id in self.open_documents() { + self.with_doc(id, |state| { + if !state.dirty + && roots.iter().any(|root| text_path(&state.path).is_ok_and(|path| path.starts_with(root))) + { + let loaded = ide_vfs::load_text_as(&state.path, &state.encoding)?; + if ide_text::normalize_line_endings(&loaded.text) != state.doc.text() { + return Err(stale(&state.path)); + } + } + Ok(()) + })?; + } + self.workspace_edits.lock().prune(SystemTime::now(), MAX_HISTORY_BYTES, MAX_HISTORY_AGE); + let mut record = self.backup(&roots)?; + self.preflight(&record, &edit)?; + fs::remove_dir_all(record.directory.path().join("stage"))?; + self.validate_preconditions(&edit.preconditions)?; + // A crash during commit can still need these files. Only a completed + // commit turns this into an expendable undo snapshot. + fs::write(record.directory.path().join("recovery"), b"commit in progress")?; + let _active = ActiveEdit::new(&self.workspace_edit_active); + if let Err(error) = self.apply_sequence(&edit, before_operation) { + return Err(self.recover(record, error)); + } + record.after = match self.fingerprints(&roots) { + Ok(after) => after, + Err(error) => return Err(self.recover(record, error)), + }; + if let Err(error) = fs::remove_file(record.directory.path().join("recovery")) { + return Err(self.recover(record, error.into())); + } + if remember { + let mut history = self.workspace_edits.lock(); + history.next += 1; + let id = history.next; + let mut affected: Vec = roots.into_iter().map(Into::into).collect(); + affected.extend(record.documents.iter().map(|(_, state)| state.path.clone().into())); + affected.sort(); + affected.dedup(); + history.records.push_back((id, record)); + if !history.reaper { + history.reaper = true; + let weak = Arc::downgrade(&self.workspace_edits); + std::thread::spawn(move || { + loop { + std::thread::sleep(Duration::from_secs(60)); + let Some(history) = weak.upgrade() else { break }; + let mut history = history.lock(); + history.prune(SystemTime::now(), MAX_HISTORY_BYTES, MAX_HISTORY_AGE); + if history.records.is_empty() { + history.reaper = false; + break; + } + } + }); + } + history.prune(SystemTime::now(), MAX_HISTORY_BYTES, MAX_HISTORY_AGE); + self.sink.emit(IdeEvent::WorkspaceEditApplied { id, paths: affected }); + } + Ok(()) + } + + fn backup(&self, roots: &[PathBuf]) -> IdeResult { + let bytes = roots.iter().try_fold(0u64, |bytes, path| { + let size = if fs::symlink_metadata(path).is_ok() { snapshot_bytes(path)? } else { 0 }; + Ok::<_, IdeError>(bytes.saturating_add(size)) + })?; + if bytes > MAX_HISTORY_BYTES { + return Err(IdeError::invalid("workspace edit exceeds the 256 MiB recovery snapshot limit")); + } + let directory = tempfile::Builder::new().prefix(SNAPSHOT_PREFIX).tempdir()?; + // Publish the owner path only after acquiring its lock, so another + // starting process cannot mistake a new snapshot for an abandoned one. + let owner = tempfile::NamedTempFile::new_in(directory.path())?; + owner.as_file().lock()?; + let owner = owner + .persist_noclobber(directory.path().join("owner.lock")) + .map_err(|error| IdeError::from(error.error))?; + let mut paths = Vec::new(); + for (index, path) in roots.iter().enumerate() { + let backup = match fs::symlink_metadata(path) { + Ok(_) => { + let backup = directory.path().join(format!("before-{index}")); + copy_snapshot(path, &backup)?; + Some(backup) + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => None, + Err(e) => return Err(e.into()), + }; + paths.push(SavedPath { path: path.clone(), backup }); + } + let mut documents = Vec::new(); + for id in self.open_documents() { + self.with_doc(id, |state| { + if roots.iter().any(|p| text_path(&state.path).is_ok_and(|file| file.starts_with(p))) { + documents.push((id, state.clone())); + } + Ok(()) + })?; + } + let manifest: Vec<_> = paths + .iter() + .map(|saved| { + ( + ide_core::IpcPath::from(saved.path.clone()), + saved.backup.as_ref().map(|path| ide_core::IpcPath::from(path.clone())), + ) + }) + .collect(); + fs::write( + directory.path().join("paths.json"), + serde_json::to_vec(&manifest).map_err(|error| IdeError::internal(error.to_string()))?, + )?; + let bytes = snapshot_bytes(directory.path())? + .saturating_add(documents.iter().map(|(_, state)| u64::from(state.doc.len_utf16()) * 2).sum::()); + if bytes > MAX_HISTORY_BYTES { + return Err(IdeError::invalid("workspace edit exceeds the 256 MiB recovery snapshot limit")); + } + Ok(Record { + _owner: owner, + directory, + created: SystemTime::now(), + bytes, + undone: false, + paths, + documents, + after: Vec::new(), + }) + } + + fn preflight(&self, record: &Record, edit: &WorkspaceEdit) -> IdeResult<()> { + let stage = dunce::canonicalize(record.directory.path())?.join("stage"); + let map = |path: &Path| -> PathBuf { + let volume = path.ancestors().last().expect("absolute path"); + let key = blake3::hash(volume.as_os_str().as_encoded_bytes()).to_hex(); + stage.join(key.as_str()).join(path.strip_prefix(volume).expect("inside volume")) + }; + for root in &record.paths { + if let Some(backup) = &root.backup { + let target = map(&root.path); + if fs::symlink_metadata(&target).is_err() { + copy_snapshot(backup, &target)?; + } + } + } + let staged = Documents::new(Arc::new(NullSink), Box::new(|_, _| ide_core::LanguageId::plain_text())); + staged.set_file_defaults(self.file_defaults()); + let mut seed = record.documents.clone(); + // Closed files need the original path's encoding/EditorConfig too, not + // the settings of the temporary staging directory. + for file in &edit.files { + if let FileEdit::Edit { path, .. } = file + && path.is_file() + && self.by_path(path).is_none() + { + let info = self.open(path)?; + let state = self.with_doc(info.id, |state| Ok(state.clone())); + self.close(info.id); + seed.push((info.id, state?)); + } + } + for (id, state) in &seed { + let mut state = state.clone(); + state.path = map(&text_path(&state.path)?); + state.observed = false; + let keys = keys_for(&state.path); + let mut registry = staged.registry.lock(); + for key in &keys { + registry.by_key.insert(key.clone(), *id); + } + registry.docs.insert(*id, RegEntry { slot: Arc::new(Mutex::new(state)), refs: 1, keys }); + } + let files = edit + .files + .iter() + .map(|file| match file { + FileEdit::Edit { path, edits, version } => { + FileEdit::Edit { path: map(path).into(), edits: edits.clone(), version: *version } + } + FileEdit::Create { path, overwrite } => { + FileEdit::Create { path: map(path).into(), overwrite: *overwrite } + } + FileEdit::Rename { from, to } => FileEdit::Rename { from: map(from).into(), to: map(to).into() }, + FileEdit::Delete { path, recursive } => { + FileEdit::Delete { path: map(path).into(), recursive: *recursive } + } + }) + .collect(); + staged.apply_sequence(&WorkspaceEdit { files, preconditions: Vec::new() }, &mut |_| Ok(())).map_err( + |mut error| { + for root in &record.paths { + error.message = error + .message + .replace(&map(&root.path).to_string_lossy().to_string(), &root.path.to_string_lossy()); + } + error + }, + ) + } + + fn apply_sequence( + &self, + edit: &WorkspaceEdit, + before_operation: &mut dyn FnMut(usize) -> IdeResult<()>, + ) -> IdeResult<()> { + for (index, file) in edit.files.iter().enumerate() { + before_operation(index)?; + match file { + FileEdit::Edit { path, .. } | FileEdit::Create { path, .. } | FileEdit::Delete { path, .. } => { + check_link_parents(path)?; + } + FileEdit::Rename { from, to } => { + check_link_parents(from)?; + check_link_parents(to)?; + } + } + match file { + FileEdit::Edit { path, edits, .. } => { + if fs::symlink_metadata(path)?.file_type().is_symlink() { + return Err(IdeError::invalid("cannot edit a symbolic link introduced by a workspace edit")); + } + let info = self.open(path)?; + let result: IdeResult<()> = (|| { + // In-place rename restores its temporary typing before the + // server request. A dirty flag alone can therefore describe + // a buffer whose encoded bytes already match the saved file. + let unsaved = self.with_doc(info.id, |state| Ok(state.dirty && differs_from_disk(state)?))?; + self.apply_edits(info.id, edits)?; + // Do not save unrelated unsaved work as a side effect of a refactoring. + if !unsaved { + self.save(info.id)?; + } else { + self.with_doc(info.id, |state| { + ide_vfs::encode(&state.doc.text_for_save(), &state.encoding, state.has_bom).map(|_| ()) + })?; + } + Ok(()) + })(); + self.close(info.id); + result?; + } + FileEdit::Create { path, overwrite } => { + if path.exists() { + if !overwrite { + return Err(IdeError::new( + ErrorKind::AlreadyExists, + format!("{} already exists", path.display()), + )); + } + self.check_resource_buffers(path)?; + if fs::symlink_metadata(path)?.file_type().is_symlink() { + return Err(IdeError::invalid("cannot overwrite a symbolic link in a workspace edit")); + } + if !path.is_file() { + return Err(IdeError::invalid("cannot overwrite a directory")); + } + ide_vfs::save_text(path, "", "utf-8", false)?; + } else { + ide_vfs::create_file(path)?; + } + self.reload_resource_buffers(&[path.to_path_buf()])?; + } + FileEdit::Rename { from, to } => { + self.check_resource_buffers(from)?; + ide_vfs::rename(from, to)?; + } + FileEdit::Delete { path, recursive } => { + self.check_resource_buffers(path)?; + if fs::symlink_metadata(path)?.is_dir() && !recursive && fs::read_dir(path)?.next().is_some() { + return Err(IdeError::invalid("recursive deletion was not requested")); + } + ide_vfs::delete_permanently(path)?; + } + } + } + Ok(()) + } + + fn check_resource_buffers(&self, path: &Path) -> IdeResult<()> { + for id in self.open_documents() { + self.with_doc(id, |state| { + if state.dirty && text_path(&state.path)?.starts_with(path) { + return Err(IdeError::invalid(format!( + "save the unsaved changes in {} before changing its file", + state.path.display() + ))); + } + Ok(()) + })?; + } + Ok(()) + } + + fn restore(&self, record: &Record) -> IdeResult<()> { + let mut errors = Vec::new(); + let mut failed_paths = Vec::new(); + for saved in &record.paths { + let result = (|| { + match fs::symlink_metadata(&saved.path) { + Ok(_) => ide_vfs::delete_permanently(&saved.path)?, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(e.into()), + } + if let Some(backup) = &saved.backup { + copy_snapshot(backup, &saved.path)?; + } + Ok::<_, IdeError>(()) + })(); + if let Err(error) = result { + failed_paths.push(saved.path.clone()); + errors.push(format!("{}: {error}", saved.path.display())); + } + } + for (id, before) in &record.documents { + if self.slot(*id).is_err() { + continue; + } + let result = self.with_doc(*id, |state| { + self.replace_locked(*id, state, &before.doc.text())?; + state.encoding = before.encoding.clone(); + state.has_bom = before.has_bom; + state.doc.set_line_ending(before.doc.line_ending()); + state.disk_mtime = fs::metadata(&state.path).map(|m| ide_vfs::mtime_ms(&m)).unwrap_or(0.0); + state.disk_len = disk_len(&state.path); + let disk_failed = + text_path(&state.path).is_ok_and(|path| failed_paths.iter().any(|failed| path.starts_with(failed))); + let dirty = disk_failed || (before.dirty && differs_from_disk(state)?); + self.set_dirty(*id, state, dirty); + Ok(()) + }); + if let Err(error) = result { + errors.push(error.to_string()); + } + } + // A file may have been opened after the refactoring; its clean buffer must follow undo too. + let paths: Vec = self + .open_documents() + .into_iter() + .filter(|id| !record.documents.iter().any(|(saved, _)| saved == id)) + .filter_map(|id| self.with_doc(id, |state| Ok(state.path.clone())).ok()) + .filter(|path| { + text_path(path).is_ok_and(|path| record.paths.iter().any(|saved| path.starts_with(&saved.path))) + }) + .collect(); + if let Err(error) = self.reload_resource_buffers(&paths) { + errors.push(error.to_string()); + } + if errors.is_empty() { Ok(()) } else { Err(IdeError::internal(errors.join("; "))) } + } + + fn reload_resource_buffers(&self, paths: &[PathBuf]) -> IdeResult<()> { + for path in paths { + let Some(id) = self.by_path(path) else { continue }; + if !path.is_file() { + continue; + } + self.with_doc(id, |state| { + if state.dirty { + return Err(stale(path)); + } + let defaults = self.file_defaults(); + let loaded = ide_vfs::load_text_with_fallback(path, defaults.fallback_for(path))?; + self.replace_locked(id, state, &loaded.text)?; + state.encoding = loaded.encoding; + state.has_bom = loaded.has_bom; + state.disk_mtime = loaded.mtime_ms; + state.disk_len = disk_len(path); + Ok(()) + })?; + } + Ok(()) + } + + fn recover(&self, record: Record, error: IdeError) -> IdeError { + match self.restore(&record) { + Ok(()) => error, + Err(rollback) => { + let directory = record.directory.keep(); + IdeError::new( + error.kind, + format!("{error}; rollback failed: {rollback}. Recovery files: {}", directory.display()), + ) + } + } + } + + /// Explicitly release disposable snapshots before an exit that bypasses Drop. + /// Failed-rollback recovery directories are detached and remain untouched. + pub fn clear_workspace_edit_history(&self) { + let _operation = self.operations.lock(); + self.workspace_edits.lock().records.clear(); + } + + /// Refuse to overwrite work made after the refactoring; keep the undo record on failure. + pub fn undo_workspace_edit(&self, id: u32) -> IdeResult<()> { + self.workspace_edit_history(id, false) + } + + pub fn redo_workspace_edit(&self, id: u32) -> IdeResult<()> { + self.workspace_edit_history(id, true) + } + + fn workspace_edit_history(&self, id: u32, redo: bool) -> IdeResult<()> { + let _operation = self.operations.lock(); + let mut history = self.workspace_edits.lock(); + history.prune(SystemTime::now(), MAX_HISTORY_BYTES, MAX_HISTORY_AGE); + let index = history + .records + .iter() + .position(|(key, _)| *key == id) + .ok_or_else(|| IdeError::not_found("the refactoring can no longer be undone"))?; + let record = &history.records[index].1; + if record.undone != redo { + return Err(IdeError::invalid("the refactoring is already in the requested history state")); + } + self.validate_preconditions(&record.after)?; + let roots = record.paths.iter().map(|p| p.path.clone()).collect::>(); + let mut current = self.backup(&roots)?; + fs::write(current.directory.path().join("recovery"), b"history restore in progress")?; + let _active = ActiveEdit::new(&self.workspace_edit_active); + if let Err(error) = self.restore(record) { + return Err(self.recover(current, error)); + } + current.after = match self.fingerprints(&roots) { + Ok(after) => after, + Err(error) => return Err(self.recover(current, error)), + }; + current.undone = !redo; + current.created = record.created; + if let Err(error) = fs::remove_file(current.directory.path().join("recovery")) { + return Err(self.recover(current, error.into())); + } + history.records[index].1 = current; + history.prune(SystemTime::now(), MAX_HISTORY_BYTES, MAX_HISTORY_AGE); + Ok(()) + } +} + +fn check_link_parents(path: &Path) -> IdeResult<()> { + for parent in path.ancestors().skip(1) { + match fs::symlink_metadata(parent) { + Ok(meta) if meta.file_type().is_symlink() => { + return Err(IdeError::invalid(format!("workspace edit traverses symbolic link {}", parent.display()))); + } + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error.into()), + } + } + Ok(()) +} + +/// Snapshot links themselves, including dangling links, instead of their targets. +fn copy_snapshot(from: &Path, to: &Path) -> IdeResult<()> { + let metadata = fs::symlink_metadata(from)?; + if let Some(parent) = to.parent() { + fs::create_dir_all(parent)?; + } + if metadata.file_type().is_symlink() { + let target = fs::read_link(from)?; + #[cfg(unix)] + std::os::unix::fs::symlink(target, to)?; + #[cfg(windows)] + { + use std::os::windows::fs::FileTypeExt; + if metadata.file_type().is_symlink_dir() { + std::os::windows::fs::symlink_dir(target, to)?; + } else { + std::os::windows::fs::symlink_file(target, to)?; + } + } + } else if metadata.is_dir() { + fs::create_dir(to)?; + for entry in fs::read_dir(from)? { + let entry = entry?; + copy_snapshot(&entry.path(), &to.join(entry.file_name()))?; + } + fs::set_permissions(to, metadata.permissions())?; + } else if metadata.is_file() { + fs::copy(from, to)?; + } else { + return Err(IdeError::invalid("cannot snapshot a special file")); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use ide_core::{ChangeSet, Pos, Range, TextChange, TextEdit}; + + #[test] + fn snapshot_size_age_and_exit_cleanup_release_files() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("file.txt"); + fs::write(&path, "old").unwrap(); + let docs = Documents::new(Arc::new(NullSink), Box::new(|_, _| ide_core::LanguageId::plain_text())); + let mut first = docs.backup(std::slice::from_ref(&path)).unwrap(); + let old_dir = first.directory.path().to_owned(); + let second = docs.backup(std::slice::from_ref(&path)).unwrap(); + let new_dir = second.directory.path().to_owned(); + let max_bytes = second.bytes; + first.created = SystemTime::now() - MAX_HISTORY_AGE; + let mut history = docs.workspace_edits.lock(); + history.records.push_back((1, first)); + history.records.push_back((2, second)); + history.prune(SystemTime::now(), MAX_HISTORY_BYTES, MAX_HISTORY_AGE); + assert!(!old_dir.exists(), "expired snapshots are deleted, not just unindexed"); + assert!(new_dir.exists()); + let third = docs.backup(std::slice::from_ref(&path)).unwrap(); + let last_dir = third.directory.path().to_owned(); + history.records.push_back((3, third)); + history.prune(SystemTime::now(), max_bytes, MAX_HISTORY_AGE); + assert!(!new_dir.exists(), "the byte budget evicts the oldest record"); + assert!(last_dir.exists()); + drop(history); + docs.clear_workspace_edit_history(); + assert!(!last_dir.exists(), "normal exit explicitly releases snapshots"); + } + + #[test] + fn oversized_snapshot_is_refused_before_mutating_any_file() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("huge.bin"); + fs::File::create(&path).unwrap().set_len(MAX_HISTORY_BYTES + 1).unwrap(); + let docs = Documents::new(Arc::new(NullSink), Box::new(|_, _| ide_core::LanguageId::plain_text())); + let error = docs + .apply_workspace_edit(&WorkspaceEdit { + files: vec![FileEdit::Delete { path: path.clone().into(), recursive: false }], + ..Default::default() + }) + .unwrap_err(); + assert!(error.message.contains("256 MiB")); + assert_eq!(fs::metadata(path).unwrap().len(), MAX_HISTORY_BYTES + 1); + } + + #[test] + fn snapshot_crash_child() { + let Some(root) = std::env::var_os("OPENIDEA_SNAPSHOT_CRASH_FIXTURE") else { return }; + let root = PathBuf::from(root); + for (name, recovery) in [("undo", false), ("recovery", true)] { + let directory = tempfile::Builder::new().prefix(SNAPSHOT_PREFIX).tempdir_in(&root).unwrap(); + let owner = fs::OpenOptions::new() + .create_new(true) + .read(true) + .write(true) + .open(directory.path().join("owner.lock")) + .unwrap(); + owner.lock().unwrap(); + fs::write(directory.path().join("paths.json"), b"[]").unwrap(); + if recovery { + fs::write(directory.path().join("recovery"), b"rollback required").unwrap(); + } + fs::write(root.join(name), directory.path().to_string_lossy().as_bytes()).unwrap(); + // Simulate process death: neither TempDir nor the owner lock is dropped. + std::mem::forget(owner); + std::mem::forget(directory); + } + std::process::exit(0); + } + + #[test] + fn startup_cleanup_after_crash_keeps_live_and_recovery_snapshots() { + let root = tempfile::tempdir().unwrap(); + let status = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "documents::workspace_edit::tests::snapshot_crash_child"]) + .env("OPENIDEA_SNAPSHOT_CRASH_FIXTURE", root.path()) + .status() + .unwrap(); + assert!(status.success()); + let undo = PathBuf::from(fs::read_to_string(root.path().join("undo")).unwrap()); + let recovery = PathBuf::from(fs::read_to_string(root.path().join("recovery")).unwrap()); + assert!(undo.exists()); + let live = tempfile::Builder::new().prefix(SNAPSHOT_PREFIX).tempdir_in(root.path()).unwrap(); + let owner = fs::OpenOptions::new() + .create_new(true) + .read(true) + .write(true) + .open(live.path().join("owner.lock")) + .unwrap(); + owner.lock().unwrap(); + let unknown = root.path().join(format!("{SNAPSHOT_PREFIX}legacy-recovery")); + fs::create_dir(&unknown).unwrap(); + cleanup_abandoned(root.path()); + assert!(!undo.exists()); + assert!(recovery.exists(), "crash recovery files are never expired or size-evicted"); + assert!(live.path().exists(), "another live app's owner lock protects its snapshots"); + assert!(unknown.exists(), "legacy recovery files have no safe deletion marker"); + drop(owner); + cleanup_abandoned(root.path()); + assert!(!live.path().exists()); + } + + #[test] + fn java_mid_apply_failure_rolls_back_declaration_and_closed_usage() { + let tmp = tempfile::tempdir().unwrap(); + let declaration = tmp.path().join("Main.java"); + let usage = tmp.path().join("Use.java"); + fs::write(&declaration, "class Original {}\n").unwrap(); + fs::write(&usage, "class Use { Original value; }\n").unwrap(); + let events = Arc::new(Mutex::new(Vec::new())); + let sink = events.clone(); + let docs = + Documents::new(Arc::new(move |e| sink.lock().push(e)), Box::new(|_, _| ide_core::LanguageId::new("java"))); + let info = docs.open(&declaration).unwrap(); + let edit = WorkspaceEdit { + files: vec![(declaration.clone(), 6, Some(info.version as i32)), (usage.clone(), 12, None)] + .into_iter() + .map(|(path, col, version)| FileEdit::Edit { + path: path.into(), + version, + edits: vec![TextEdit { + range: Range::new(Pos::new(0, col), Pos::new(0, col + 8)), + new_text: "Renamed".into(), + }], + }) + .collect(), + ..Default::default() + }; + let error = docs + .apply_workspace_edit_inner(&edit, true, &mut |index| { + if index == 1 { Err(IdeError::internal("injected Java usage write failure")) } else { Ok(()) } + }) + .unwrap_err(); + assert!(error.message.contains("injected Java")); + assert_eq!(fs::read_to_string(&declaration).unwrap(), "class Original {}\n"); + assert_eq!(fs::read_to_string(&usage).unwrap(), "class Use { Original value; }\n"); + assert_eq!(docs.snapshot(info.id).unwrap().text, "class Original {}\n"); + assert!(!docs.snapshot(info.id).unwrap().dirty); + assert!( + events + .lock() + .iter() + .filter_map(|e| match e { + IdeEvent::DocPatched { workspace_edit, .. } => Some(workspace_edit), + _ => None, + }) + .all(|grouped| *grouped) + ); + } + + #[test] + fn failed_second_write_rolls_back_disk_and_dirty_buffers() { + let tmp = tempfile::tempdir().unwrap(); + let a = tmp.path().join("a.txt"); + let b = tmp.path().join("b.txt"); + fs::write(&a, "old\r\n").unwrap(); + fs::write(&b, "two\n").unwrap(); + let docs = Documents::new(Arc::new(NullSink), Box::new(|_, _| ide_core::LanguageId::plain_text())); + let info = docs.open(&a).unwrap(); + docs.edit(info.id, 0, ChangeSet { changes: vec![TextChange { from: 0, to: 3, insert: "own".into() }] }) + .unwrap(); + let edit = WorkspaceEdit { + files: [&a, &b] + .into_iter() + .map(|path| FileEdit::Edit { + path: path.clone().into(), + version: None, + edits: vec![TextEdit { range: Range::new(Pos::new(0, 0), Pos::new(0, 3)), new_text: "new".into() }], + }) + .collect(), + ..Default::default() + }; + let error = docs + .apply_workspace_edit_inner(&edit, true, &mut |index| { + if index == 1 { Err(IdeError::new(ErrorKind::Io, "injected write failure")) } else { Ok(()) } + }) + .unwrap_err(); + assert!(error.message.contains("injected")); + assert_eq!(fs::read(&a).unwrap(), b"old\r\n"); + assert_eq!(fs::read(&b).unwrap(), b"two\n"); + let after = docs.snapshot(info.id).unwrap(); + assert_eq!(after.text, "own\n"); + assert!(after.dirty); + assert!(after.version > info.version); + assert!(docs.workspace_edits.lock().records.is_empty()); + } + + #[test] + fn failed_commit_restores_encoded_files_and_resource_operations() { + let tmp = tempfile::tempdir().unwrap(); + let first = tmp.path().join("first.txt"); + let created = tmp.path().join("new/deep/file.txt"); + let source = tmp.path().join("source.bin"); + let renamed = tmp.path().join("renamed.bin"); + let deleted = tmp.path().join("deleted.bin"); + ide_vfs::save_text(&first, "old\r\n", "utf-16le", true).unwrap(); + let bytes = fs::read(&first).unwrap(); + fs::write(&source, [0, 255, 128]).unwrap(); + fs::write(&deleted, [255, 1, 128]).unwrap(); + let docs = Documents::new(Arc::new(NullSink), Box::new(|_, _| ide_core::LanguageId::plain_text())); + let info = docs.open(&first).unwrap(); + let edit = WorkspaceEdit { + files: vec![ + FileEdit::Edit { + path: first.clone().into(), + version: Some(info.version as i32), + edits: vec![TextEdit { range: Range::new(Pos::new(0, 0), Pos::new(0, 3)), new_text: "new".into() }], + }, + FileEdit::Create { path: created.clone().into(), overwrite: false }, + FileEdit::Rename { from: source.clone().into(), to: renamed.clone().into() }, + FileEdit::Delete { path: deleted.clone().into(), recursive: false }, + FileEdit::Create { path: tmp.path().join("last").into(), overwrite: false }, + ], + ..Default::default() + }; + let error = docs + .apply_workspace_edit_inner(&edit, true, &mut |index| { + if index == 4 { Err(IdeError::new(ErrorKind::Io, "injected commit failure")) } else { Ok(()) } + }) + .unwrap_err(); + assert_eq!(error.message, "injected commit failure"); + assert_eq!(fs::read(&first).unwrap(), bytes); + assert_eq!(fs::read(&source).unwrap(), [0, 255, 128]); + assert_eq!(fs::read(&deleted).unwrap(), [255, 1, 128]); + assert!(!renamed.exists()); + assert!(!tmp.path().join("new").exists()); + let after = docs.snapshot(info.id).unwrap(); + assert_eq!(after.text, "old\n"); + assert_eq!(after.encoding, info.encoding); + assert_eq!(after.line_ending, info.line_ending); + assert!(!after.dirty); + } + + #[test] + fn failed_rollback_keeps_recovery_files_and_marks_the_restored_buffer_dirty() { + let tmp = tempfile::tempdir().unwrap(); + let parent = tmp.path().join("subdir"); + fs::create_dir(&parent).unwrap(); + let first = parent.join("first.txt"); + let second = tmp.path().join("second.txt"); + fs::write(&first, "old\n").unwrap(); + fs::write(&second, "two\n").unwrap(); + let docs = Documents::new(Arc::new(NullSink), Box::new(|_, _| ide_core::LanguageId::plain_text())); + let info = docs.open(&first).unwrap(); + let edit = WorkspaceEdit { + files: [&first, &second] + .into_iter() + .map(|path| FileEdit::Edit { + path: path.clone().into(), + version: None, + edits: vec![TextEdit { range: Range::new(Pos::new(0, 0), Pos::new(0, 3)), new_text: "new".into() }], + }) + .collect(), + ..Default::default() + }; + let error = docs + .apply_workspace_edit_inner(&edit, true, &mut |index| { + if index != 1 { + return Ok(()); + } + fs::remove_file(&first)?; + fs::remove_dir(&parent)?; + fs::write(&parent, "blocks restoration")?; + Err(IdeError::new(ErrorKind::Io, "injected commit failure")) + }) + .unwrap_err(); + assert!(error.message.contains("rollback failed")); + let recovery = PathBuf::from(error.message.split("Recovery files: ").last().unwrap()); + let manifest: Vec<(ide_core::IpcPath, Option)> = + serde_json::from_slice(&fs::read(recovery.join("paths.json")).unwrap()).unwrap(); + let backup = manifest + .iter() + .find(|(path, _)| path.file_name().is_some_and(|name| name == "first.txt")) + .unwrap() + .1 + .as_ref() + .unwrap(); + assert_eq!(fs::read(backup).unwrap(), b"old\n"); + let after = docs.snapshot(info.id).unwrap(); + assert_eq!(after.text, "old\n"); + assert!(after.dirty, "a buffer whose disk restoration failed must not look saved"); + fs::remove_dir_all(recovery).unwrap(); + } +} diff --git a/crates/ide-workspace/src/file_moves.rs b/crates/ide-workspace/src/file_moves.rs index dbac62857..ad6444b89 100644 --- a/crates/ide-workspace/src/file_moves.rs +++ b/crates/ide-workspace/src/file_moves.rs @@ -89,7 +89,7 @@ fn write_text(documents: &Documents, path: &Path, text: &str) -> IdeResult<()> { fn edited_paths(edit: &WorkspaceEdit) -> Vec { let mut out: Vec = Vec::new(); for f in &edit.files { - if let FileEdit::Edit { path, edits } = f + if let FileEdit::Edit { path, edits, .. } = f && !edits.is_empty() && !out.iter().any(|p| p == path) { @@ -245,17 +245,7 @@ mod tests { } fn apply(documents: &Documents) -> impl Fn(&WorkspaceEdit) -> IdeResult<()> + '_ { - move |edit| { - for f in &edit.files { - if let FileEdit::Edit { path, edits } = f { - let doc = documents.open(path)?.id; - documents.apply_edits(doc, edits)?; - documents.save(doc)?; - documents.close(doc); - } - } - Ok(()) - } + move |edit| documents.apply_file_move_edits(edit) } #[test] @@ -272,8 +262,10 @@ mod tests { let edit = WorkspaceEdit { files: vec![FileEdit::Edit { path: root.join("main.ts").into(), + version: None, edits: vec![TextEdit { range: Range::new(Pos::new(0, 21), Pos::new(0, 22)), new_text: "b".into() }], }], + ..Default::default() }; let id = moves.perform(&docs, Some(&history), &mv, &edit, "Move", &apply(&docs)).unwrap(); assert!(root.join("b/util.ts").exists() && !root.join("a").exists()); @@ -303,13 +295,16 @@ mod tests { files: vec![ FileEdit::Edit { path: root.join("Foo.java").into(), + version: None, edits: vec![TextEdit { range: Range::new(Pos::new(0, 6), Pos::new(0, 9)), new_text: "Bar".into() }], }, FileEdit::Edit { path: root.join("Use.java").into(), + version: None, edits: vec![TextEdit { range: Range::new(Pos::new(0, 0), Pos::new(0, 3)), new_text: "Bar".into() }], }, ], + ..Default::default() }; let mv = vec![FileMove { from: root.join("Foo.java").into(), to: root.join("Bar.java").into() }]; let id = moves.perform(&docs, None, &mv, &edit, "Rename", &apply(&docs)).unwrap(); diff --git a/crates/ide-workspace/src/text_refs.rs b/crates/ide-workspace/src/text_refs.rs index ab3447801..1ea4daa15 100644 --- a/crates/ide-workspace/src/text_refs.rs +++ b/crates/ide-workspace/src/text_refs.rs @@ -201,7 +201,11 @@ pub fn find_text_occurrences( let mut files = found.into_inner(); files.sort_by(|a, b| a.0.cmp(&b.0)); WorkspaceEdit { - files: files.into_iter().map(|(path, edits)| FileEdit::Edit { path: path.into(), edits }).collect(), + files: files + .into_iter() + .map(|(path, edits)| FileEdit::Edit { path: path.into(), edits, version: None }) + .collect(), + ..Default::default() } } diff --git a/crates/ide-workspace/tests/workspace_edits.rs b/crates/ide-workspace/tests/workspace_edits.rs new file mode 100644 index 000000000..78bd905bd --- /dev/null +++ b/crates/ide-workspace/tests/workspace_edits.rs @@ -0,0 +1,463 @@ +use ide_core::{ChangeSet, FileEdit, LanguageId, NullSink, Pos, Range, TextChange, TextEdit, WorkspaceEdit}; +use ide_core::{IdeEvent, error::ErrorKind}; +use ide_workspace::Documents; +use parking_lot::Mutex; +use std::path::Path; +use std::sync::Arc; + +fn documents() -> Documents { + Documents::new(Arc::new(NullSink), Box::new(|_, _| LanguageId::plain_text())) +} + +fn edit(path: &Path, text: &str) -> FileEdit { + FileEdit::Edit { + path: path.to_owned().into(), + version: None, + edits: vec![TextEdit { range: Range::new(Pos::new(0, 0), Pos::new(0, 3)), new_text: text.into() }], + } +} + +#[test] +fn later_failure_preserves_disk_and_unsaved_buffer() { + let tmp = tempfile::tempdir().unwrap(); + let first = tmp.path().join("first.txt"); + let missing = tmp.path().join("missing.txt"); + std::fs::write(&first, "old\n").unwrap(); + let docs = documents(); + let info = docs.open(&first).unwrap(); + docs.edit(info.id, info.version, ChangeSet { changes: vec![TextChange { from: 0, to: 3, insert: "own".into() }] }) + .unwrap(); + let result = docs.apply_workspace_edit(&WorkspaceEdit { + files: vec![edit(&first, "new"), edit(&missing, "bad")], + ..Default::default() + }); + assert!(result.is_err()); + assert_eq!(std::fs::read_to_string(&first).unwrap(), "old\n"); + let after = docs.snapshot(info.id).unwrap(); + assert_eq!(after.text, "own\n"); + assert!(after.dirty); +} + +#[test] +fn create_conflict_does_not_modify_earlier_files() { + let tmp = tempfile::tempdir().unwrap(); + let first = tmp.path().join("first.txt"); + let existing = tmp.path().join("exists.txt"); + std::fs::write(&first, "old\n").unwrap(); + std::fs::write(&existing, "keep").unwrap(); + let docs = documents(); + let result = docs.apply_workspace_edit(&WorkspaceEdit { + files: vec![edit(&first, "new"), FileEdit::Create { path: existing.clone().into(), overwrite: false }], + ..Default::default() + }); + assert!(result.is_err()); + assert_eq!(std::fs::read_to_string(&first).unwrap(), "old\n"); + assert_eq!(std::fs::read_to_string(existing).unwrap(), "keep"); +} + +fn recorded_documents() -> (Documents, Arc>>) { + let events = Arc::new(Mutex::new(Vec::new())); + let sink = events.clone(); + (Documents::new(Arc::new(move |event| sink.lock().push(event)), Box::new(|_, _| LanguageId::plain_text())), events) +} + +fn undo_id(events: &Mutex>) -> u32 { + events + .lock() + .iter() + .rev() + .find_map(|event| match event { + IdeEvent::WorkspaceEditApplied { id, .. } => Some(*id), + _ => None, + }) + .expect("successful transaction has one undo record") +} + +#[test] +fn stale_document_version_rejects_the_whole_edit() { + let tmp = tempfile::tempdir().unwrap(); + let a = tmp.path().join("a.txt"); + let b = tmp.path().join("b.txt"); + std::fs::write(&a, "old\n").unwrap(); + std::fs::write(&b, "old\n").unwrap(); + let docs = documents(); + let info = docs.open(&b).unwrap(); + docs.edit(info.id, 0, ChangeSet { changes: vec![TextChange { from: 0, to: 0, insert: "own ".into() }] }).unwrap(); + let mut second = edit(&b, "new"); + if let FileEdit::Edit { version, .. } = &mut second { + *version = Some(0); + } + let result = + docs.apply_workspace_edit(&WorkspaceEdit { files: vec![edit(&a, "new"), second], ..Default::default() }); + assert_eq!(result.unwrap_err().kind, ErrorKind::OutOfSync); + assert_eq!(std::fs::read_to_string(&a).unwrap(), "old\n"); + assert_eq!(docs.snapshot(info.id).unwrap().text, "own old\n"); + assert!(docs.is_dirty(info.id).unwrap()); +} + +#[test] +fn matching_document_version_is_applied() { + let tmp = tempfile::tempdir().unwrap(); + let a = tmp.path().join("a.txt"); + std::fs::write(&a, "old\n").unwrap(); + let docs = documents(); + let info = docs.open(&a).unwrap(); + let mut operation = edit(&a, "new"); + if let FileEdit::Edit { version, .. } = &mut operation { + *version = Some(info.version as i32); + } + docs.apply_workspace_edit(&WorkspaceEdit { files: vec![operation], ..Default::default() }).unwrap(); + assert_eq!(docs.snapshot(info.id).unwrap().text, "new\n"); + assert_eq!(std::fs::read_to_string(a).unwrap(), "new\n"); +} + +#[test] +fn unversioned_preview_rejects_changed_disk_and_dirty_buffers() { + let tmp = tempfile::tempdir().unwrap(); + for open in [false, true] { + let path = tmp.path().join(if open { "open.txt" } else { "closed.txt" }); + std::fs::write(&path, "old\n").unwrap(); + let docs = documents(); + let info = open.then(|| docs.open(&path).unwrap()); + let mut change = WorkspaceEdit { files: vec![edit(&path, "new")], ..Default::default() }; + docs.prepare_workspace_edit(&mut change).unwrap(); + if let Some(info) = &info { + docs.edit( + info.id, + info.version, + ChangeSet { changes: vec![TextChange { from: 0, to: 3, insert: "own".into() }] }, + ) + .unwrap(); + } else { + std::fs::write(&path, "own\n").unwrap(); + } + let original = change.preconditions.clone(); + docs.prepare_workspace_edit(&mut change).unwrap(); + assert_eq!(change.preconditions, original, "resolution must not refresh an old preview's guard"); + assert_eq!(docs.apply_workspace_edit(&change).unwrap_err().kind, ErrorKind::OutOfSync); + if let Some(info) = info { + assert_eq!(docs.snapshot(info.id).unwrap().text, "own\n"); + assert_eq!(std::fs::read_to_string(&path).unwrap(), "old\n"); + } else { + assert_eq!(std::fs::read_to_string(&path).unwrap(), "own\n"); + } + } +} + +#[test] +fn grouped_undo_restores_open_closed_and_unsaved_files() { + let tmp = tempfile::tempdir().unwrap(); + let a = tmp.path().join("a.txt"); + let b = tmp.path().join("b.txt"); + let c = tmp.path().join("c.txt"); + for path in [&a, &b, &c] { + std::fs::write(path, "old\r\n").unwrap(); + } + let (docs, events) = recorded_documents(); + let dirty = docs.open(&a).unwrap(); + let clean = docs.open(&b).unwrap(); + docs.edit(dirty.id, 0, ChangeSet { changes: vec![TextChange { from: 0, to: 3, insert: "own".into() }] }).unwrap(); + let change = + WorkspaceEdit { files: [&a, &b, &c].into_iter().map(|p| edit(p, "new")).collect(), ..Default::default() }; + docs.apply_workspace_edit(&change).unwrap(); + assert_eq!(docs.snapshot(dirty.id).unwrap().text, "new\n"); + assert!(docs.is_dirty(dirty.id).unwrap()); + assert_eq!(std::fs::read(&a).unwrap(), b"old\r\n", "unrelated unsaved work is not saved"); + assert_eq!(std::fs::read(&c).unwrap(), b"new\r\n"); + let opened_later = docs.open(&c).unwrap(); + let id = undo_id(&events); + docs.undo_workspace_edit(id).unwrap(); + assert_eq!(docs.snapshot(dirty.id).unwrap().text, "own\n"); + assert!(docs.is_dirty(dirty.id).unwrap()); + for info in [clean, opened_later] { + assert_eq!(docs.snapshot(info.id).unwrap().text, "old\n"); + assert!(!docs.is_dirty(info.id).unwrap()); + } + for path in [&a, &b, &c] { + assert_eq!(std::fs::read(path).unwrap(), b"old\r\n"); + } + assert!(docs.undo_workspace_edit(id).is_err()); +} + +#[test] +fn undo_refuses_later_changes_and_retains_the_record() { + let tmp = tempfile::tempdir().unwrap(); + let a = tmp.path().join("a.txt"); + std::fs::write(&a, "old\n").unwrap(); + let (docs, events) = recorded_documents(); + docs.apply_workspace_edit(&WorkspaceEdit { files: vec![edit(&a, "new")], ..Default::default() }).unwrap(); + let id = undo_id(&events); + std::fs::write(&a, "own\n").unwrap(); + assert_eq!(docs.undo_workspace_edit(id).unwrap_err().kind, ErrorKind::OutOfSync); + assert_eq!(std::fs::read_to_string(&a).unwrap(), "own\n"); + std::fs::write(&a, "new\n").unwrap(); + docs.undo_workspace_edit(id).unwrap(); + assert_eq!(std::fs::read_to_string(a).unwrap(), "old\n"); +} + +#[test] +fn ordered_resource_operations_and_binary_deletion_are_undoable() { + let tmp = tempfile::tempdir().unwrap(); + let created = tmp.path().join("new/deep/file.txt"); + let renamed = tmp.path().join("renamed.txt"); + let deleted = tmp.path().join("binary.dat"); + let overwritten = tmp.path().join("overwrite.txt"); + std::fs::write(&deleted, [0, 255, 1, 128]).unwrap(); + std::fs::write(&overwritten, [255, 254, 65, 0]).unwrap(); + let (docs, events) = recorded_documents(); + let change = WorkspaceEdit { + files: vec![ + FileEdit::Create { path: created.clone().into(), overwrite: false }, + edit(&created, "hello"), + FileEdit::Rename { from: created.clone().into(), to: renamed.clone().into() }, + FileEdit::Delete { path: deleted.clone().into(), recursive: false }, + FileEdit::Create { path: overwritten.clone().into(), overwrite: true }, + ], + ..Default::default() + }; + docs.apply_workspace_edit(&change).unwrap(); + assert_eq!(std::fs::read_to_string(&renamed).unwrap(), "hello"); + assert!(!deleted.exists()); + assert!(std::fs::read(&overwritten).unwrap().is_empty()); + docs.undo_workspace_edit(undo_id(&events)).unwrap(); + assert!(!tmp.path().join("new").exists()); + assert!(!renamed.exists()); + assert_eq!(std::fs::read(deleted).unwrap(), [0, 255, 1, 128]); + assert_eq!(std::fs::read(overwritten).unwrap(), [255, 254, 65, 0]); +} + +#[test] +fn nonrecursive_directory_delete_is_rejected_before_any_write() { + let tmp = tempfile::tempdir().unwrap(); + let a = tmp.path().join("a.txt"); + let dir = tmp.path().join("directory"); + std::fs::write(&a, "old\n").unwrap(); + std::fs::create_dir(&dir).unwrap(); + std::fs::write(dir.join("keep.txt"), "keep").unwrap(); + let change = WorkspaceEdit { + files: vec![edit(&a, "new"), FileEdit::Delete { path: dir.clone().into(), recursive: false }], + ..Default::default() + }; + assert!(documents().apply_workspace_edit(&change).is_err()); + assert_eq!(std::fs::read_to_string(a).unwrap(), "old\n"); + assert!(dir.join("keep.txt").exists()); +} + +#[test] +fn external_write_not_yet_seen_by_watcher_is_not_overwritten() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("a.txt"); + std::fs::write(&path, "old\n").unwrap(); + let docs = documents(); + let info = docs.open(&path).unwrap(); + std::fs::write(&path, "own\n").unwrap(); + let mut change = WorkspaceEdit { files: vec![edit(&path, "new")], ..Default::default() }; + docs.prepare_workspace_edit(&mut change).unwrap(); + assert_eq!(docs.apply_workspace_edit(&change).unwrap_err().kind, ErrorKind::OutOfSync); + assert_eq!(std::fs::read_to_string(path).unwrap(), "own\n"); + assert_eq!(docs.snapshot(info.id).unwrap().text, "old\n"); +} + +#[cfg(unix)] +#[test] +fn undo_restores_deleted_directory_with_symlinks_and_permissions() { + use std::os::unix::fs::{PermissionsExt, symlink}; + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path().join("tree"); + std::fs::create_dir(&dir).unwrap(); + std::fs::write(dir.join("script"), "old\n").unwrap(); + std::fs::set_permissions(dir.join("script"), std::fs::Permissions::from_mode(0o751)).unwrap(); + symlink("missing-target", dir.join("link")).unwrap(); + let (docs, events) = recorded_documents(); + docs.apply_workspace_edit(&WorkspaceEdit { + files: vec![FileEdit::Delete { path: dir.clone().into(), recursive: true }], + ..Default::default() + }) + .unwrap(); + assert!(!dir.exists()); + docs.undo_workspace_edit(undo_id(&events)).unwrap(); + assert_eq!(std::fs::read(dir.join("script")).unwrap(), b"old\n"); + assert_eq!(std::fs::metadata(dir.join("script")).unwrap().permissions().mode() & 0o777, 0o751); + assert_eq!(std::fs::read_link(dir.join("link")).unwrap(), Path::new("missing-target")); +} + +#[cfg(unix)] +#[test] +fn preflight_never_writes_through_copied_or_moved_symlinks() { + use std::os::unix::fs::symlink; + let tmp = tempfile::tempdir().unwrap(); + let target = tmp.path().join("target.txt"); + let link = tmp.path().join("link.txt"); + let moved = tmp.path().join("moved.txt"); + std::fs::write(&target, "old\n").unwrap(); + symlink(&target, &link).unwrap(); + let docs = documents(); + for files in [ + vec![FileEdit::Create { path: link.clone().into(), overwrite: true }], + vec![FileEdit::Rename { from: link.clone().into(), to: moved.clone().into() }, edit(&moved, "new")], + ] { + assert!(docs.apply_workspace_edit(&WorkspaceEdit { files, ..Default::default() }).is_err()); + assert_eq!(std::fs::read_to_string(&target).unwrap(), "old\n"); + assert!(std::fs::symlink_metadata(&link).unwrap().file_type().is_symlink()); + assert!(!moved.exists()); + } +} + +#[test] +fn rename_after_restoring_inplace_typing_saves_but_real_unsaved_work_stays_dirty() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("main.ts"); + std::fs::write(&path, "old").unwrap(); + let (docs, events) = recorded_documents(); + let info = docs.open(&path).unwrap(); + for (version, text) in [(0, "own"), (1, "old")] { + docs.edit(info.id, version, ChangeSet { changes: vec![TextChange { from: 0, to: 3, insert: text.into() }] }) + .unwrap(); + } + assert!(docs.snapshot(info.id).unwrap().dirty); + docs.apply_workspace_edit(&WorkspaceEdit { files: vec![edit(&path, "new")], ..Default::default() }).unwrap(); + assert_eq!(std::fs::read_to_string(&path).unwrap(), "new"); + assert!(!docs.snapshot(info.id).unwrap().dirty); + let id = undo_id(&events); + docs.undo_workspace_edit(id).unwrap(); + assert_eq!(std::fs::read_to_string(&path).unwrap(), "old"); + assert!(!docs.snapshot(info.id).unwrap().dirty, "restoring saved bytes must clear a stale dirty flag"); + docs.redo_workspace_edit(id).unwrap(); + assert_eq!(std::fs::read_to_string(&path).unwrap(), "new"); + assert!(!docs.snapshot(info.id).unwrap().dirty); + docs.edit( + info.id, + docs.version(info.id).unwrap(), + ChangeSet { changes: vec![TextChange { from: 3, to: 3, insert: " own".into() }] }, + ) + .unwrap(); + assert_eq!(docs.undo_workspace_edit(id).unwrap_err().kind, ErrorKind::OutOfSync); +} + +#[test] +fn duplicate_edits_keep_versions_and_apply_one_replacement() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("main.cpp"); + std::fs::write(&path, "old").unwrap(); + let docs = documents(); + let info = docs.open(&path).unwrap(); + let mut versioned = edit(&path, "new_long"); + if let FileEdit::Edit { version, .. } = &mut versioned { + *version = Some(info.version as i32); + } + let mut workspace = WorkspaceEdit { files: vec![edit(&path, "new_long"), versioned], ..Default::default() }; + workspace.deduplicate(); + assert_eq!(workspace.files.len(), 1); + assert!(matches!(workspace.files[0], FileEdit::Edit { version: Some(0), .. })); + docs.apply_workspace_edit(&workspace).unwrap(); + assert_eq!(std::fs::read_to_string(&path).unwrap(), "new_long"); + + let mut conflicting = workspace.files[0].clone(); + if let FileEdit::Edit { version, .. } = &mut conflicting { + *version = Some(99); + } + workspace.files.push(conflicting); + workspace.deduplicate(); + assert_eq!(workspace.files.len(), 2, "deduplication must not discard incompatible versions"); + assert_eq!(docs.apply_workspace_edit(&workspace).unwrap_err().kind, ErrorKind::OutOfSync); + assert_eq!(std::fs::read_to_string(path).unwrap(), "new_long"); +} + +#[test] +fn undo_accepts_typing_that_was_reverted_to_the_saved_refactoring() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("main.ts"); + std::fs::write(&path, "old").unwrap(); + let (docs, events) = recorded_documents(); + let info = docs.open(&path).unwrap(); + docs.apply_workspace_edit(&WorkspaceEdit { files: vec![edit(&path, "new")], ..Default::default() }).unwrap(); + let id = undo_id(&events); + for text in ["own", "new"] { + docs.edit( + info.id, + docs.version(info.id).unwrap(), + ChangeSet { changes: vec![TextChange { from: 0, to: 3, insert: text.into() }] }, + ) + .unwrap(); + } + assert!(docs.snapshot(info.id).unwrap().dirty); + docs.undo_workspace_edit(id).unwrap(); + assert_eq!(std::fs::read_to_string(path).unwrap(), "old"); + assert_eq!(docs.snapshot(info.id).unwrap().text, "old"); + assert!(!docs.snapshot(info.id).unwrap().dirty); +} + +#[cfg(unix)] +#[test] +fn replacing_a_symlink_then_editing_the_new_file_never_writes_its_old_target() { + let tmp = tempfile::tempdir().unwrap(); + let target = tmp.path().join("precious.txt"); + let link = tmp.path().join("new.txt"); + std::fs::write(&target, "old target\n").unwrap(); + std::os::unix::fs::symlink(&target, &link).unwrap(); + let (docs, events) = recorded_documents(); + let target_doc = docs.open(&target).unwrap(); + let mut edit = WorkspaceEdit { + files: vec![ + FileEdit::Delete { path: link.clone().into(), recursive: false }, + FileEdit::Create { path: link.clone().into(), overwrite: false }, + FileEdit::Edit { + path: link.clone().into(), + version: None, + edits: vec![TextEdit { range: Range::point(Pos::new(0, 0)), new_text: "new file\n".into() }], + }, + ], + ..Default::default() + }; + docs.prepare_workspace_edit(&mut edit).unwrap(); + docs.apply_workspace_edit(&edit).unwrap(); + assert_eq!(std::fs::read_to_string(&target).unwrap(), "old target\n"); + assert_eq!(docs.snapshot(target_doc.id).unwrap().text, "old target\n"); + assert_eq!(std::fs::read_to_string(&link).unwrap(), "new file\n"); + assert!(!std::fs::symlink_metadata(&link).unwrap().file_type().is_symlink()); + let id = undo_id(&events); + docs.undo_workspace_edit(id).unwrap(); + assert_eq!(std::fs::read_link(&link).unwrap(), target); + docs.redo_workspace_edit(id).unwrap(); + assert_eq!(std::fs::read_to_string(&target).unwrap(), "old target\n"); + assert_eq!(std::fs::read_to_string(&link).unwrap(), "new file\n"); +} + +#[cfg(unix)] +#[test] +fn replacing_a_directory_symlink_does_not_resolve_later_child_paths_through_its_old_target() { + for alias in 0..3 { + let tmp = tempfile::tempdir().unwrap(); + let target = tmp.path().join("precious"); + let link = tmp.path().join("new"); + std::fs::create_dir(&target).unwrap(); + std::fs::write(target.join("file.txt"), "old target\n").unwrap(); + std::os::unix::fs::symlink(&target, &link).unwrap(); + let alternate = tmp.path().join("alternate"); + std::os::unix::fs::symlink(tmp.path(), &alternate).unwrap(); + let shortcut = tmp.path().join("shortcut"); + std::os::unix::fs::symlink(&link, &shortcut).unwrap(); + let edited = match alias { + 0 => link.join("file.txt"), + 1 => alternate.join("new/file.txt"), + _ => shortcut.join("file.txt"), + }; + let docs = documents(); + let mut edit = WorkspaceEdit { + files: vec![ + FileEdit::Delete { path: link.clone().into(), recursive: false }, + FileEdit::Create { path: link.join("file.txt").into(), overwrite: false }, + FileEdit::Edit { + path: edited.into(), + version: None, + edits: vec![TextEdit { range: Range::point(Pos::new(0, 0)), new_text: "new file\n".into() }], + }, + ], + ..Default::default() + }; + docs.prepare_workspace_edit(&mut edit).unwrap(); + docs.apply_workspace_edit(&edit).unwrap(); + assert_eq!(std::fs::read_to_string(target.join("file.txt")).unwrap(), "old target\n"); + assert_eq!(std::fs::read_to_string(link.join("file.txt")).unwrap(), "new file\n"); + } +} diff --git a/src-tauri/src/commands/doc.rs b/src-tauri/src/commands/doc.rs index 91e0781eb..5d366faad 100644 --- a/src-tauri/src/commands/doc.rs +++ b/src-tauri/src/commands/doc.rs @@ -20,22 +20,26 @@ pub async fn doc_open(window: Window, state: State<'_, AppState>, path: PathBuf) #[tauri::command] #[specta::specta] -pub fn doc_close(window: Window, state: State<'_, AppState>, doc: DocId) -> IdeResult<()> { - ide_host::ops::doc::close(&state.host, window.label(), doc) +pub async fn doc_close(window: Window, state: State<'_, AppState>, doc: DocId) -> IdeResult<()> { + let host = state.host.clone(); + let window = window.label().to_owned(); + blocking(move || ide_host::ops::doc::close(&host, &window, doc)).await } /// Applies a CodeMirror change set; returns the new version. Rejects with /// `outOfSync` when `base_version` is stale; the UI then calls `doc_snapshot`. #[tauri::command] #[specta::specta] -pub fn doc_edit( +pub async fn doc_edit( window: Window, state: State<'_, AppState>, doc: DocId, base_version: u32, changes: ChangeSet, ) -> IdeResult { - ide_host::ops::doc::edit(&state.host, window.label(), doc, base_version, changes) + let host = state.host.clone(); + let window = window.label().to_owned(); + blocking(move || ide_host::ops::doc::edit(&host, &window, doc, base_version, changes)).await } #[tauri::command] @@ -92,13 +96,15 @@ pub async fn doc_set_encoding( #[tauri::command] #[specta::specta] -pub fn doc_set_line_ending( +pub async fn doc_set_line_ending( window: Window, state: State<'_, AppState>, doc: DocId, line_ending: LineEnding, ) -> IdeResult<()> { - ide_host::ops::doc::doc_set_line_ending(&state.host, window.label(), doc, line_ending) + let host = state.host.clone(); + let window = window.label().to_owned(); + blocking(move || ide_host::ops::doc::doc_set_line_ending(&host, &window, doc, line_ending)).await } /// Structure view: LSP document symbols when available (with tree-sitter diff --git a/src-tauri/src/commands/lsp.rs b/src-tauri/src/commands/lsp.rs index 765d7e755..0092cdcf6 100644 --- a/src-tauri/src/commands/lsp.rs +++ b/src-tauri/src/commands/lsp.rs @@ -337,8 +337,30 @@ pub async fn lsp_restart(window: Window, state: State<'_, AppState>, server: Ser /// Applies a (previewed) workspace edit, e.g. after rename confirmation. #[tauri::command] #[specta::specta] -pub fn apply_workspace_edit(window: Window, state: State<'_, AppState>, edit: WorkspaceEdit) -> IdeResult<()> { - ide_host::ops::lsp::apply_workspace_edit(&state.host, window.label(), edit) +pub async fn apply_workspace_edit(window: Window, state: State<'_, AppState>, edit: WorkspaceEdit) -> IdeResult<()> { + ide_host::ops::lsp::apply_workspace_edit(&state.host, window.label(), edit).await +} + +#[tauri::command] +#[specta::specta] +pub async fn prepare_workspace_edit( + window: Window, + state: State<'_, AppState>, + edit: WorkspaceEdit, +) -> IdeResult { + ide_host::ops::lsp::prepare_workspace_edit(&state.host, window.label(), edit).await +} + +#[tauri::command] +#[specta::specta] +pub async fn undo_workspace_edit(window: Window, state: State<'_, AppState>, id: u32) -> IdeResult<()> { + ide_host::ops::lsp::undo_workspace_edit(&state.host, window.label(), id).await +} + +#[tauri::command] +#[specta::specta] +pub async fn redo_workspace_edit(window: Window, state: State<'_, AppState>, id: u32) -> IdeResult<()> { + ide_host::ops::lsp::redo_workspace_edit(&state.host, window.label(), id).await } /// Runs a server command (`workspace/executeCommand`), e.g. a completion item's diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 1ef4d111b..4a8f3c06c 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -228,6 +228,9 @@ pub fn builder() -> tauri_specta::Builder { lsp::lsp_resend_diagnostics, lsp::lsp_restart, lsp::apply_workspace_edit, + lsp::prepare_workspace_edit, + lsp::undo_workspace_edit, + lsp::redo_workspace_edit, lsp::lsp_execute_command, lsp::lsp_resolve_command, lsp::lsp_execute_code_action, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index e75910a3d..478b25f43 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -210,6 +210,7 @@ pub fn run() { let _ = &early_opens; if let tauri::RunEvent::Exit = event { if let Some(state) = app.try_state::() { + state.host.clear_workspace_edit_history_on_exit(); state.host.shutdown_notebooks_on_exit(); } // Cleanup must finish before update/restart replaces this process. diff --git a/src/bindings.ts b/src/bindings.ts index e76c5885f..4f1913248 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -426,6 +426,9 @@ export const commands = { lspRestart: (server: ServerId) => typedError(__TAURI_INVOKE("lsp_restart", { server })), /** Applies a (previewed) workspace edit, e.g. after rename confirmation. */ applyWorkspaceEdit: (edit: WorkspaceEdit) => typedError(__TAURI_INVOKE("apply_workspace_edit", { edit })), + prepareWorkspaceEdit: (edit: WorkspaceEdit) => typedError(__TAURI_INVOKE("prepare_workspace_edit", { edit })), + undoWorkspaceEdit: (id: number) => typedError(__TAURI_INVOKE("undo_workspace_edit", { id })), + redoWorkspaceEdit: (id: number) => typedError(__TAURI_INVOKE("redo_workspace_edit", { id })), /** * Runs a server command (`workspace/executeCommand`), e.g. a completion item's * follow-up command. Edits the server requests arrive via `workspace/applyEdit`. @@ -3088,7 +3091,9 @@ export type FileCoverage = { branchesCovered: number, }; -export type FileEdit = { kind: "edit"; path: IpcPath; edits: TextEdit[] } | { kind: "create"; path: IpcPath; overwrite: boolean } | { kind: "rename"; from: IpcPath; to: IpcPath } | { kind: "delete"; path: IpcPath; recursive: boolean }; +export type FileEdit = { kind: "edit"; path: IpcPath; edits: TextEdit[]; +/** LSP TextDocumentEdit version; null/unversioned edits use content preconditions. */ +version?: number | null } | { kind: "create"; path: IpcPath; overwrite: boolean } | { kind: "rename"; from: IpcPath; to: IpcPath } | { kind: "delete"; path: IpcPath; recursive: boolean }; export type FileEntry = { name: string, @@ -3603,9 +3608,13 @@ export type IdeEvent = * The backend changed a document (reload from disk, formatting, refactoring). * The UI applies `changes` on top of `base_version`, yielding `version`. */ -{ type: "docPatched"; doc: DocId; baseVersion: number; version: number; changes: TextChange[] } | +{ type: "docPatched"; doc: DocId; baseVersion: number; version: number; changes: TextChange[]; +/** Part of a grouped transaction; the editor must not record a file-local undo. */ +workspaceEdit?: boolean } | /** A document's dirty flag changed (saved / modified externally). */ { type: "docDirty"; doc: DocId; dirty: boolean } | +/** A complete workspace edit can be undone as one operation. */ +{ type: "workspaceEditApplied"; id: number; paths: IpcPath[] } | /** * An open document's language changed (Settings › File Types, Override File * Type), or its UI mode (a framework's root marker appeared or went). @@ -6480,6 +6489,13 @@ export type Workspace = Workspace_Serialize | Workspace_Deserialize; /** Edits to many files, applied atomically from the user's point of view. */ export type WorkspaceEdit = { files: FileEdit[], + /** Content fingerprints captured before presenting an edit for confirmation. */ + preconditions?: WorkspaceEditPrecondition[], +}; + +export type WorkspaceEditPrecondition = { + path: IpcPath, + hash: string, }; export type WorkspaceSymbol = { diff --git a/src/editor/api.ts b/src/editor/api.ts index 9d1e81e01..671f0d9bc 100644 --- a/src/editor/api.ts +++ b/src/editor/api.ts @@ -619,3 +619,37 @@ export function codePreviewExtensions(path: string): Promise { export function _provideCodePreview(provider: ((path: string) => Promise) | null) { codePreviewProvider = provider; } + +let workspaceHistory: ((id: number, redo: boolean) => Promise) | null = null; + +/** Undo/redo a refactoring through the same history used by editor commands. */ +export function workspaceEditHistory(id: number, redo = false): Promise { + return workspaceHistory ? workspaceHistory(id, redo) : Promise.reject(new Error("Editor history is unavailable")); +} + +/** @internal editor/core only. */ +export function _provideWorkspaceHistory(run: (id: number, redo: boolean) => Promise): () => void { + workspaceHistory = run; + return () => { + if (workspaceHistory === run) workspaceHistory = null; + }; +} + +export interface WorkspaceEditHistoryState { + id: number; + undone: boolean; +} +const workspaceHistoryListeners = new Set<(state: WorkspaceEditHistoryState) => void>(); + +/** Successful workspace Undo/Redo, including commands invoked from an editor. */ +export function onWorkspaceEditHistory(listener: (state: WorkspaceEditHistoryState) => void): () => void { + workspaceHistoryListeners.add(listener); + return () => { + workspaceHistoryListeners.delete(listener); + }; +} + +/** @internal editor/core only. */ +export function _emitWorkspaceEditHistory(state: WorkspaceEditHistoryState) { + for (const listener of workspaceHistoryListeners) listener(state); +} diff --git a/src/editor/core/backend.ts b/src/editor/core/backend.ts index 55dfc59af..8c1cfbef5 100644 --- a/src/editor/core/backend.ts +++ b/src/editor/core/backend.ts @@ -28,6 +28,7 @@ import { customRegions } from "./cm/regions"; export type LanguageInfo = LanguageInfo_Serialize; export interface DocPatchedEvent { + workspaceEdit?: boolean; doc: DocId; baseVersion: number; version: number; @@ -335,7 +336,7 @@ function lineIndex(text: string) { export interface MemoryBackend extends DocBackend { /** Test/dev hook: apply a backend-originated change and emit `docPatched`. */ - patch(doc: DocId, changes: TextChange[]): void; + patch(doc: DocId, changes: TextChange[], workspaceEdit?: boolean): void; /** Seed file contents before opening. */ files: Map; docs: Map; @@ -582,13 +583,13 @@ export function createMemoryBackend(files: Record = {}): MemoryB dirtyHandlers.add(h); return () => dirtyHandlers.delete(h); }, - patch(doc, changes) { + patch(doc, changes, workspaceEdit = false) { const d = get(doc); const before = d.text !== d.saved; const baseVersion = d.version; d.text = applyChanges(d.text, changes); d.version++; - const e = { doc, baseVersion, version: d.version, changes }; + const e = { doc, baseVersion, version: d.version, changes, workspaceEdit }; patched.forEach((h) => h(e)); setDirty(d, before); }, diff --git a/src/editor/core/model/attachment.ts b/src/editor/core/model/attachment.ts index daefba9e1..0ac7bd141 100644 --- a/src/editor/core/model/attachment.ts +++ b/src/editor/core/model/attachment.ts @@ -29,9 +29,19 @@ export async function attachDocument(manager: DocumentManager, path: string): Pr const target = new AttachedView(); model.attach(target); let released = false; + const historyTarget = { + path, + state: () => target.view!.state, + dispatch: (tr: Transaction) => target.view?.dispatch(tr), + }; const plugin = ViewPlugin.define((view) => { target.view = view; + let detachHistory = () => {}; + let destroyed = false; + queueMicrotask(() => { + if (!destroyed) detachHistory = model.manager.workspaceHistory.attach(historyTarget); + }); const handle: TextEditorHandle = { view, ctx: model.ctx() }; _addEditor(handle); // The model may have moved on between `text()` and the view's creation. @@ -46,6 +56,8 @@ export async function attachDocument(manager: DocumentManager, path: string): Pr }); return { destroy() { + destroyed = true; + detachHistory(); _removeEditor(handle); if (target.view === view) target.view = null; }, @@ -57,6 +69,7 @@ export async function attachDocument(manager: DocumentManager, path: string): Pr text: () => model.sync.localText.toString(), readOnly: model.getMeta().readOnly || model.virtual, extension: [ + model.manager.workspaceHistory.extension(historyTarget), plugin, documentSynced.of(() => model.sync.whenIdle()), EditorView.updateListener.of((u) => { diff --git a/src/editor/core/model/document.ts b/src/editor/core/model/document.ts index ff94d564a..9c70e8f61 100644 --- a/src/editor/core/model/document.ts +++ b/src/editor/core/model/document.ts @@ -62,6 +62,7 @@ import { import { setMethodLines, softWrapActive } from "../cm/prefs"; import type { EditorSlot } from "./slot"; import { tx } from "@/platform/i18n"; +import { WorkspaceHistory } from "./workspaceHistory"; export type Banner = "binary" | "tooLarge" | "readOnly" | "library" | null; @@ -261,7 +262,7 @@ export class DocModel { snapshot: () => manager.backend.snapshot(this.doc), }, { - applyRemote: (cs) => this.applyRemote(cs), + applyRemote: (cs, grouped) => this.applyRemote(cs, grouped), onDiscarded: () => manager.notify({ level: "warning", @@ -344,14 +345,14 @@ export class DocModel { this.scheduleRefresh(); } - private applyRemote(cs: ChangeSet) { + private applyRemote(cs: ChangeSet, grouped = false) { this.modified.map(cs); this.changeCounter++; const primary = this.primarySlot(); for (const s of this.slots) - s.applyExternal(cs, [remoteChange.of(true), Transaction.addToHistory.of(s === primary)]); + s.applyExternal(cs, [remoteChange.of(true), Transaction.addToHistory.of(!grouped && s === primary)]); // Without a tab, the first attached view keeps backend edits (Replace in Files…) undoable. - let first = !primary; + let first = !grouped && !primary; for (const a of this.attachments) { a.applyExternal(cs, [remoteChange.of(true), Transaction.addToHistory.of(first)]); first = false; @@ -625,6 +626,12 @@ export interface ManagerHost { } export class DocumentManager { + readonly workspaceHistory = new WorkspaceHistory( + async () => { + await Promise.all([...this.byPath.values()].map((m) => m.sync.whenIdle())); + }, + (n) => this.notify(n), + ); readonly byPath = new Map(); readonly byId = new Map(); private opening = new Map>(); @@ -647,6 +654,7 @@ export class DocumentManager { start() { this.disposers.push( + this.workspaceHistory.start(), this.backend.onPatched((e) => this.byId.get(e.doc)?.sync.remote(e)), this.backend.onLanguage?.((e) => this.byId.get(e.doc)?.setLanguage(e.language, e.uiMode)) ?? (() => {}), this.backend.onDirty((e) => { diff --git a/src/editor/core/model/slot.ts b/src/editor/core/model/slot.ts index fdd5684f7..bb818b1cd 100644 --- a/src/editor/core/model/slot.ts +++ b/src/editor/core/model/slot.ts @@ -30,6 +30,7 @@ import { showContextMenu } from "@/ui"; import { baseExtensions, compartments, contributionExtensions } from "../cm/setup"; import { gutterMenuItems, inGutterMenuArea } from "../gutterMenu"; import type { DocModel } from "./document"; +import type { WorkspaceHistoryTarget } from "./workspaceHistory"; import { isNavigation } from "./navHistory"; export interface SlotHost { @@ -52,6 +53,8 @@ export interface Reveal { export class EditorSlot { state: EditorState; + private historyTarget: WorkspaceHistoryTarget; + private detachHistory: () => void; view: EditorView | null = null; handle: TextEditorHandle | null = null; private scroll: StateEffect | null = null; @@ -71,6 +74,14 @@ export class EditorSlot { private host: SlotHost, init?: { from?: EditorSlot; reveal?: Reveal }, ) { + this.historyTarget = { + path: model.path, + state: () => this.currentState(), + dispatch: (tr) => { + if (this.view) this.view.dispatch(tr); + else this.state = tr.state; + }, + }; const source = init?.from; this.state = EditorState.create({ doc: model.sync.localText, @@ -91,6 +102,7 @@ export class EditorSlot { else if (source) this.scroll = source.scroll; if (init?.reveal) this.pendingReveal = init.reveal; model.addSlot(this); + this.detachHistory = model.manager.workspaceHistory.attach(this.historyTarget); } get ctx(): EditorDocContext { @@ -103,6 +115,7 @@ export class EditorSlot { private slotExtensions() { return [ + this.model.manager.workspaceHistory.extension(this.historyTarget), documentSynced.of(() => this.model.sync.whenIdle()), EditorView.updateListener.of((u) => { for (const l of this.listeners) l(u); @@ -170,6 +183,7 @@ export class EditorSlot { dispose() { if (this.disposed) return; + this.detachHistory(); this.unmount(); this.disposed = true; this.model.removeSlot(this); diff --git a/src/editor/core/model/workspaceHistory.test.ts b/src/editor/core/model/workspaceHistory.test.ts new file mode 100644 index 000000000..001d76f7f --- /dev/null +++ b/src/editor/core/model/workspaceHistory.test.ts @@ -0,0 +1,217 @@ +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { redo, redoDepth, undo, undoDepth, history } from "@codemirror/commands"; +import { EditorView } from "@codemirror/view"; +import { workspaceEditHistory } from "@/editor/api"; +import { installWorkspaceEditUndo } from "@/features/code-insight/workspaceUndo"; +import { provideNotificationService, type NotificationService } from "@/workbench/contributions"; +import { emitLocalIdeEvent } from "@/platform/ipc"; +import { createMemoryBackend, type MemoryBackend } from "../backend"; +import { _resetLanguages } from "../cm/languages"; +import { DocumentManager } from "./document"; +import { EditorSlot, type SlotHost } from "./slot"; +import { attachDocument } from "./attachment"; + +const commands = vi.hoisted(() => ({ undoWorkspaceEdit: vi.fn(), redoWorkspaceEdit: vi.fn() })); +vi.mock("@/bindings", () => ({ commands })); +const host: SlotHost = { onFocus() {}, onBlur() {}, onSelection() {}, onUserEdit() {}, onContextMenu() {} }; +let manager: DocumentManager; +let backend: MemoryBackend; +let notify = vi.fn<(n: { level: "info" | "warning" | "error"; title: string; body?: string }) => void>(); +let slots: EditorSlot[]; +let attached: EditorView | null; + +beforeEach(() => { + _resetLanguages(); + backend = createMemoryBackend({ "/p/A.java": "old", "/p/B.java": "old", "/p/closed.java": "old" }); + notify = vi.fn(); + manager = new DocumentManager(backend, { settings: () => ({ files: { autoSave: false } }), notify }); + manager.start(); + slots = []; + attached = null; + commands.undoWorkspaceEdit.mockReset().mockImplementation(async () => { + await replace("old"); + return { status: "ok", data: null }; + }); + commands.redoWorkspaceEdit.mockReset().mockImplementation(async () => { + await replace("new"); + return { status: "ok", data: null }; + }); +}); +afterEach(() => { + attached?.destroy(); + slots.forEach((s) => s.dispose()); + manager.stop(); +}); + +async function open(path: string, from?: EditorSlot) { + const slot = new EditorSlot(await manager.acquire(path), String(slots.length), host, from ? { from } : undefined); + slots.push(slot); + return slot; +} +async function replace(text: string) { + for (const d of backend.docs.values()) { + backend.patch(d.id, [{ from: 0, to: 3, insert: text }], true); + await backend.save(d.id); + } + backend.files.set("/p/closed.java", text); +} +async function rename() { + await replace("new"); + emitLocalIdeEvent({ type: "workspaceEditApplied", id: 9, paths: ["/p/A.java", "/p/B.java", "/p/closed.java"] }); +} +function historyStep(slot: EditorSlot, forward = false) { + return (forward ? redo : undo)({ + state: slot.currentState(), + dispatch: (tr) => { + slot.state = tr.state; + }, + }); +} + +it("editor Undo and Redo restore the entire Java rename, with one step in every split and background tab", async () => { + const a = await open("/p/A.java"); + const split = await open("/p/A.java"); + const b = await open("/p/B.java"); + await rename(); + for (const s of slots) expect(undoDepth(s.currentState())).toBe(1); + expect(historyStep(split)).toBe(true); + await vi.waitFor(() => expect(backend.files.get("/p/closed.java")).toBe("old")); + for (const s of slots) { + expect(s.currentState().doc.toString()).toBe("old"); + expect(undoDepth(s.currentState())).toBe(0); + expect(redoDepth(s.currentState())).toBe(1); + } + expect(commands.undoWorkspaceEdit).toHaveBeenCalledExactlyOnceWith(9); + historyStep(b, true); + await vi.waitFor(() => expect(backend.files.get("/p/closed.java")).toBe("new")); + expect(a.currentState().doc.toString()).toBe("new"); + expect(undoDepth(a.currentState())).toBe(1); + expect(commands.redoWorkspaceEdit).toHaveBeenCalledExactlyOnceWith(9); +}); + +it("notification Undo consumes the same marker and never makes Cmd+Z reapply a single file", async () => { + const a = await open("/p/A.java"); + await rename(); + await workspaceEditHistory(9); + expect(a.currentState().doc.toString()).toBe("old"); + expect(historyStep(a)).toBe(false); + expect(redoDepth(a.currentState())).toBe(1); + expect(commands.undoWorkspaceEdit).toHaveBeenCalledOnce(); +}); + +it("preserves earlier local history and refuses the notification until later typing is undone", async () => { + const a = await open("/p/A.java"); + a.dispatchLocal({ changes: { from: 3, insert: "!" }, userEvent: "input.type" }); + await a.model.sync.whenIdle(); + await rename(); + a.dispatchLocal({ changes: { from: 4, insert: "?" }, userEvent: "input.type" }); + await expect(workspaceEditHistory(9)).rejects.toThrow("Undo newer editor changes"); + expect(commands.undoWorkspaceEdit).not.toHaveBeenCalled(); + undo({ state: a.currentState(), dispatch: (tr) => a.dispatchLocal(tr) }); + await a.model.sync.whenIdle(); + await workspaceEditHistory(9); + expect(a.currentState().doc.toString()).toBe("old!"); + expect(undoDepth(a.currentState())).toBe(1); +}); + +it("restores the editor marker when backend guards refuse Undo", async () => { + const a = await open("/p/A.java"); + await rename(); + commands.undoWorkspaceEdit.mockResolvedValue({ + status: "error", + error: { kind: "outOfSync", message: "external write" }, + }); + historyStep(a); + await vi.waitFor(() => expect(notify).toHaveBeenCalled()); + expect(a.currentState().doc.toString()).toBe("new"); + expect(undoDepth(a.currentState())).toBe(1); + expect(redoDepth(a.currentState())).toBe(0); +}); + +it("supports direct CodeMirror Undo from an attached diff view", async () => { + const att = await attachDocument(manager, "/p/A.java"); + attached = new EditorView({ doc: att.text(), extensions: [att.extension, history()] }); + await Promise.resolve(); + await rename(); + undo(attached); + await vi.waitFor(() => expect(attached!.state.doc.toString()).toBe("old")); + expect(commands.undoWorkspaceEdit).toHaveBeenCalledOnce(); + expect(undoDepth(attached.state)).toBe(0); + attached.destroy(); + attached = null; + att.release(); +}); + +it.each(["closed file", "new split"])("can redo after a %s opens following Undo", async (kind) => { + const a = await open("/p/A.java"); + await rename(); + await workspaceEditHistory(9); + const next = await open(kind === "closed file" ? "/p/B.java" : "/p/A.java", kind === "new split" ? a : undefined); + expect(next.currentState().doc.toString()).toBe("old"); + historyStep(a, true); + await vi.waitFor(() => expect(commands.redoWorkspaceEdit).toHaveBeenCalledExactlyOnceWith(9)); + await vi.waitFor(() => expect(next.currentState().doc.toString()).toBe("new")); + expect(undoDepth(next.currentState())).toBe(1); + expect(notify).not.toHaveBeenCalled(); +}); + +it("keeps the refactoring notification in sync with editor Undo and Redo", async () => { + const notices: Parameters[0][] = []; + provideNotificationService({ + notify: (n) => { + notices.push(n); + }, + }); + const stop = installWorkspaceEditUndo(); + try { + const a = await open("/p/A.java"); + await rename(); + expect(notices.at(-1)?.actions?.[0]?.label).toBe("Undo"); + historyStep(a); + await vi.waitFor(() => expect(notices.at(-1)?.actions?.[0]?.label).toBe("Redo")); + historyStep(a, true); + await vi.waitFor(() => expect(notices.at(-1)?.actions?.[0]?.label).toBe("Undo")); + await notices.at(-1)!.actions![0]!.run(); + expect(notices.at(-1)?.actions?.[0]?.label).toBe("Redo"); + await notices.at(-1)!.actions![0]!.run(); + expect(a.currentState().doc.toString()).toBe("new"); + expect(notices.at(-1)?.actions?.[0]?.label).toBe("Undo"); + } finally { + stop(); + } +}); + +it("rebuilds multiple redo markers in order when a closed file opens", async () => { + const a = await open("/p/A.java"); + await rename(); + await replace("alt"); + emitLocalIdeEvent({ type: "workspaceEditApplied", id: 10, paths: ["/p/A.java", "/p/B.java"] }); + commands.undoWorkspaceEdit.mockImplementation(async (id: number) => { + await replace(id === 10 ? "new" : "old"); + return { status: "ok", data: null }; + }); + commands.redoWorkspaceEdit.mockImplementation(async (id: number) => { + await replace(id === 10 ? "alt" : "new"); + return { status: "ok", data: null }; + }); + await workspaceEditHistory(10); + await workspaceEditHistory(9); + const b = await open("/p/B.java"); + expect(redoDepth(b.currentState())).toBe(2); + historyStep(b, true); + await vi.waitFor(() => expect(a.currentState().doc.toString()).toBe("new")); + historyStep(a, true); + await vi.waitFor(() => expect(b.currentState().doc.toString()).toBe("alt")); + expect(commands.redoWorkspaceEdit.mock.calls.map(([id]) => id)).toEqual([9, 10]); +}); + +it("refuses redo when a newly opened affected editor has newer typing", async () => { + await open("/p/A.java"); + await rename(); + await workspaceEditHistory(9); + const b = await open("/p/B.java"); + b.dispatchLocal({ changes: { from: 3, insert: " own" }, userEvent: "input.type" }); + await expect(workspaceEditHistory(9, true)).rejects.toThrow("Undo newer editor changes"); + expect(commands.redoWorkspaceEdit).not.toHaveBeenCalled(); + expect(b.currentState().doc.toString()).toBe("old own"); +}); diff --git a/src/editor/core/model/workspaceHistory.ts b/src/editor/core/model/workspaceHistory.ts new file mode 100644 index 000000000..3a05ea440 --- /dev/null +++ b/src/editor/core/model/workspaceHistory.ts @@ -0,0 +1,176 @@ +/** Workspace edits contribute history markers; their text patches never enter local history. */ +import { invertedEffects, isolateHistory, redo, undo } from "@codemirror/commands"; +import { EditorState, Prec, StateEffect, type Transaction, type Extension } from "@codemirror/state"; +import { EditorView, keymap } from "@codemirror/view"; +import { _emitWorkspaceEditHistory, _provideWorkspaceHistory, isExternalChange } from "@/editor/api"; +import { call, commands, onIdeEvent } from "@/platform/ipc"; +import { useProject } from "@/platform/project"; + +interface Marker { + id: number; + redo: boolean; +} +const marker = StateEffect.define(); + +export interface WorkspaceHistoryTarget { + path: string; + state(): EditorState; + dispatch(tr: Transaction): void; +} + +export class WorkspaceHistory { + private targets = new Set(); + private records = new Map(); + private project = useProject.getState().project; + private busy = false; + private moving = false; + + constructor( + private synced: () => Promise, + private notify: (n: { level: "error"; title: string; body: string }) => void, + ) {} + + start(): () => void { + const stop = onIdeEvent("workspaceEditApplied", ({ id, paths }) => { + if (this.project !== useProject.getState().project) { + this.records.clear(); + this.project = useProject.getState().project; + } + // A new edit invalidates redo for any overlapping workspace transaction. + for (const [key, record] of this.records) + if ( + record.undone && + record.paths.some((path) => this.affects(paths, path) || paths.some((root) => root.startsWith(`${path}/`))) + ) + this.records.delete(key); + this.records.set(id, { paths, undone: false }); + while (this.records.size > 20) this.records.delete(this.records.keys().next().value!); + for (const target of this.targets) if (this.affects(paths, target.path)) this.mark(target, id); + }); + const unprovide = _provideWorkspaceHistory((id, redo) => this.run(id, redo)); + return () => { + stop(); + unprovide(); + this.records.clear(); + }; + } + + private affects(paths: string[], path: string) { + return paths.some((root) => path === root || path.startsWith(`${root}/`)); + } + + attach(target: WorkspaceHistoryTarget): () => void { + this.targets.add(target); + const records = [...this.records].filter(([, record]) => this.affects(record.paths, target.path)); + for (const [id] of records) this.mark(target, id); + // Build the same done/undone marker stacks as existing editors, without + // replaying any backend operation or changing the newly opened text. + for (const [id, record] of records.reverse()) { + if (!record.undone) continue; + const tr = this.step(target, id, false); + if (tr) target.dispatch(tr); + } + return () => this.targets.delete(target); + } + + extension(target: WorkspaceHistoryTarget): Extension { + return [ + Prec.highest( + keymap.of([ + { key: "Mod-z", run: () => this.busy }, + { key: "Mod-Shift-z", run: () => this.busy }, + { key: "Mod-y", run: () => this.busy }, + ]), + ), + Prec.highest( + EditorView.domEventHandlers({ + beforeinput: (event) => { + if (this.busy && (event.inputType === "historyUndo" || event.inputType === "historyRedo")) { + event.preventDefault(); + return true; + } + return false; + }, + }), + ), + invertedEffects.of((tr) => + tr.effects.filter((e) => e.is(marker)).map((e) => marker.of({ id: e.value.id, redo: !e.value.redo })), + ), + // History commands disable transaction filters. An extender observes the + // effect-only history step, including native beforeinput and direct CM undo. + EditorState.transactionExtender.of((tr) => { + if (!this.moving && (tr.isUserEvent("undo") || tr.isUserEvent("redo"))) { + const entry = tr.effects.find((e) => e.is(marker)); + if (entry) + void this.run(entry.value.id, entry.value.redo, target).catch((error: unknown) => { + this.notify({ + level: "error", + title: "Cannot undo refactoring", + body: String(error instanceof Error ? error.message : error), + }); + }); + } + return null; + }), + EditorState.changeFilter.of((tr) => !this.busy || isExternalChange(tr)), + ]; + } + + private mark(target: WorkspaceHistoryTarget, id: number) { + target.dispatch( + target.state().update({ effects: marker.of({ id, redo: true }), annotations: isolateHistory.of("full") }), + ); + } + + /** Inspect the next history step without dispatching it or starting another request. */ + private step(target: WorkspaceHistoryTarget, id: number, forward: boolean): Transaction | null { + let result: Transaction | null = null; + this.moving = true; + try { + (forward ? redo : undo)({ + state: target.state(), + dispatch: (tr) => { + if (tr.effects.some((e) => e.is(marker) && e.value.id === id && e.value.redo === forward)) result = tr; + }, + }); + } finally { + this.moving = false; + } + return result; + } + + async run(id: number, forward = false, source?: WorkspaceHistoryTarget): Promise { + if (this.busy) throw new Error("A refactoring history operation is in progress"); + this.busy = true; + try { + // Yield before reading a source: its marker transaction is still being built. + await this.synced(); + if (this.project !== useProject.getState().project) + throw new Error("The project for this refactoring is no longer open"); + const record = this.records.get(id); + if (!record || record.undone !== forward) throw new Error("The refactoring can no longer be undone"); + const targets = [...this.targets].filter((t) => t !== source && this.affects(record.paths, t.path)); + for (const target of targets) + if (!this.step(target, id, forward)) + throw new Error("Undo newer editor changes before undoing this refactoring"); + await call(forward ? commands.redoWorkspaceEdit(id) : commands.undoWorkspaceEdit(id)); + await this.synced(); + for (const target of targets) { + if (!this.targets.has(target)) continue; + const tr = this.step(target, id, forward); + if (tr) target.dispatch(tr); + } + record.undone = !forward; + _emitWorkspaceEditHistory({ id, undone: record.undone }); + } catch (error) { + // A refused backend restore must leave the initiating editor's history intact. + if (source && this.targets.has(source)) { + const tr = this.step(source, id, !forward); + if (tr) source.dispatch(tr); + } + throw error; + } finally { + this.busy = false; + } + } +} diff --git a/src/editor/core/sync/DocSync.ts b/src/editor/core/sync/DocSync.ts index d262f7adc..b0bb67c35 100644 --- a/src/editor/core/sync/DocSync.ts +++ b/src/editor/core/sync/DocSync.ts @@ -35,7 +35,7 @@ export interface SyncTransport { export interface SyncHost { /** Apply a change set (in local-text coordinates) to every view, annotated as remote. */ - applyRemote(changes: ChangeSet): void; + applyRemote(changes: ChangeSet, workspaceEdit?: boolean): void; /** Called after a hard resync discarded local changes the backend never saw. */ onDiscarded?(reason: unknown): void; /** Non-recoverable transport errors (logged by default). */ @@ -73,6 +73,7 @@ export function toTextChanges(cs: ChangeSet): TextChange[] { } interface RemotePatch { + workspaceEdit?: boolean; baseVersion: number; version: number; changes: TextChange[]; @@ -298,7 +299,7 @@ export class DocSync { } if (p.empty) return; this.localText = p.apply(this.localText); - this.host.applyRemote(p); + this.host.applyRemote(p, patch.workspaceEdit); } /** Last resort: adopt the backend text, dropping unacknowledged local changes. */ diff --git a/src/editor/lsp/rename.ts b/src/editor/lsp/rename.ts index aaeed7547..7a5ec9baa 100644 --- a/src/editor/lsp/rename.ts +++ b/src/editor/lsp/rename.ts @@ -23,7 +23,7 @@ import { type WorkspaceEdit, } from "@/platform/ipc"; import { executeAction } from "@/platform/actions"; -import { whenSynced, type EditorDocContext } from "@/editor/api"; +import { whenAllSynced, whenSynced, type EditorDocContext } from "@/editor/api"; import { structure } from "@/editor/structure"; import { docOf, incompleteNote, isUnavailable, lspRequest, reportLspError } from "./lsp"; import { comparePos, offsetToPos, rangeToOffsets } from "./positions"; @@ -44,6 +44,7 @@ import { tx } from "@/platform/i18n"; async function applyEdit(view: EditorView, edit: WorkspaceEdit) { try { + await whenAllSynced(); await lspRequest(() => commands.applyWorkspaceEdit(edit)); } catch (e) { reportLspError(view, e, true); diff --git a/src/editor/lsp/syntaxFallback.test.ts b/src/editor/lsp/syntaxFallback.test.ts index 366cbb6da..5c31bd84c 100644 --- a/src/editor/lsp/syntaxFallback.test.ts +++ b/src/editor/lsp/syntaxFallback.test.ts @@ -417,6 +417,29 @@ describe("rename wiring", () => { expect(document.querySelector(".cm-lsp-hint")?.textContent).toContain("only local variables"); }); + it.each(["typescript", "java"])("previews a %s multi-file rename and retains version guards", async (language) => { + cmd.lspPrepareRename.mockReturnValueOnce(ok(r(0, 0, 3))); + const edit = { + files: ["/p/Main.java", "/p/Use.java"].map((path) => ({ + kind: "edit", + path, + version: 7, + edits: [{ range: r(0, 0, 3), new_text: "new" }], + })), + preconditions: [{ path: "/p/Main.java", hash: "original" }], + }; + cmd.lspRename.mockReturnValueOnce(ok({ edit, incomplete: null })); + const view = editor("old", language, 0); + await renameNamePart(view, { from: 0, to: 3 }, "new"); + const preview = useLspPopup.getState().popup; + expect(preview?.kind).toBe("preview"); + expect(cmd.applyWorkspaceEdit).not.toHaveBeenCalled(); + if (preview?.kind !== "preview") throw new Error("missing rename preview"); + expect(preview.summary.files).toBe(2); + preview.onConfirm(); + await vi.waitFor(() => expect(cmd.applyWorkspaceEdit).toHaveBeenCalledExactlyOnceWith(edit)); + }); + describe("Typo: Rename to (issue #42)", () => { it("renames the whole name through the server, references included", async () => { cmd.lspPrepareRename.mockReturnValueOnce(ok(r(0, 0, 12))); diff --git a/src/features/analysis/AnalysisDialogs.tsx b/src/features/analysis/AnalysisDialogs.tsx index 55cffd851..cd9a8ce38 100644 --- a/src/features/analysis/AnalysisDialogs.tsx +++ b/src/features/analysis/AnalysisDialogs.tsx @@ -7,6 +7,7 @@ import { type DuplicateOptions, type StructuralFile, type StructuralLanguage, + type WorkspaceEdit, } from "@/platform/ipc"; import { executeAction } from "@/platform/actions"; import { activeProfileId, listProfiles, validateInspections, type ProfileId } from "@/platform/inspections"; @@ -29,6 +30,7 @@ import { } from "./structural"; import styles from "./Analysis.module.css"; import { tx } from "@/platform/i18n"; +import { notifications } from "@/workbench/contributions"; function InspectCodeDialog({ onClose }: { onClose: () => void }) { const [scope, setScope] = useState(() => defaultScope()); @@ -254,7 +256,7 @@ function StructuralDialog({ replace, onClose }: { replace: boolean; onClose: () } else if (!result.files.length) { await showStructuralResults(form.pattern, result.files, result.summary); } else { - showReplacePreview(result.files); + await showReplacePreview(result.files); } }; @@ -322,7 +324,15 @@ function StructuralDialog({ replace, onClose }: { replace: boolean; onClose: () ); } -function ReplacePreviewDialog({ files, onClose }: { files: StructuralFile[]; onClose: () => void }) { +function ReplacePreviewDialog({ + files, + preconditions, + onClose, +}: { + files: StructuralFile[]; + preconditions: WorkspaceEdit["preconditions"]; + onClose: () => void; +}) { const [excluded, setExcluded] = useState>(() => new Set()); const edit = useMemo(() => buildReplaceEdit(files, excluded), [files, excluded]); const count = edit.files.reduce((n, f) => n + (f.kind === "edit" ? f.edits.length : 0), 0); @@ -351,7 +361,7 @@ function ReplacePreviewDialog({ files, onClose }: { files: StructuralFile[]; onC disabled: count === 0, onClick: () => { onClose(); - void applyStructuralReplace(edit); + void applyStructuralReplace({ ...edit, preconditions }); }, }, { label: "Cancel", role: "cancel" }, @@ -389,5 +399,18 @@ export const showDuplicatesDialog = () => openDialog((close) => openDialog((close) => ); export const showStructuralDialog = (replace: boolean) => openDialog((close) => ); -export const showReplacePreview = (files: StructuralFile[]) => - openDialog((close) => ); +export const showReplacePreview = async (files: StructuralFile[]) => { + try { + const edit = buildReplaceEdit(files, new Set()); + const prepared = isTauri() ? await call(commands.prepareWorkspaceEdit(edit)) : edit; + openDialog((close) => ( + + )); + } catch (error) { + notifications().notify({ + level: "error", + title: "Cannot preview replacements", + body: error instanceof Error ? error.message : String(error), + }); + } +}; diff --git a/src/features/analysis/replacePreview.test.tsx b/src/features/analysis/replacePreview.test.tsx new file mode 100644 index 000000000..e1af8f8cf --- /dev/null +++ b/src/features/analysis/replacePreview.test.tsx @@ -0,0 +1,77 @@ +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import type { StructuralFile, WorkspaceEdit } from "@/platform/ipc"; +import { provideNotificationService } from "@/workbench/contributions"; +import { AnalysisDialogHost } from "./dialogs"; +import { showReplacePreview } from "./AnalysisDialogs"; + +const mocks = vi.hoisted(() => ({ prepare: vi.fn(), apply: vi.fn(), synced: vi.fn(), notify: vi.fn() })); +vi.mock("@/platform/ipc", async (original) => { + const actual = await original(); + return { + ...actual, + isTauri: () => true, + commands: { ...actual.commands, prepareWorkspaceEdit: mocks.prepare, applyWorkspaceEdit: mocks.apply }, + }; +}); +vi.mock("@/editor/api", async (original) => ({ + ...(await original()), + whenAllSynced: mocks.synced, +})); +const files: StructuralFile[] = ["a.ts", "b.ts"].map((name) => ({ + path: `/p/${name}`, + relPath: name, + matches: [ + { + range: { start: { line: 0, col: 0 }, end: { line: 0, col: 3 } }, + text: "old", + lineText: "old", + replacement: "new", + }, + ], +})); +const preconditions = files.map((f) => ({ path: f.path, hash: `preview:${f.path}` })); +beforeEach(() => { + mocks.prepare + .mockReset() + .mockImplementation(async (edit: WorkspaceEdit) => ({ status: "ok", data: { ...edit, preconditions } })); + mocks.apply.mockReset().mockResolvedValue({ status: "ok", data: null }); + mocks.synced.mockReset().mockResolvedValue(undefined); + mocks.notify.mockReset(); + provideNotificationService({ notify: mocks.notify }); +}); +afterEach(() => { + if (screen.queryByRole("button", { name: "Cancel" })) fireEvent.click(screen.getByRole("button", { name: "Cancel" })); + cleanup(); +}); + +it("captures guards before preview and preserves them when the user excludes a file", async () => { + render(); + await act(() => showReplacePreview(files)); + expect(mocks.prepare).toHaveBeenCalledOnce(); + fireEvent.click(screen.getByRole("checkbox", { name: "Replace in b.ts:1" })); + fireEvent.click(screen.getByRole("button", { name: "Replace" })); + await waitFor(() => expect(mocks.apply).toHaveBeenCalledOnce()); + expect(mocks.apply.mock.calls[0]![0]).toEqual({ + files: [{ kind: "edit", path: "/p/a.ts", edits: [{ range: files[0]!.matches[0]!.range, new_text: "new" }] }], + preconditions, + }); + expect(mocks.synced).toHaveBeenCalledOnce(); +}); + +it("reports a stale preview without refreshing guards or retrying the edits", async () => { + mocks.apply.mockResolvedValue({ + status: "error", + error: { kind: "outOfSync", message: "a.ts changed; request again" }, + }); + render(); + await act(() => showReplacePreview(files)); + fireEvent.click(screen.getByRole("button", { name: "Replace" })); + await waitFor(() => + expect(mocks.notify).toHaveBeenCalledWith( + expect.objectContaining({ level: "error", title: "Replace Structurally", body: "a.ts changed; request again" }), + ), + ); + expect(mocks.prepare).toHaveBeenCalledOnce(); + expect(mocks.apply).toHaveBeenCalledOnce(); +}); diff --git a/src/features/analysis/structural.ts b/src/features/analysis/structural.ts index 5d35e0ea3..54ba88251 100644 --- a/src/features/analysis/structural.ts +++ b/src/features/analysis/structural.ts @@ -4,7 +4,7 @@ * one workspace edit. */ import { Channel } from "@tauri-apps/api/core"; -import { activeEditor } from "@/editor/api"; +import { activeEditor, whenAllSynced } from "@/editor/api"; import { executeAction } from "@/platform/actions"; import { call, @@ -160,6 +160,7 @@ export async function showStructuralResults( export async function applyStructuralReplace(edit: WorkspaceEdit): Promise { try { + await whenAllSynced(); await call(commands.applyWorkspaceEdit(edit)); } catch (e) { notifications().notify({ diff --git a/src/features/code-insight/index.ts b/src/features/code-insight/index.ts index 1ad1f06e4..410c12ee6 100644 --- a/src/features/code-insight/index.ts +++ b/src/features/code-insight/index.ts @@ -22,6 +22,7 @@ import { resetHierarchy, showHierarchy } from "./hierarchy"; import { installServerEvents } from "./servers"; import { goConstraintExcluded } from "./goConstraints"; import { ServerStatus } from "./ServerStatus"; +import { installWorkspaceEditUndo } from "./workspaceUndo"; const EDITOR_WHEN = "textEditorActive && (editorFocus || popupOpen || !toolWindowFocus)"; @@ -60,6 +61,7 @@ export function activate(): () => void { installProblemsEvents(), installProblemDecorations(), installServerEvents(), + installWorkspaceEditUndo(), registerToolWindow({ id: "problems", title: "Problems", diff --git a/src/features/code-insight/workspaceUndo.test.ts b/src/features/code-insight/workspaceUndo.test.ts new file mode 100644 index 000000000..a972b9ed7 --- /dev/null +++ b/src/features/code-insight/workspaceUndo.test.ts @@ -0,0 +1,66 @@ +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { emitLocalIdeEvent } from "@/platform/ipc"; +import { provideNotificationService, type NotificationService } from "@/workbench/contributions"; +import { useProject } from "@/platform/project"; +import { installWorkspaceEditUndo } from "./workspaceUndo"; + +const mocks = vi.hoisted(() => ({ undo: vi.fn(), synced: vi.fn(), history: vi.fn(() => () => {}) })); +vi.mock("@/editor/api", () => ({ + whenAllSynced: mocks.synced, + workspaceEditHistory: mocks.undo, + onWorkspaceEditHistory: mocks.history, +})); + +let stop: () => void; +let notices: Parameters[0][]; + +beforeEach(() => { + mocks.undo.mockReset().mockResolvedValue(undefined); + mocks.synced.mockReset().mockResolvedValue(undefined); + notices = []; + provideNotificationService({ + notify: (notice) => { + notices.push(notice); + }, + }); + stop = installWorkspaceEditUndo(); +}); + +afterEach(() => stop()); + +it("waits for pending typing before undoing the entire backend transaction", async () => { + let release: () => void = () => {}; + mocks.synced.mockReturnValue( + new Promise((resolve) => { + release = resolve; + }), + ); + emitLocalIdeEvent({ type: "workspaceEditApplied", id: 17, paths: [] }); + const action = notices[0]!.actions![0]!; + expect(action.label).toBe("Undo"); + const pending = action.run(); + expect(mocks.undo).not.toHaveBeenCalled(); + release(); + await pending; + expect(mocks.undo).toHaveBeenCalledExactlyOnceWith(17); +}); + +it("reports a refused undo instead of discarding newer work", async () => { + mocks.undo.mockRejectedValue(new Error("a.txt changed")); + emitLocalIdeEvent({ type: "workspaceEditApplied", id: 18, paths: [] }); + await notices[0]!.actions![0]!.run(); + expect(notices[1]).toMatchObject({ level: "error", title: "Cannot undo refactoring", body: "a.txt changed" }); +}); + +it("does not apply an old notification's id to a different project", async () => { + emitLocalIdeEvent({ type: "workspaceEditApplied", id: 19, paths: [] }); + const previous = useProject.getState().project; + useProject.setState({ project: { ...previous, root: "/different-project" } as NonNullable }); + try { + await notices[0]!.actions![0]!.run(); + expect(mocks.undo).not.toHaveBeenCalled(); + expect(notices[1]).toMatchObject({ level: "error", body: "The project for this refactoring is no longer open" }); + } finally { + useProject.setState({ project: previous }); + } +}); diff --git a/src/features/code-insight/workspaceUndo.ts b/src/features/code-insight/workspaceUndo.ts new file mode 100644 index 000000000..a95643708 --- /dev/null +++ b/src/features/code-insight/workspaceUndo.ts @@ -0,0 +1,55 @@ +import { onWorkspaceEditHistory, whenAllSynced, workspaceEditHistory } from "@/editor/api"; +import { onIdeEvent } from "@/platform/ipc"; +import { useProject } from "@/platform/project"; +import { notifications } from "@/workbench/contributions"; + +/** The notification follows the same history state as editor Undo/Redo. */ +export function installWorkspaceEditUndo(): () => void { + let current: { id: number; project: ReturnType["project"]; undone: boolean } | null = + null; + const show = () => { + if (!current) return; + const entry = current; + const { id, project, undone } = entry; + notifications().notify({ + level: "info", + title: undone ? "Refactoring undone" : "Refactoring applied", + key: "refactor.workspaceEdit", + actions: [ + { + label: undone ? "Redo" : "Undo", + run: async () => { + try { + await whenAllSynced(); + if (project !== useProject.getState().project) + throw new Error("The project for this refactoring is no longer open"); + if (current !== entry || current.undone !== undone) return; + if (undone) await workspaceEditHistory(id, true); + else await workspaceEditHistory(id); + } catch (error) { + notifications().notify({ + level: "error", + title: "Cannot undo refactoring", + body: error instanceof Error ? error.message : String(error), + }); + } + }, + }, + ], + }); + }; + const stopApplied = onIdeEvent("workspaceEditApplied", ({ id }) => { + current = { id, project: useProject.getState().project, undone: false }; + show(); + }); + const stopHistory = onWorkspaceEditHistory(({ id, undone }) => { + if (!current || current.id !== id || current.project !== useProject.getState().project) return; + current = { ...current, undone }; + show(); + }); + return () => { + stopApplied(); + stopHistory(); + current = null; + }; +} diff --git a/src/features/project-tree/RefactoringPreview.tsx b/src/features/project-tree/RefactoringPreview.tsx index 00886ca96..fbcf8020d 100644 --- a/src/features/project-tree/RefactoringPreview.tsx +++ b/src/features/project-tree/RefactoringPreview.tsx @@ -138,7 +138,7 @@ function PreviewDialog({ request }: { request: PreviewRequest }) { label: "Do Refactor", role: "default", autoFocus: true, - onClick: () => request.resolve({ files: kept }), + onClick: () => request.resolve({ ...request.edit, files: kept }), }, { label: "Cancel", role: "cancel" }, ]} diff --git a/src/features/project-tree/fileRefactor.test.tsx b/src/features/project-tree/fileRefactor.test.tsx index 34eb34f94..1e6baf07c 100644 --- a/src/features/project-tree/fileRefactor.test.tsx +++ b/src/features/project-tree/fileRefactor.test.tsx @@ -22,6 +22,16 @@ const edit = (path: string, ...ranges: [number, number, number, string][]): Work }); describe("file refactoring edits", () => { + it("retains version guards and both sources' preview fingerprints", () => { + const lsp: WorkspaceEdit = { + files: [{ kind: "edit", path: "/p/main.ts", edits: [{ range: r(0, 0, 3), new_text: "new" }], version: 7 }], + preconditions: [{ path: "/p/main.ts", hash: "source" }], + }; + const text: WorkspaceEdit = { files: [], preconditions: [{ path: "/p/README.md", hash: "text" }] }; + const merged = mergeEdits(lsp, text); + expect(merged.files[0]).toMatchObject({ version: 7 }); + expect(merged.preconditions).toEqual([...lsp.preconditions!, ...text.preconditions!]); + }); it("merges text occurrences that no server edit covers and drops file operations", () => { const lsp: WorkspaceEdit = { files: [edit("/p/main.ts", [0, 20, 26, "./b/util"]), { kind: "rename", from: "/p/a", to: "/p/b" }], @@ -58,7 +68,10 @@ describe("runFileRefactoring", () => { backend = { calls: [], edits: vi.fn(async () => ({ - edit: { files: [edit("/p/main.ts", [0, 20, 26, "./b/util"])] }, + edit: { + files: [edit("/p/main.ts", [0, 20, 26, "./b/util"])], + preconditions: [{ path: "/p/main.ts", hash: "preview" }], + }, errors: ["tsserver: busy"], })), move: vi.fn(async (moves: FileMove[], e: WorkspaceEdit) => { @@ -86,6 +99,11 @@ describe("runFileRefactoring", () => { fireEvent.click(within(dialog).getByRole("button", { name: "Do Refactor" })); expect(await status).toBe("done"); expect(backend.calls).toEqual(["move 1 1"]); + expect(backend.move).toHaveBeenCalledWith( + moves, + expect.objectContaining({ preconditions: [{ path: "/p/main.ts", hash: "preview" }] }), + "Move", + ); expect(lastFileRefactoring()?.id).toBe(7); // Server failures are reported; the result offers Undo. expect(notify).toHaveBeenCalledWith(expect.objectContaining({ level: "warning", body: "tsserver: busy" })); diff --git a/src/features/project-tree/fileRefactor.ts b/src/features/project-tree/fileRefactor.ts index 63e3a65e1..6676af2d0 100644 --- a/src/features/project-tree/fileRefactor.ts +++ b/src/features/project-tree/fileRefactor.ts @@ -129,9 +129,15 @@ function overlaps(a: { start: { line: number; col: number }; end: { line: number * Only text edits are kept: the move itself is the refactoring's file operation. */ export function mergeEdits(lsp: WorkspaceEdit, text: WorkspaceEdit): WorkspaceEdit { + const versions = new Map(); + const guards: WorkspaceEdit["files"] = []; const byPath = new Map["edits"]>(); for (const f of lsp.files) { if (f.kind !== "edit") continue; + const previous = versions.get(f.path); + if (previous != null && f.version != null && previous !== f.version) + guards.push({ ...f, edits: [], version: previous }); + versions.set(f.path, f.version ?? previous); byPath.set(f.path, [...(byPath.get(f.path) ?? []), ...f.edits]); } for (const f of text.files) { @@ -140,7 +146,15 @@ export function mergeEdits(lsp: WorkspaceEdit, text: WorkspaceEdit): WorkspaceEd const extra = f.edits.filter((t) => !existing.some((e) => overlaps(e.range, t.range))); if (extra.length) byPath.set(f.path, [...existing, ...extra]); } - return { files: [...byPath].filter(([, e]) => e.length).map(([path, edits]) => ({ kind: "edit", path, edits })) }; + return { + files: [ + ...guards, + ...[...byPath] + .filter(([, e]) => e.length) + .map(([path, edits]) => ({ kind: "edit" as const, path, edits, version: versions.get(path) })), + ], + preconditions: [...(lsp.preconditions ?? []), ...(text.preconditions ?? [])], + }; } /** Whether `edit` changes files other than the moved ones (IDEA previews those refactorings). */ diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index f12eafbdc..4240050a1 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -326,6 +326,8 @@ "Cannot send the request": "无法发送请求", "Cannot stop process": "无法停止进程", "Cannot undo": "无法撤消", + "Cannot undo refactoring": "无法撤消重构", + "Cannot preview replacements": "无法预览替换", "Caret blinking (ms):": "光标闪烁(毫秒):", "Caret column number (1-based)": "光标所在列号(从 1 开始)", "Caret line number (1-based)": "光标所在行号(从 1 开始)", @@ -1603,6 +1605,7 @@ "Refactor This": "重构此项", "Refactor This…": "重构此项…", "Refactoring undone": "重构已撤消", + "Refactoring applied": "重构已应用", "Reformat Code": "重新格式化代码", "Prettier path": "Prettier 路径", "Auto: nearest project node_modules, managed installation, then PATH. An explicit path overrides auto detection.": "自动:依次查找最近的项目 node_modules、受管安装和 PATH。指定路径将覆盖自动检测。", From d981603f3c9b7b618d745a3514256f5f63d18339 Mon Sep 17 00:00:00 2001 From: qiaojianlong <109515705+qjlqyf@users.noreply.github.com> Date: Mon, 2 Nov 2026 22:23:09 +0800 Subject: [PATCH 029/106] feat(java): navigate Spring configuration keys and usages (#823) YAML/properties keys stayed inside the generic YAML language server, so Spring configuration bindings and their consumers were unreachable from the editor. A new command in the bundled JDT LS extension (java-usages 1.2.0) resolves keys to @ConfigurationProperties members (prefixes, nested/inherited members, records, constructor aliases, collections), @Value placeholders and PropertyResolver calls, and back from Java to configuration entries; Find Usages merges configuration consumers and declarations with the native Java results. Several profiles or modules are offered as candidates. Closes #796. Co-authored-by: qjlqyf <109515705+qjlqyf@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 --- .../ide-agent/src/native/claude/translate.rs | 2 +- crates/ide-host/src/data_dirs.rs | 2 + crates/ide-language/src/lib.rs | 6 +- .../ide-language/src/syntax/configuration.rs | 242 ++++++++ crates/ide-language/src/syntax/mod.rs | 2 + crates/ide-lsp/src/jdtls.rs | 1 + crates/ide-lsp/src/jdtls/configuration.rs | 416 ++++++++++++++ crates/ide-lsp/src/lib.rs | 49 +- crates/ide-lsp/tests/mock_server.rs | 89 +++ crates/ide-sdk/src/java.rs | 2 +- crates/ide-syntax/src/jvm.rs | 2 +- plugins/java-usages/CONFIGURATION.md | 114 ++++ plugins/java-usages/MANIFEST.MF | 4 +- plugins/java-usages/README.md | 10 +- plugins/java-usages/openidea-java-usages.jar | Bin 31030 -> 82033 bytes plugins/java-usages/plugin.xml | 3 + .../configuration/ConfigurationCommand.java | 525 ++++++++++++++++++ plugins/java-usages/tests/configuration.py | 220 ++++++++ 18 files changed, 1674 insertions(+), 15 deletions(-) create mode 100644 crates/ide-language/src/syntax/configuration.rs create mode 100644 crates/ide-lsp/src/jdtls/configuration.rs create mode 100644 plugins/java-usages/CONFIGURATION.md create mode 100644 plugins/java-usages/src/ai/openidea/configuration/ConfigurationCommand.java create mode 100644 plugins/java-usages/tests/configuration.py diff --git a/crates/ide-agent/src/native/claude/translate.rs b/crates/ide-agent/src/native/claude/translate.rs index ac8158f01..066be5f03 100644 --- a/crates/ide-agent/src/native/claude/translate.rs +++ b/crates/ide-agent/src/native/claude/translate.rs @@ -169,7 +169,7 @@ impl Translator { Some("tool_use") => { // The input streams after this: shown by name until the message has it. let (id, name) = (str_of(&block, "id"), str_of(&block, "name")); - if !id.is_empty() && !tools::is_plan(&name) && !(tools::is_agent(&name) && self.subagents) { + if !(id.is_empty() || tools::is_plan(&name) || tools::is_agent(&name) && self.subagents) { out.extend(self.ensure_call(&id, &name, &json!({}), &session)); } } diff --git a/crates/ide-host/src/data_dirs.rs b/crates/ide-host/src/data_dirs.rs index 9c96c4153..b76b168cc 100644 --- a/crates/ide-host/src/data_dirs.rs +++ b/crates/ide-host/src/data_dirs.rs @@ -57,6 +57,7 @@ fn migrate_entries(data: &Path, local: &Path) { "notebook-runtime", "server", "java-usages-1.1.1.jar", + "java-usages-1.2.0.jar", "agents/registry.json", ] .into_iter() @@ -298,6 +299,7 @@ mod tests { "agents/tools", "agents/registry.json", "java-usages-1.1.1.jar", + "java-usages-1.2.0.jar", ] { assert!(is_local(Path::new(entry)), "{entry}"); assert_eq!( diff --git a/crates/ide-language/src/lib.rs b/crates/ide-language/src/lib.rs index 908e8315c..bd4fa6d59 100644 --- a/crates/ide-language/src/lib.rs +++ b/crates/ide-language/src/lib.rs @@ -15,9 +15,9 @@ mod syntax; pub use markers::marked_root; pub use registry::{FileTypes, LanguageRegistry}; pub use syntax::{ - CSS_CLASS, CSS_ID, CSS_VARIABLE, FileReference, Literal, LiteralKind, MAX_CACHED_TREES, MAX_PARSE_BYTES, - ParsedTree, SpellingKind, SpellingRegion, Syntax, SyntaxCache, SyntaxCacheStats, SyntaxHighlightKind, - SyntaxHighlights, TreeError, WebReference, WebReferenceKind, merge_visibility, + CSS_CLASS, CSS_ID, CSS_VARIABLE, ConfigurationKey, FileReference, Literal, LiteralKind, MAX_CACHED_TREES, + MAX_PARSE_BYTES, ParsedTree, SpellingKind, SpellingRegion, Syntax, SyntaxCache, SyntaxCacheStats, + SyntaxHighlightKind, SyntaxHighlights, TreeError, WebReference, WebReferenceKind, merge_visibility, }; /// Parsed form of `languages/.toml`. Backend-only; the UI gets [`LanguageInfo`]. diff --git a/crates/ide-language/src/syntax/configuration.rs b/crates/ide-language/src/syntax/configuration.rs new file mode 100644 index 000000000..43c1cb5cf --- /dev/null +++ b/crates/ide-language/src/syntax/configuration.rs @@ -0,0 +1,242 @@ +//! Source ranges for Spring-style configuration keys. This is a syntax index, +//! not a YAML evaluator: aliases, merge keys and runtime profile precedence are +//! intentionally left to the YAML server / application. +use super::lines::LineIndex; +use super::{ParsedTree, Syntax, TreeError}; +use ide_core::{LanguageId, Range}; +use tree_sitter::Node; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ConfigurationKey { + pub name: String, + pub range: Range, +} + +impl Syntax { + pub fn configuration_keys( + &self, + language: &LanguageId, + text: &str, + cancelled: &dyn Fn() -> bool, + ) -> Result, TreeError> { + if language.as_str() == "properties" { + return Ok(properties(text)); + } + self.with_parsed(language, text, cancelled, |tree| tree.configuration_keys(cancelled)) + } +} + +impl ParsedTree<'_> { + pub fn configuration_keys(&self, cancelled: &dyn Fn() -> bool) -> Vec { + let mut out = Vec::new(); + if self.grammar == "yaml" { + yaml(self.tree.root_node(), "", self.text, &LineIndex::new(self.text), &mut out, cancelled, 0); + } + out + } +} + +fn yaml( + node: Node<'_>, + prefix: &str, + text: &str, + lines: &LineIndex<'_>, + out: &mut Vec, + cancelled: &dyn Fn() -> bool, + depth: usize, +) { + if depth > 64 || cancelled() || out.len() >= 20_000 { + return; + } + match node.kind() { + "block_mapping_pair" | "flow_pair" => { + let Some(key) = node.child_by_field_name("key") else { return }; + let raw = &text[key.byte_range()]; + if raw.contains('\n') { + return; + } + let name = if raw.starts_with('\'') && raw.ends_with('\'') { + raw[1..raw.len() - 1].replace("''", "'") + } else if raw.starts_with('"') { + // JSON escapes cover ordinary quoted configuration keys. Unsupported + // YAML-only escapes are skipped rather than indexed under a wrong key. + let Ok(name) = serde_json::from_str::(raw) else { return }; + name + } else { + if raw.contains(['\n', '{', '[', '*', '&', '!']) || raw == "<<" { + return; + } + raw.to_owned() + }; + if name.is_empty() { + return; + } + let name = if prefix.is_empty() { + name + } else if name.starts_with('[') { + format!("{prefix}{name}") + } else { + format!("{prefix}.{name}") + }; + if name.len() > 1024 { + return; + } + out.push(ConfigurationKey { name: name.clone(), range: lines.range(key.start_byte(), key.end_byte()) }); + if let Some(value) = node.child_by_field_name("value") { + yaml(value, &name, text, lines, out, cancelled, depth + 1); + } + } + "block_sequence" | "flow_sequence" => { + let mut cursor = node.walk(); + for (i, child) in node.named_children(&mut cursor).filter(|n| n.kind() != "comment").enumerate() { + yaml(child, &format!("{prefix}[{i}]"), text, lines, out, cancelled, depth + 1); + } + } + // Do not descend into scalars, anchors or aliases. + "stream" + | "document" + | "block_node" + | "flow_node" + | "block_mapping" + | "flow_mapping" + | "block_sequence_item" => { + let mut cursor = node.walk(); + for child in node.named_children(&mut cursor) { + yaml(child, prefix, text, lines, out, cancelled, depth + 1); + } + } + _ => {} + } +} + +fn properties(text: &str) -> Vec { + let lines = LineIndex::new(text); + let mut out = Vec::new(); + let mut logical = String::new(); + let mut offsets = Vec::new(); + let mut base = 0; + for line in text.split_inclusive('\n') { + let physical = line.trim_end_matches(['\r', '\n']); + let start = physical.len() - physical.trim_start_matches([' ', '\t', '\u{c}']).len(); + let slice = &physical[start..]; + if logical.is_empty() && (slice.is_empty() || slice.starts_with(['#', '!'])) { + base += line.len(); + continue; + } + let continued = slice.bytes().rev().take_while(|b| *b == b'\\').count() % 2 == 1; + let end = physical.len() - usize::from(continued); + for (i, ch) in physical[start..end].char_indices() { + offsets.extend((0..ch.len_utf8()).map(|byte| base + start + i + byte)); + logical.push(ch); + } + if !continued || base + line.len() == text.len() { + let mut escaped = false; + let key_end = logical + .char_indices() + .find_map(|(i, ch)| { + if escaped { + escaped = false; + return None; + } + if ch == '\\' { + escaped = true; + return None; + } + matches!(ch, '=' | ':' | ' ' | '\t' | '\u{c}').then_some(i) + }) + .unwrap_or(logical.len()); + if key_end > 0 + && key_end <= 1024 + && let Some(name) = unescape_property(&logical[..key_end]) + { + out.push(ConfigurationKey { name, range: lines.range(offsets[0], offsets[key_end - 1] + 1) }); + } + logical.clear(); + offsets.clear(); + } + base += line.len(); + } + out +} + +fn unescape_property(raw: &str) -> Option { + let mut chars = raw.chars(); + let mut out = String::new(); + while let Some(ch) = chars.next() { + if ch != '\\' { + out.push(ch); + continue; + } + match chars.next()? { + 'u' => { + let hex: String = chars.by_ref().take(4).collect(); + if hex.len() != 4 { + return None; + } + out.push(char::from_u32(u32::from_str_radix(&hex, 16).ok()?)?); + } + 't' => out.push('\t'), + 'n' => out.push('\n'), + 'r' => out.push('\r'), + 'f' => out.push('\u{c}'), + ch => out.push(ch), + } + } + Some(out) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn yaml_structures_and_utf16() { + let text = "app:\n 'time-out': 1\n hosts:\n - url: a\n - {url: b}\n map: {east: {size: 2}}\n---\n\"app.name\": ok\n'😀': {port: 3}\n"; + let keys = Syntax::new().configuration_keys(&LanguageId::new("yaml"), text, &|| false).unwrap(); + let names: Vec<_> = keys.iter().map(|k| k.name.as_str()).collect(); + assert_eq!( + names, + [ + "app", + "app.time-out", + "app.hosts", + "app.hosts[0].url", + "app.hosts[1].url", + "app.map", + "app.map.east", + "app.map.east.size", + "app.name", + "😀", + "😀.port" + ] + ); + assert_eq!(keys.last().unwrap().range.start.col, 7); + } + #[test] + fn aliases_and_values_are_not_configuration_keys() { + let keys = Syntax::new() + .configuration_keys( + &LanguageId::new("yaml"), + "base: &base {size: 1}\napp:\n <<: *base\n text: |\n fake: key\n", + &|| false, + ) + .unwrap(); + assert_eq!(keys.iter().map(|k| k.name.as_str()).collect::>(), ["base", "base.size", "app", "app.text"]); + } + #[test] + fn properties_unicode_ranges_and_eof_continuation() { + let keys = properties("app.中文=1\nlast\\\n"); + assert_eq!(keys[0].range.end.col, 6); + assert_eq!(keys[1].name, "last"); + } + #[test] + fn properties_escapes_continuations_and_comments() { + let keys = properties( + "# comment\r\napp.time\\\n -out : 1\napp\\.name=ok\napp.\\u0061=2\nspace\\ key value\n!ignored=x\n", + ); + assert_eq!( + keys.iter().map(|k| k.name.as_str()).collect::>(), + ["app.time-out", "app.name", "app.a", "space key"] + ); + assert_eq!(keys[0].range.end.line, 2); + } +} diff --git a/crates/ide-language/src/syntax/mod.rs b/crates/ide-language/src/syntax/mod.rs index 5e22c169f..c578dbba8 100644 --- a/crates/ide-language/src/syntax/mod.rs +++ b/crates/ide-language/src/syntax/mod.rs @@ -1,6 +1,7 @@ //! Tree-sitter driven structure: outline, folding, symbol tags, selection ranges, highlighting. mod cache; +mod configuration; mod folding; mod grammars; mod highlight; @@ -12,6 +13,7 @@ mod web; use crate::{FoldRange, LanguageConfig, LanguageRegistry, SymbolTag}; pub use cache::{MAX_CACHED_TREES, SyntaxCache, SyntaxCacheStats}; +pub use configuration::ConfigurationKey; use grammars::{SPECS, Spec}; pub use highlight::{SyntaxHighlightKind, SyntaxHighlights}; use ide_core::{LanguageId, OutlineItem, Pos, Range}; diff --git a/crates/ide-lsp/src/jdtls.rs b/crates/ide-lsp/src/jdtls.rs index 82a52ef41..8198a443c 100644 --- a/crates/ide-lsp/src/jdtls.rs +++ b/crates/ide-lsp/src/jdtls.rs @@ -18,6 +18,7 @@ use std::time::{Duration, Instant}; const SERVER: &str = "jdtls"; +pub(crate) mod configuration; mod formatter; pub use formatter::configure_formatter; /// First start imports the Maven / Gradle model, which can take minutes. diff --git a/crates/ide-lsp/src/jdtls/configuration.rs b/crates/ide-lsp/src/jdtls/configuration.rs new file mode 100644 index 000000000..b11260ff4 --- /dev/null +++ b/crates/ide-lsp/src/jdtls/configuration.rs @@ -0,0 +1,416 @@ +//! Bridges configuration syntax to the app-owned JDT extension. No additional +//! language server, build, or network request is needed. Disk parsing runs on a +//! blocking worker; open buffers always win over cached disk contents. +use super::*; +use crate::text::Encoding; +use crate::types::{References, ReferencesIncomplete}; +use crate::{DocSnap, Target}; +use ide_core::{Location, Pos}; +use ide_language::{ConfigurationKey, Syntax}; +use parking_lot::Mutex; +use std::collections::HashMap; +use std::sync::OnceLock; +use std::time::SystemTime; + +const COMMAND: &str = "openidea.java.configuration"; +const MAX_BYTES: u64 = 2 * 1024 * 1024; +const MAX_FILES: usize = 2000; +const MAX_CACHED_KEYS: usize = 100_000; + +#[derive(Default)] +pub(crate) struct ConfigurationCache { + files: HashMap, +} +struct CachedFile { + stamp: (SystemTime, u64), + keys: Vec, +} +#[derive(Deserialize)] +struct Pattern { + name: String, + relaxed: bool, +} +#[derive(Deserialize)] +struct Response { + #[serde(default)] + locations: Vec, + #[serde(default)] + keys: Vec, + #[serde(default)] + roots: Vec, + #[serde(default)] + incomplete: bool, +} +fn syntax() -> &'static Syntax { + static SYNTAX: OnceLock = OnceLock::new(); + SYNTAX.get_or_init(Syntax::new) +} +fn language(path: &Path) -> Option { + match path.extension()?.to_str()? { + "yaml" | "yml" => Some(LanguageId::new("yaml")), + "properties" => Some(LanguageId::new("properties")), + _ => None, + } +} +fn parts(key: &str) -> Vec { + let mut out = Vec::new(); + let mut part = String::new(); + let mut chars = key.chars().peekable(); + let mut bracket = false; + while let Some(ch) = chars.next() { + match ch { + '[' => { + if !part.is_empty() { + out.push(std::mem::take(&mut part)); + } + for ch in chars.by_ref() { + if ch == ']' { + break; + } + if !matches!(ch, '-' | '_') { + part.extend(ch.to_lowercase()); + } + } + out.push(std::mem::take(&mut part)); + bracket = true; + } + '.' => { + if !bracket { + out.push(std::mem::take(&mut part)); + } + bracket = false; + } + '-' | '_' => {} + ch => { + part.extend(ch.to_lowercase()); + bracket = false; + } + } + } + if !part.is_empty() { + out.push(part); + } + out +} +fn matches(pattern: &Pattern, key: &str) -> bool { + if !pattern.relaxed { + return pattern.name == key; + } + let (a, b) = (parts(&pattern.name), parts(key)); + a.len() == b.len() && a.iter().zip(b).all(|(a, b)| a == "*" || *a == b) +} + +impl LspManager { + pub(crate) async fn configuration_navigation( + &self, + snap: &DocSnap, + pos: Pos, + usages: bool, + include_declaration: bool, + cancel: &CancellationToken, + ) -> IdeResult> { + if cancel.is_cancelled() { + return Err(IdeError::cancelled()); + } + let config = language(&snap.path).is_some(); + if !config && snap.language.as_str() != "java" { + return Ok(None); + } + // Definition in ordinary Java code remains JDT's definition. Reverse + // configuration navigation is offered on string expressions; field/config + // accessor usages are merged into the ordinary Find Usages response. + if !config && !usages { + let text = snap.text.as_str(); + if !text.contains("Value") && !text.contains("getProperty") && !text.contains("getRequiredProperty") { + return Ok(None); + } + } + let key = if config { + let text = snap.text.as_str().to_owned(); + if text.len() as u64 > MAX_BYTES { + return Ok(None); + } + let lang = language(&snap.path).expect("configuration language"); + let cancelled = cancel.clone(); + tokio::task::spawn_blocking(move || { + syntax() + .configuration_keys(&lang, &text, &|| cancelled.is_cancelled()) + .ok()? + .into_iter() + .find(|key| key.range.start <= pos && pos < key.range.end) + .map(|key| key.name) + }) + .await + .ok() + .flatten() + } else { + None + }; + if config && key.is_none() { + return Ok(None); + } + if config { + // Avoid starting a JVM for unrelated YAML (CI, Kubernetes, etc.). + let path = snap.path.clone(); + let root = self.project_root.clone(); + let java_project = tokio::task::spawn_blocking(move || { + path.ancestors().take_while(|p| p.starts_with(&root)).any(|p| { + ["pom.xml", "build.gradle", "build.gradle.kts", ".classpath"] + .iter() + .any(|name| p.join(name).is_file()) + }) + }) + .await + .unwrap_or(false); + if !java_project { + return Ok(None); + } + } + let result = self.configuration_query(snap, pos, key, usages, include_declaration, cancel).await; + match result { + Err(e) if cancel.is_cancelled() || e.kind == ErrorKind::Cancelled => Err(IdeError::cancelled()), + Err(e) => { + tracing::debug!("Configuration navigation unavailable; retaining native language navigation: {e}"); + Ok(None) + } + result => result, + } + } + + async fn configuration_query( + &self, + snap: &DocSnap, + pos: Pos, + key: Option, + usages: bool, + include_declaration: bool, + cancel: &CancellationToken, + ) -> IdeResult> { + let (server, run) = self.jdtls(&snap.path, cancel).await?; + if !run.caps.read().commands().iter().any(|command| command == COMMAND) { + return Ok(None); + } + let target = Target { server: server.clone(), run }; + let position = snap.text.ide_to_lsp(Encoding::Utf16, pos); + let response: Response = self + .call( + &target, + "workspace/executeCommand", + json!({ + "command": COMMAND, "arguments": [snap.uri, key, position.line, position.character, usages, include_declaration] + }), + Some(cancel), + crate::LONG_TIMEOUT, + ) + .await?; + if cancel.is_cancelled() { + return Err(IdeError::cancelled()); + } + if response.keys.is_empty() { + return Ok(response + .incomplete + .then(|| References { incomplete: Some(ReferencesIncomplete::FilesSkipped), ..Default::default() })); + } + let mut result = References { + locations: response.locations.iter().filter_map(|l| self.conv(Encoding::Utf16).location(l)).collect(), + incomplete: response.incomplete.then_some(ReferencesIncomplete::FilesSkipped), + ..Default::default() + }; + self.note_virtual(&server, result.locations.iter().map(|l| l.uri.as_deref())); + if key.is_none() || usages { + let roots: Vec = response.roots.iter().filter_map(|uri| super::project_path(uri)).collect(); + let overlays: HashMap = self + .docs + .lock() + .values() + .filter(|d| language(&d.path).is_some() && d.text.as_str().len() as u64 <= MAX_BYTES) + .map(|d| (d.canonical.clone(), d.text.as_str().to_owned())) + .collect(); + let cache = self.configuration_cache.clone(); + let cancelled = cancel.clone(); + let keys = response.keys; + let files = + tokio::task::spawn_blocking(move || configuration_files(&roots, &keys, &overlays, &cache, &cancelled)) + .await + .map_err(|_| IdeError::unavailable("configuration search worker stopped"))?; + result.locations.extend(files.locations); + result.incomplete = result.incomplete.or(files.incomplete); + } + if cancel.is_cancelled() || !self.docs.lock().values().any(|d| d.uri == snap.uri && d.version == snap.version) { + return Err(IdeError::cancelled()); + } + if usages && key.is_some() && !include_declaration { + let source_path = self + .docs + .lock() + .values() + .find(|d| d.uri == snap.uri) + .map(|d| d.canonical.clone()) + .unwrap_or_else(|| snap.path.clone()); + result.locations.retain(|location| { + location.path.to_path_buf() != source_path || !(location.range.start <= pos && pos < location.range.end) + }); + } + crate::convert::dedup_locations(&mut result.locations); + Ok(Some(result)) + } +} + +fn in_module(root: &Path, path: &Path) -> bool { + if !path.starts_with(root) { + return false; + } + for parent in path.parent().into_iter().flat_map(Path::ancestors).take_while(|p| *p != root) { + if matches!( + parent.file_name().and_then(|n| n.to_str()), + Some("target" | "build" | "node_modules" | ".git" | ".gradle") + ) { + return false; + } + if ["pom.xml", "build.gradle", "build.gradle.kts", ".classpath"].iter().any(|name| parent.join(name).is_file()) + { + return false; + } + } + true +} + +fn configuration_files( + roots: &[PathBuf], + patterns: &[Pattern], + overlays: &HashMap, + cache: &Mutex, + cancel: &CancellationToken, +) -> References { + let mut result = References::default(); + let mut paths = std::collections::BTreeSet::new(); + // Do not scan a source root twice when its enclosing module is also present. + let roots: Vec<_> = + roots.iter().filter(|root| !roots.iter().any(|other| other != *root && root.starts_with(other))).collect(); + for root in &roots { + let module = (*root).clone(); + let walker = ignore::WalkBuilder::new(root) + .follow_links(false) + .filter_entry(move |entry| { + if !entry.file_type().is_some_and(|t| t.is_dir()) { + return true; + } + if entry.path() == module { + return true; + } + if matches!(entry.file_name().to_str(), Some("target" | "build" | "node_modules" | ".git" | ".gradle")) + { + return false; + } + // A sibling application's identical key must not leak into this module. + !["pom.xml", "build.gradle", "build.gradle.kts", ".classpath"] + .iter() + .any(|name| entry.path().join(name).is_file()) + }) + .build(); + for (visited, entry) in walker.enumerate() { + if cancel.is_cancelled() { + return result; + } + if visited >= 50_000 || paths.len() >= MAX_FILES { + result.incomplete = Some(ReferencesIncomplete::TooManyFiles); + break; + } + match entry { + Ok(entry) if entry.file_type().is_some_and(|t| t.is_file()) && language(entry.path()).is_some() => { + paths.insert(entry.into_path()); + } + Err(_) => result.incomplete = Some(ReferencesIncomplete::FilesSkipped), + _ => {} + } + } + } + paths.extend(overlays.keys().filter(|p| roots.iter().any(|r| in_module(r, p))).cloned()); + let mut cache = cache.lock(); + if cache.files.len() > MAX_FILES { + cache.files.clear(); + } + for path in paths { + if cancel.is_cancelled() { + return result; + } + let lang = language(&path).expect("configuration file"); + let keys = if let Some(text) = overlays.get(&path) { + syntax().configuration_keys(&lang, text, &|| cancel.is_cancelled()).ok() + } else { + (|| { + let metadata = std::fs::metadata(&path).ok()?; + if metadata.len() > MAX_BYTES { + return None; + } + let stamp = (metadata.modified().ok()?, metadata.len()); + if let Some(file) = cache.files.get(&path).filter(|f| f.stamp == stamp) { + return Some(file.keys.clone()); + } + let text = std::fs::read_to_string(&path).ok()?; + let keys = syntax().configuration_keys(&lang, &text, &|| cancel.is_cancelled()).ok()?; + cache.files.remove(&path); + if cache.files.len() >= MAX_FILES + || cache.files.values().map(|file| file.keys.len()).sum::() + keys.len() > MAX_CACHED_KEYS + { + cache.files.clear(); + } + cache.files.insert(path.clone(), CachedFile { stamp, keys: keys.clone() }); + Some(keys) + })() + }; + match keys { + Some(keys) => result.locations.extend( + keys.into_iter() + .filter(|key| patterns.iter().any(|p| matches(p, &key.name))) + .map(|key| Location::file(path.clone(), key.range)), + ), + None => result.incomplete = Some(ReferencesIncomplete::FilesSkipped), + } + if result.locations.len() >= MAX_FILES { + result.locations.truncate(MAX_FILES); + result.incomplete = Some(ReferencesIncomplete::TooManyFiles); + break; + } + } + result +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn file_cache_tracks_edits_overlays_removals_and_limits() { + let directory = tempfile::tempdir().unwrap(); + let root = directory.path().to_path_buf(); + let file = root.join("application.properties"); + std::fs::write(&file, "app.port=1\n").unwrap(); + let patterns = [Pattern { name: "app.port".into(), relaxed: true }]; + let cache = Mutex::new(ConfigurationCache::default()); + let cancel = CancellationToken::new(); + let query = |overlays: &HashMap| { + configuration_files(std::slice::from_ref(&root), &patterns, overlays, &cache, &cancel) + }; + assert_eq!(query(&HashMap::new()).locations.len(), 1); + std::fs::write(&file, "app.other=12\n").unwrap(); + assert!(query(&HashMap::new()).locations.is_empty()); + assert_eq!(query(&HashMap::from([(file.clone(), "app.port=unsaved\n".into())])).locations.len(), 1); + std::fs::remove_file(&file).unwrap(); + assert!(query(&HashMap::new()).locations.is_empty()); + std::fs::write(&file, "x".repeat(MAX_BYTES as usize + 1)).unwrap(); + assert_eq!(query(&HashMap::new()).incomplete, Some(ReferencesIncomplete::FilesSkipped)); + cancel.cancel(); + assert!(query(&HashMap::new()).locations.is_empty()); + } + + #[test] + fn relaxed_binding_keeps_boundaries() { + let pattern = Pattern { name: "app.clients[*].timeOut".into(), relaxed: true }; + assert!(matches(&pattern, "app.clients[2].time-out")); + assert!(!matches(&pattern, "app.clientsTimeOut")); + let map = Pattern { name: "app.routes.*.url".into(), relaxed: true }; + assert!(matches(&map, "app.routes[us.east].url")); + assert!(!matches(&map, "app.routes.us.east.url")); + assert!(!matches(&Pattern { name: "app.time-out".into(), relaxed: false }, "app.timeOut")); + } +} diff --git a/crates/ide-lsp/src/lib.rs b/crates/ide-lsp/src/lib.rs index d8ead994b..45bef903a 100644 --- a/crates/ide-lsp/src/lib.rs +++ b/crates/ide-lsp/src/lib.rs @@ -179,6 +179,7 @@ pub struct LspManager { shutting_down: AtomicBool, execution_allowed: AtomicBool, java_usages_warned: AtomicBool, + configuration_cache: Arc>, /// `window/showMessage` texts already surfaced; servers repeat them on every restart/reconfigure. shown_messages: Mutex>, /// Virtual (non-`file:`) URIs a server returned, by the server that owns them. @@ -313,6 +314,7 @@ impl LspManager { shutting_down: AtomicBool::new(false), execution_allowed: AtomicBool::new(true), java_usages_warned: AtomicBool::new(false), + configuration_cache: Arc::new(Mutex::new(Default::default())), shown_messages: Mutex::new(HashSet::new()), virtual_origins: Mutex::new(HashMap::new()), virtual_docs: Mutex::new(HashMap::new()), @@ -1122,6 +1124,15 @@ impl LspManager { } pub async fn goto(&self, doc: DocId, pos: Pos, kind: GotoKind) -> IdeResult> { + if matches!(kind, GotoKind::Definition) { + let snap = self.snapshot(doc)?; + if let Some(result) = + self.configuration_navigation(&snap, pos, false, false, &CancellationToken::new()).await? + && !result.locations.is_empty() + { + return Ok(result.locations); + } + } let (method, cap, feature) = goto_method(kind); let (snap, targets) = self.doc_targets(doc, feature, |c| c.has(cap)).await?; self.goto_in(&snap, &targets, method, pos, None).await @@ -1200,6 +1211,36 @@ impl LspManager { include_declaration: bool, project_wide: bool, cancel: CancellationToken, + ) -> IdeResult { + let snap = self.snapshot(doc)?; + // Scoped probes back code lenses and bulk analysis. Keep their native cost; + // configuration indexing is reserved for explicit, project-wide usages. + let configuration = if project_wide || snap.language.as_str() != "java" { + self.configuration_navigation(&snap, pos, true, include_declaration, &cancel).await? + } else { + None + }; + if snap.language.as_str() != "java" + && let Some(result) = configuration + { + return Ok(result); + } + let mut result = self.native_references(doc, pos, include_declaration, project_wide, cancel).await?; + if let Some(configuration) = configuration { + result.locations.extend(configuration.locations); + result.incomplete = result.incomplete.or(configuration.incomplete); + convert::dedup_locations(&mut result.locations); + } + Ok(result) + } + + async fn native_references( + &self, + doc: DocId, + pos: Pos, + include_declaration: bool, + project_wide: bool, + cancel: CancellationToken, ) -> IdeResult { let (snap, targets) = self.doc_targets(doc, "find references", |c| c.has("referencesProvider")).await?; // The extension preserves method identity before JDT collapses references to locations. @@ -1212,7 +1253,7 @@ impl LspManager { let t = &targets[0]; let extension_error = Mutex::new(None); let query = || async { - if cancel.is_cancelled() || !self.docs.lock().get(&doc).is_some_and(|d| d.version == snap.version) { + if cancel.is_cancelled() || self.docs.lock().get(&doc).is_none_or(|d| d.version != snap.version) { return Err(IdeError::cancelled()); } let params = snap.pos_params(t, pos); @@ -1240,7 +1281,7 @@ impl LspManager { *extension_error.lock() = Some(e.clone()); } let rows = result?; - if cancel.is_cancelled() || !self.docs.lock().get(&doc).is_some_and(|d| d.version == snap.version) { + if cancel.is_cancelled() || self.docs.lock().get(&doc).is_none_or(|d| d.version != snap.version) { return Err(IdeError::cancelled()); } let conv = self.conv(t.enc()); @@ -1266,7 +1307,7 @@ impl LspManager { scope::Widened::only(first, empty, &t.server) }; // A changed source also invalidates an earlier answer retained by scope widening. - if cancel.is_cancelled() || !self.docs.lock().get(&doc).is_some_and(|d| d.version == snap.version) { + if cancel.is_cancelled() || self.docs.lock().get(&doc).is_none_or(|d| d.version != snap.version) { return Err(IdeError::cancelled()); } // Scope widening can retain the first answer after a failed second query. @@ -1292,7 +1333,7 @@ impl LspManager { Err(e) => { if e.kind == ErrorKind::Cancelled || cancel.is_cancelled() - || !self.docs.lock().get(&doc).is_some_and(|d| d.version == snap.version) + || self.docs.lock().get(&doc).is_none_or(|d| d.version != snap.version) { return Err(IdeError::cancelled()); } diff --git a/crates/ide-lsp/tests/mock_server.rs b/crates/ide-lsp/tests/mock_server.rs index a473e8cbc..b33110a20 100644 --- a/crates/ide-lsp/tests/mock_server.rs +++ b/crates/ide-lsp/tests/mock_server.rs @@ -2916,3 +2916,92 @@ async fn clang_tidy_config_for_a_later_opened_file_warns_on_a_running_server() { assert_eq!(h.mgr.servers().len(), 1); h.mgr.shutdown_all().await; } + +#[tokio::test(flavor = "multi_thread")] +async fn configuration_definition_uses_java_without_yaml_definition_capability() { + let mut caps = incremental_caps(); + caps["executeCommandProvider"] = json!({"commands": ["openidea.java.configuration"]}); + let h = harness_for("java", "java", &[("jdtls", caps)], &[]); + std::fs::write(h.root.join("pom.xml"), "").unwrap(); + let s = h.launcher.state("jdtls"); + let target = uri_of(&h.root.join("Settings.java")); + s.respond("workspace/executeCommand", json!({ + "locations": [{"uri": target, "range": {"start": {"line": 2, "character": 4}, "end": {"line": 2, "character": 11}}}], + "keys": [{"name": "app.timeOut", "relaxed": true}], "roots": [] + })); + let path = h.root.join("application.yml"); + let doc = DocId::next(); + h.mgr.did_open(doc, &path, &LanguageId::new("yaml"), 1, "app:\n time-out: 5\n"); + let locations = h.mgr.goto(doc, Pos::new(1, 4), GotoKind::Definition).await.unwrap(); + assert_eq!(locations.len(), 1); + assert_eq!(locations[0].range.start, Pos::new(2, 4)); + assert_eq!(s.requested("workspace/executeCommand")[0]["arguments"][1], "app.time-out"); + assert!(s.requested("textDocument/definition").is_empty()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn configuration_usages_merge_native_and_unsaved_profiles_without_sibling_modules() { + let mut caps = incremental_caps(); + caps["executeCommandProvider"] = json!({"commands": ["openidea.java.configuration"]}); + let h = harness_for("java", "java", &[("jdtls", caps)], &[]); + let s = h.launcher.state("jdtls"); + let (doc, path) = h.open("Settings.java", "int timeOut;"); + h.wait_status("jdtls", ServerStatus::Ready).await; + let root = uri_of(&h.root); + s.respond( + "workspace/executeCommand", + json!({ + "locations": [], "keys": [{"name": "app.timeOut", "relaxed": true}], "roots": [root] + }), + ); + s.respond( + "textDocument/references", + json!([{"uri": uri_of(&path), "range": { + "start": {"line": 3, "character": 0}, "end": {"line": 3, "character": 7} + }}]), + ); + std::fs::write(h.root.join("application.yml"), "app:\n time-out: 1\n").unwrap(); + std::fs::write(h.root.join("application-prod.properties"), "app.time-out=2\n").unwrap(); + let overlay = h.root.join("application-dev.yml"); + std::fs::write(&overlay, "app:\n old: 3\n").unwrap(); + h.mgr.did_open(DocId::next(), &overlay, &LanguageId::new("yaml"), 1, "app:\n time-out: 3\n"); + let sibling = h.root.join("other"); + std::fs::create_dir(&sibling).unwrap(); + std::fs::write(sibling.join("pom.xml"), "").unwrap(); + std::fs::write(sibling.join("application.yml"), "app:\n time-out: wrong\n").unwrap(); + h.mgr.did_open( + DocId::next(), + &sibling.join("application.yml"), + &LanguageId::new("yaml"), + 1, + "app:\n time-out: still-wrong\n", + ); + let scoped = h.mgr.references(doc, Pos::new(0, 6), false, false, Default::default()).await.unwrap(); + assert_eq!(scoped.locations.len(), 1); + assert!(s.requested("workspace/executeCommand").is_empty()); + let refs = h.mgr.references(doc, Pos::new(0, 6), false, true, Default::default()).await.unwrap(); + assert_eq!(refs.locations.len(), 4, "{refs:?}"); + assert!(refs.locations.iter().all(|l| !l.path.to_path_buf().starts_with(&sibling))); + assert!(refs.locations.iter().any(|l| l.path.to_path_buf() == overlay)); + assert_eq!(refs.incomplete, None); +} + +#[tokio::test(flavor = "multi_thread")] +async fn configuration_extension_failure_preserves_native_java_navigation() { + let mut caps = incremental_caps(); + caps["executeCommandProvider"] = json!({"commands": ["openidea.java.configuration"]}); + let h = harness_for("java", "java", &[("jdtls", caps)], &[]); + let s = h.launcher.state("jdtls"); + let (doc, path) = h.open("Settings.java", "@Value(\"${app.port}\") int port;"); + h.wait_status("jdtls", ServerStatus::Ready).await; + s.errors.lock().insert("workspace/executeCommand".into(), (-32603, "old server".into())); + s.respond( + "textDocument/definition", + json!({"uri": uri_of(&path), "range": { + "start": {"line": 0, "character": 1}, "end": {"line": 0, "character": 6} + }}), + ); + let result = h.mgr.goto(doc, Pos::new(0, 2), GotoKind::Definition).await.unwrap(); + assert_eq!(result.len(), 1); + assert_eq!(s.requested("textDocument/definition").len(), 1); +} diff --git a/crates/ide-sdk/src/java.rs b/crates/ide-sdk/src/java.rs index 56d81ecf7..9120b0e5f 100644 --- a/crates/ide-sdk/src/java.rs +++ b/crates/ide-sdk/src/java.rs @@ -1173,7 +1173,7 @@ mod kotlin_bridge_bundle_tests { /// Installs the app-owned JDT LS command bundle, falling back to ordinary LSP on IO failure. pub fn usages_bundle(data_dir: &Path) -> Option { - let extension = data_dir.join("java-usages-1.1.1.jar"); + let extension = data_dir.join("java-usages-1.2.0.jar"); let bytes = include_bytes!("../../../plugins/java-usages/openidea-java-usages.jar"); if std::fs::read(&extension).is_ok_and(|old| old == bytes) { clean_usages_bundles(data_dir, &extension); diff --git a/crates/ide-syntax/src/jvm.rs b/crates/ide-syntax/src/jvm.rs index 9da0f7b46..88e3c7ca6 100644 --- a/crates/ide-syntax/src/jvm.rs +++ b/crates/ide-syntax/src/jvm.rs @@ -159,7 +159,7 @@ impl StructureService { let runnable = body.is_some_and(|b| { members(*b).into_iter().any(|n| { if !matches!(n.kind(), "method_declaration" | "function_declaration") - || !n.child_by_field_name("name").is_some_and(|id| text(id, snap.text) == "main") + || n.child_by_field_name("name").is_none_or(|id| text(id, snap.text) != "main") { return false; } diff --git a/plugins/java-usages/CONFIGURATION.md b/plugins/java-usages/CONFIGURATION.md new file mode 100644 index 000000000..fae42f40c --- /dev/null +++ b/plugins/java-usages/CONFIGURATION.md @@ -0,0 +1,114 @@ +# Spring configuration navigation + +`openidea.java.configuration` bridges YAML / properties to the existing JDT LS +Java model. It is bundled with the Java usages extension; it does not install a +second language server, run a build, change the application's dependencies, or +connect to external configuration services. + +## Editor behavior + +- **Go to Definition** on a YAML / properties key opens binding members and + literal consumers in Java. Multiple matches use the existing location chooser. +- **Go to Definition** inside a Java `@Value` placeholder or a constant + `Environment.getProperty` / `getRequiredProperty` argument opens matching + configuration keys. Ordinary Java definition navigation remains native. +- **Find Usages** joins configuration declarations, property consumers, and Java + references to associated binding members. Java's existing reference results and + read/write classifications are retained. +- Open editor contents take precedence over disk. All matching profiles/documents + are shown; navigation does not claim to know the runtime effective value. +- Missing or older extension bundles preserve native language-server behavior. + YAML aliases still use the YAML server's anchor navigation. + +## Association sources + +The Java command resolves annotation and method identities with JDT bindings: + +- `@ConfigurationProperties` on classes and factory methods, including inherited + members, nested objects, records, constructor-only parameters / `@Name`, arrays, + collections and maps. Binder keys use relaxed spelling while retaining path + boundaries. Indexed collection positions and arbitrary map keys use wildcards. +- `@Value("${key:default}")`, including constant annotation values and nested + default placeholders. +- Literal or compile-time constant keys passed to Spring's `PropertyResolver` + methods, including through `Environment`. Unrelated methods with the same name + and non-Spring annotations are excluded. +- Dependency JAR `META-INF/spring-configuration-metadata.json` and + `additional-spring-configuration-metadata.json`. The closest group, source + type, and source method locate a member or its owning class. Source attachments + and JDT class-file/decompiler navigation use the existing virtual-document path. +- The same metadata files in source roots and `src/main/resources`. + +YAML key ranges come from the bundled tree-sitter grammar, including nested/flow +mappings, sequences, quoted keys and multiple documents. Properties support +escaped keys, Unicode escapes and logical-line continuations. Ranges crossing +IPC are UTF-16. No new IPC type or frontend provider is required. + +## Scope and costs + +The command uses the selected JDT project's resolved package roots, including +source dependencies and its dependency JARs. Configuration search includes those +modules, skips nested independent modules and build output, and honors ignore +files for disk discovery. It does not guess a global association from matching +text in unrelated projects. + +Indexing is lazy. The first explicit navigation/search builds a compact snapshot; +ordinary typing, code-lens probes and bulk scoped reference analysis do not start indexing. At most eight project snapshots are +retained, with a shared 100,000-association retention cap. Records contain property names, element handles and source locations; +ASTs and bindings are discarded after indexing. A lexical prefilter avoids AST +construction for ordinary Java units, but every accepted association is resolved +semantically. Warm requests reuse the snapshot. Java model/reconcile changes +invalidate it conservatively, including dependent-type changes; open working-copy +content fingerprints also prevent stale answers before reconciliation. This is +**snapshot invalidation**, not a fully incremental Java semantic index. + +Metadata/JAR timestamps are checked before reuse. The client caches parsed disk +configuration files by modification time and size (at most 100,000 cached keys), overlays open buffers, and +performs bounded discovery/parsing on a blocking worker. It does not continuously +watch or parse an additional copy of the entire workspace. + +Bounds: 20,000 Java units, 100,000 associations per snapshot, 2 MiB per candidate +metadata/configuration file (Java source: 2M UTF-16 units), 1,024-character property names, 2,000 configuration files/results, 50,000 filesystem +entries per root, and depth limits on recursive bindings/YAML. Java requests and +references are cancellable. File/association scan limits and unreadable files carry the +existing incomplete-result indicator; unsupported syntax keys are omitted. First navigation can take seconds on a +cold project; classpath import and source download remain owned by JDT LS. + +## Deliberate limits + +This is Java/Spring property navigation, not full IntelliJ Spring support. It +cannot infer dynamically constructed names, custom configuration APIs/annotations, +remote-only configuration, arbitrary SpEL evaluation, YAML merge/alias expansion, +or custom Spring converters that change the binding shape. Kotlin is not indexed +by this Java bundle. YAML-only quoted-string escape forms that cannot be decoded +safely are skipped. Properties surrogate-pair escapes are not decoded as keys. +A missing source attachment may navigate to the decompiled owning class when JDT +cannot provide an exact member range. Binding members are navigation candidates; +the feature does not validate whether Spring will successfully bind a bean at +runtime or resolve profile/precedence rules. + +## Verification + +Use the JDT LS distribution / Java version documented in [README](README.md). + +```sh +python3 plugins/java-usages/build.py /path/to/jdtls +python3 plugins/java-usages/tests/configuration.py /path/to/jdtls +OPENIDEA_CONFIGURATION_STRESS=1 python3 plugins/java-usages/tests/configuration.py /path/to/jdtls +OPENIDEA_SPRING_BOOT_JAR=/path/to/spring-boot-autoconfigure.jar python3 plugins/java-usages/tests/configuration.py /path/to/jdtls +cargo test -p ide-language -p ide-lsp +``` + +The isolated real-server harness covers custom bindings, inheritance, nesting, +collections/maps, records, constructor aliases, factory methods, consumers, +constant keys, negative identity matches, dependency metadata/source locations, +reverse queries, and unsaved-change/close invalidation. Optional stress fixtures +add 3,000 ordinary units and 100 binding classes; output includes cold/warm/edit +latencies for the JDT command only, excluding server import, UI and configuration-file discovery. The optional actual Spring Boot JAR checks `server.port`, +`spring.datasource.url` and `spring.jackson.date-format` through dependency +metadata. No user's project is compiled or modified by the harness. + +Rust tests cover configuration syntax/ranges and the actual manager bridge: +YAML navigation without YAML definition capabilities, Java native fallback, +reference merging, profiles, open-buffer overlays and sibling-module exclusion. +Existing Java usages / read-write integration harnesses remain regression checks. diff --git a/plugins/java-usages/MANIFEST.MF b/plugins/java-usages/MANIFEST.MF index 1775ddec8..7ec5b7f32 100644 --- a/plugins/java-usages/MANIFEST.MF +++ b/plugins/java-usages/MANIFEST.MF @@ -2,9 +2,9 @@ Manifest-Version: 1.0 Bundle-ManifestVersion: 2 Bundle-Name: OpenIDEA Java usages Bundle-SymbolicName: ai.openidea.usages;singleton:=true -Bundle-Version: 1.1.1 +Bundle-Version: 1.2.0 Bundle-RequiredExecutionEnvironment: JavaSE-21 Require-Bundle: org.eclipse.jdt.ls.core,org.eclipse.jdt.core, org.eclipse.jdt.core.manipulation,org.eclipse.core.runtime, - org.eclipse.lsp4j + org.eclipse.lsp4j,org.eclipse.core.resources,com.google.gson diff --git a/plugins/java-usages/README.md b/plugins/java-usages/README.md index 5be955109..6f8203215 100644 --- a/plugins/java-usages/README.md +++ b/plugins/java-usages/README.md @@ -1,4 +1,6 @@ -# Semantic Java usages +# Semantic Java usages and configuration navigation + +Spring YAML / properties navigation is described in [CONFIGURATION.md](CONFIGURATION.md). The app-owned OSGi command `openidea.java.usages` returns `{location, kind, access}`. `kind` retains field/getter/setter/unknown identity for compatibility. Optional @@ -55,7 +57,7 @@ is not a claim of full IntelliJ feature parity. ## Rebuild and delivery The checked-in jar is embedded by ide-sdk `include_bytes!`, installed as -`java-usages-1.1.1.jar`, and added alongside existing initialization bundles. +`java-usages-1.2.0.jar`, and added alongside existing initialization bundles. Users need no compiler, Maven or Python. Manifest/install version must change when replacing the bundle because OSGi caches bundles. @@ -64,8 +66,10 @@ Use its shipped ECJ and plugin jars, with Java 25; emitted bytecode targets Java The build uses sorted inputs and fixed zip timestamps and does not resolve Maven artifacts. Check the extracted distribution against `build-dependencies.sha256` from its root (`sha256sum -c /path/to/build-dependencies.sha256`). Build twice -and compare SHA-256. The 1.1.1 output is +and compare SHA-256. The prior 1.1.1 output was `1e569939cba22c7db6e2c9b53de2a4c10ccaabe762a51427119a04af3d974ae4`. +Version 1.2.0 adds the configuration command; its reproducible SHA-256 is +`9140f1d2ecf27eadfe66b40a17daa18a08a65927ebef79456ba55d486ce5eeba`. ```sh export PATH=/path/to/jdk25/bin:$PATH diff --git a/plugins/java-usages/openidea-java-usages.jar b/plugins/java-usages/openidea-java-usages.jar index 0eee593542b614d0580af64f0a9732b3042f96a6..3f521b3a3218b8f52fc4fa0808705773eec01462 100644 GIT binary patch literal 82033 zcmdpf31C#!+4g(Ro#aktZWa;(3~Rt3kQJgvO%T}v1PCBuF`&qh3@{Rsm`PY%YsI>2 ztF|@N4Yf_JRz)QwU{Tu&w$*A|TkZ07|604-)mG8`&wK8@Gk4Z3>i7NsuVm)Vxo2PA z{k-q0WtwXwng30`VD;~Rbo~v2i2pdXu4c`=(%Kb^%IoH>s9jW3zox8iQJudfxHS;! zC|wt54+UFWstRV6&GgLgY-wr^l-e)D?<(v!EBreHRRt^C0xh)*YvvU!_3!c*bcX!f z0wFs`{ob8hTAPE7W=?;wtQDDqO#y$InXft&Y}wWv=s=U_bhLK{>~i6@W|dV$da^oj zNoTM<&{VT0(Ae35e%7??3bwbl>0Jo=?Lx<dS94z-Bn6!{+}d<<2|5LH zS8mN|Ip!4oX%Cb)weBpRSHEUmFcj=)Z5Nc@u{{_noQ1}g53jXqLGE4t=EBaFV27Y- z%M-R*ivhO<&81n3j3|^oJBWulg0@T_Zp(vgYsNZ3t_7`4feaFqX;2!a^Ha7#I=MZh z(FlVwC|8g+owH^y54HqWbne^|XkX*s(j36}+S&tZ!URp7p14tKT34Wz>KW@YXf*kB z8Y8H3c#~?-SQ;lt-`R>e;$EZv;(`?m$#M&Kl74=_#_-D*#p(;1zWi+mO1ab^i7pF$FwSOr<)Nq&!^V0T@(9 z<$}gwZCaam1tLw)54JR+PlC#)&xl&rs1er26{z;oEILi63hal3$BcJjd@+Mgr!xeN z*cRxBRJmqv8+P`LlpWwaR$tNdnRK>JXJM(5H|;23P!-{n=b+K^I{nSTt-(MOqZmOW z(dNa#L*>o>mTl$r9qpKkYB!xDXaQ<23$<~=)^_CG-P*pRY)flv2XH}4M_^A!Sz9}x zYkNm95Gsp;r#J#ux`)oCc{hG@lj#@Bl^mn?r)~r#t$wa?8bm#t!U8H`O2y zPW;y39zIEn4O&9A01N)`!mjf-cVZy*DfV3aDpWffvj&}D9=Q9<3|dZgIQ_v;&Ca%t zy$Ud)Un}{9ano)(50nCL#(IO+&{{zmnC4afb|6_ng{jsQpkKi#oz@GQnRF%RZ3%U> z`x`rWK^Fpt_)R3{p>@LWGSZwju)!HqADQ(X{>B}3 z{x(Im>GYr=<`a^Vzzhcmpa2x$sBPKR+8Ci#zCd5psXNs+G=WB>m_ZLSq#0=e%qW<` z=kgI=N?^b*3-V%`EnK$OO^*x8LxRmy*)O=vL4%&Ok-7=lfHW5{58!8|gJ>r4zCcF|I?5+8 z!{5HGlfht!&)qWqIA+kZ^c;X3M$34))`=OzN93!ic5DPnc<6chxRuBSmS z)3<@3+x+cZPf?P|i(`a%=Nh#wErE8mdI6ji-Qo%XcvgVpU(y=t0AY?Ef;E&mIn2Kh z<>WH=f)pKp86XC*dGIuiq{^3FRD6hxqLM?DRdkqg4&oIy6F$@NO=1*2M^e63=oEZQ zyz>@4O~%tSs+Y#c78s2zQ;0KiE0d6&3+P3~Jyhs=6pzl@$EeKH2bbBWGS3)X=CNoN ze1e%-PR3NAJ7N`jQB+rQf?Nkn`e>%0KB^S7qBM7Q56$VK`$}>b_R^v%m(O*aygpYC zE$t$2N$v_O@dW9)tEyb6WOY@VFYS3+*F~rJ(w?JrRp~qZYG@zAat!I_KL`rTg?AYC29YTKV}`AA!Q}p`B4lmk1mJM?Ie{&FxT~yw&bcaaCIJ z^R%mr@`|6MT~+Da_MWP=ZaN>ymv_;5NWR=`@X9{=xS*V5Z4xoib@}4A*PP5*l89JTI|J)~;y?Wq3wD>(LQfUwL(>Tr~ zbSg$xD<3_8ZGSobZXhb_$M#Q8kc0k%A@Ht!$l6;+$i*jz_F|WP| zn4A4H!us`?nyIz;^#tBMWi3}P9qOXFDB0IX&tT^0bbi+Q=qr+*qvuzY_R!aOhdoc{ zcF`E0>p42NDoxERe`ewJadl42d7X&9lQ;wOT1ivInY2ipMQg;_n8zxRRkPLncuAh5 zsXA?cm(D}azo8c8oAfPwy8yRdqF3;gi-j5u@ck+T1~}O2zwh8v(u8P1)!xjzpS*q^ z5&z9bhM`FYY(Z7Dm`Wa0Vya=vIFyje!K9gR9^h|SLJyP!N@)jY2+q%ee-wcSq*EW< z*5U`v3)s9Q=Jo8M%Aab`(BA4fAdAwr0#TsFY)6%#K=9_G=#<&!&z4|oITtBkL8Bl#dp+YxADW)RcDbcV`1*#O)&xYQ|1 z9G6Jw_5m3l4^3wfG9#fJpqNEC>CJ(bZ5`Xy_-eh7)y&jcTocCv%;LvXg!Laa+#ZjF(Ag)Q-eVnPOOVeyEkqt#v>L5l}Voz{X9 zPqEHA0rVu6g)9Y?Tn7%Txn@seppAz#EaKQh6%;h+V#-5tC71S4Gqvcn6A;JQvnbIi z*g_VYVD1de-O}at%l#doq&;*Ag>(WNKP6RB2+~3U2unIxGLq2}*li|Yq|BHMToN4i zCA8b1Jxl;*gs`%Tr{L5}4Z4gj2U&qsi~F*KOW0tp={PqYx{^Mo(^Z1bj%_J^i805^ zAw=zjKCeE|zAMnas=0Gp5F(?inJ{E#vR3gujaa6063AEvT_c8cCG9ilTKa?uR@Or_ z%N%EG+uoJC!MvmpCC6q}({sH6+7058JWNDvhUDB+Y)+_Jcvf#V=oX$;uFRg!)q%!V zOvqk-f15#{q)%Zj8ruWd2-qEhRz@Fz0hAjlSKSh*-jPaz6C=`Kgz#yDa%hB3pZx%e zJ`a6{x)^MOnExNpqj?~xxZ9w6=w6^teO(T{nMkK^w_J9FW{y_%BElq(v zD_WO2wu_~0ek16VHb|$0)F$bL0_Ur_^frZ+#36m1$7L&;;1yt1a3(V;gOvW`uliK|c&8#8@XdvGTZ)2;xgf5o6AQMc7I3_+C$MU9WrG$pjzToMs7L*;Ll50#*6k1gTMu$DM)nMJ3{M&Ov5GIHbBcS zqnBKAPZ{^}6$6YCnn*`4mjxif{4Oof>3g6=)JCzVm0S6~K|ioa1f_)G;%^xAL;BGN z5a$c}36n&pa%Dd?=x6kEpaS&8l;8!LOi>b0LCN$P8&m9vC=nvy=`ZQmI{nHfb=5Ew zfez&V-{8F|9j7AT2g)p)zR__eY@L^bRWUQNRu1{AVbnlx8uUB*J&ponP*5~c0`RS! zEsc!Z%U8kXBM2#J0HW|e(w}wulOP8h5LK-~f1$U~DKJ8yReKB^+s z*53^JJN*OIfxrPjf(ec4p(WriqkkFnHvM~W!u)sWq)zXms>rs82=m{g_c0r(g!$mW zl2AoTK^wJtUg{Up5SnnA`zoTL#Wt9kdK*OY+8U}{wEQMkK1n!=spdd!@UbWuXl}w>m!;k|>-_COHf*sc z8Zsm?4X0d8haxZmYI2^x1jm!NTah7(dAnt_w6+A)e&bmwHAESNe&o8iH3;%Qq4F|r zJ0(Ec*kSYrZ?g(RoF-0>*_@0XG3cVygLWcO5CNjn5NC?Bu%0adYk?*~tqDe+0t7qj zOPtNgkS9=Dd+8lfWr%8pWGwZWE?@hTz#z)bY=|W+OL7N8U`{)NU?Z94+Q3~|W{Bk$%}~F$rDJ;luQ`2% zAyzVqwe^xn!`k7+YD3iX$>bM2dAy$U`D2|S)^kmn!H`nZw0E+?ncu8%vsg<5RnI37 z?C_Vwg$#uf5q55I5wfCgF6cMJ7H%tA)9n#WVyiB|3z>o}1;m(!jW!$wg6Fq}S5g|i#DB#l`*a@yi7rQWq1fW$h2fC@%BU${v%S4_w`o~-&Hvz`OF=A)s}1pSbr6FgML6Zc zfN~!%`L%*hP0+^#>zWH;{yv7x*BjynUR*Z%unazUzcbLe$)JPuq+8r#QJiH=@{}pb zXoa}V5T6v_Qa>6EY%Zmn-MiBepJo`!WH$F}o~@0ALQ(8@2;qO`c?iCM-FX-a)&=NZ(E%7-G(Jj8H z2uv2hW9DoE6T*B>srHC3iAQzu2pC+2&a8Apd|5oE%9(u%+m1wQ0eHMm7~&xBKMy#n zJ*EJUlbpVn{V#e^58D8<6_^K|Rk7C1cC=Bs6@pS<8woXv> zQN)AAxH#Z#G%KSZyOpGik{rUYb91Hp`=H=D88wSZ`c4ToP`$_VE0?%C54$* z2e!eoyxrUYHjxz0th6~6JPs#D8i9aV7vBcvQoT@Y&ZWV&@(sZ@uFor8<$d@ao81Uk zz_ohB%i?ZZkHDJ0bqbMsRBgqB?lt97?{12!SBAXBns ze{<=;MIgTyKQhFR#ZMsEZruv~g%b=8v;b<1iuKBc-gqC6;%A2Vx%dTO2ZKdcpfQ*0 zSV6Fy5ZqKdiA`&gOm&+_{7U?{E`AMZP24o99ESLf_^k;8>Y=PrGX14#N+VhTnLN7i z2SfZ(fH7srT-$}oF2Hq84sTYLbb~WDCb)b{w_Arn<%A;fBr%#dDPdZ1QGkXe{lCF2~v% zO+h!ui}A@wdBuTPh*g&pagaC8*JVMH^|goU30EC*%Ts{Awzao*wuL-$lALPDQ(5$; zfnVX@(+xR87FmJ|wLpq`iX14_)$qwqv|_42wJxEn@g;rlk;OEMKmO&GGg0?+ z0SG`qB-{n@Vpg_66*ck_L$~kRO-VfTG}|VI>KA$=Q9Yq*=LX&=M+K-rBmOv#mNF zL^x8S3Mv>mte0k!&ZgokcA^05?E-&ub3LvORA;BlD|2WeBmnX{L$=6PBzz)AwpwX7 z8!{w2kakm!?6A^4WyrnqQlx#7#koUTxEy(hM}9`$rAug*3*+YBL^{|#5MeDc?{+}Y zA-e?K_0i9{h4Gw#-6Girn7>cE<=wy@Lb&Ce#KPVAVR^*o8T0J%tEJv1y zIBOa3Pcp;mG)3L(^5LQD-kD_sJt~|{Gy{Y2G-4E579V0PQc@&LwExt@+qb%c%drmBJ5$9pT3WJ`)MqmiS?OilWW|L z{%{Y^fobft5iWX=M!@!UTpyK!w>|v;WfZv%(HRF}c{_pAkJ6b9#W1ry8~QTWVLG>u z=1W>{8Xt3WSzMWL;Ua4qjE&Q&25D)ejY*q;2WarERv*F~pkLIOx>dYgvY+ycbC>tf ziv461=dQ*>7i`AEeaT%@0z2Jx_}kb^o3wD3x+!D5*>yB|7B$cX)P$+yK8`|8>97Tx5}o zun|aPL710KSt199QzLK@IhUr0JeX~dgb8Cl?pltbrQ%$4;lA)RK8P-{b+lj2(EXLJ zZmQ3BrERWE>!$R5X(B(3=l22f73af7{Gptx`zXtLyyE`;U)Znnii)`u=lLSf=A)>N z6ME>eN>{$??*7Lj3p|z^6!Sr*E}#)&A&mBGaLI8#Z5E5w1m;lsNxVD>$)lY34vpcf z2aum)>dhVh6z{t94(vMIY|yq_vx)6B5xeSXHqy?=LmxjFcz8w-FE^f^s}^x}TX#M4W7;vD&|9OFUCEk#a9ZTjdt5;eFEV*Ib|$9)Rkv#+yh|BvzTUxIq+ z7f0x~gp7ZX_<%rwt@iuqpKOocP5FuU3`aJpGiYmjW=X?7^mZXNNoMV;QT4h!@o#D&L&#I`( z^kw#mTx(!?Vswx2b2@Ra9GT#(n~NC;#2$rtu<5`$11&ArKg*A_>6o{MO6kaB#3j-53z7Rt>GU_ zdDWKsbbhW2F9K$!ve-&O@2WD3e3|@nmDxq}$9Z5S&9rdeJR%wfb2GShp+ z#;PnUO{=KN?xxeY)FxGGx?O6LU1~p#bQao-wz7R$$7zKx3&;PVUeV~wa-q&6B1lw~ zRqV^+vF8-|a<~R_c`#F1YNvM5Xnv7xy^5QHJYED4=zLY=I8EmX!AZyyZMq5$j->vz~q+&ZnP<23W3afYNRwRQ(r-iBR!PgW7MF*dopl zjad68u?af6E3pJOh}Gf_5fpceiy^4qA)bLo?zb4rKST>CgH}jLcF00;iJXIeo-abu zFFL>rb%LSUC9e~^<$ke8J|p(Zm&IlByW(>BCd5QMyPqD<5a!_Jq*IZrpB>{Zvmz>ipT9jz#wdi8UXmwnwxDLOz(uYKE^@nB8pZCw!37dImUcf~&u>0M za5Unou&U~&5Xyfxu6$Qqc>t;f}vj`7BnEP>)EZstpqw&Nj637xb z(C&R03RHn$!(|7kL9c`E;CkFtxPg|78{uf+Cb~%6OnbyFfB?6`-t{)RPka)v{!{b| z4DAo%PKE7p6C`c=Nm`C&JhvP*eNNnM(o5PsAWcBWiZ5CizPL}^1C$^h1k@J~ufR{- zl-*Bdcz9CKahhsA^obt+HQMnjTn052l$Q32qkZC8jnpi|6W`AhulI@{hSB=h7;-AKJ{>EP?$eKm|049~r;N-_Fyc7i%jglm z94X$s^Ilx7WYV{rZV^>pn0+8W-u<4DcAofikN6AFMb@!A@y~}qigQk1=5g`^QHXza zQ^Qf3-r&o6QvBzz=GM8|`n@N}1N08kVT8}?Gd_=B z`09~acwOZ4l=R44#sdmVpY2O$T$A1-M|G=flKAOXKYgxeK_a=huCyu_*X8o1ab5W0 zy6~E>yvfmBlz}U6a`dwnnGC=Jo53;I8*dA*`u+xN^rorb)71B+V!dd>?;Ro}Lii4d z+r{nDXJM2LAgl?STNj_jox{688FqnS-A@hTbI5r&T`KOOE^#l+f$zgD>HC4f9soFg z5ZL4)wEG3L^$=SAJlg&OEM2;RMead+598@eyw`xE@=wxIUA#KBpQf2GK-{a#aY(`G zR{vhM!TMwqtjE1q;N~4&{E8^ApHAbuJpc`{wM25+6~CoRFfa_)vIbj1!JK!8sxvW!;i@Mn8prrF; zd9R$shb&K?!B3!EPJm#?&oj-R`fJEt(GtVf>B_4LVeG^Sorzq#4F z(205Ijw0yQ<217m;)Mi#KoxQ9Yl?p5uEF8g`S6R~qROL2XLM7o&j^ps>+|xk_~4tv z!ofdI)0liaA{PLFFbguqmj%KSAcCengd59Qs5KKu-mu2-6pugG=Q)l^@PsEJDlIZk z07QRCFed;RfDw54Z8VLBUh5)V!^)^enV{duP{e20xI`}GKA)glxY_Hu*(I3!<8%c- zs@59>ieaf9LzjCXo~^bXUjq8A)qAN@rf0T?+7 z^6eRs4J(LIu!ATN&%y}rIY^P85%sW8*ajPg%ft)f8rT`!B~FO@VNq}d1_j^5|38EM z!0*IMP?)|98{cosN#YgRAigVih(oedyrziE3ux@fMY=2wUt^Y&7!X0(Ws#M$%|mP_ z>k@R{J2>Dbxx>eNib?&f~Mh`fX*QB+Q zXJTli2J|X8o`Y|a`z_F7KcMGOifLvs43%+$x&&|c z(426<-wWj?lZ=w9L7uW~$Qwh&;i(P_qi3Jb1_7ZpmZ{vl#WTM zFnwa?SpQKL-FZMpWut>6TAlgu;`QYFnxl-n#jO7h-vD8 zd!DZVb#>7b>N|)#RWGUEFRAa9v>AdOAN&=3MtyhDAJn(W`_566=?@?Pd>yxk-+<$> zA3}!pV_doX33Y%Xyi)uWRPWE>Zs(_RgoKKSVRfu4!?<|Fix@1f8tEs*cDMkh1x(djYUv>KahKD z7jrzU&M@~E{9p3L2Z99I5}xGYfki5^Z9nPQWiE|9g_!eZ`XM<_jX#Z~Z>~UHP=i=? ztwlDjs|;0yg<7%`m|(xEKa#dvr7`YuxgynjP@cpZ8Y$M=O_Zo6pjEM&I0ac)e(eQ+ z00~nO>f!Rxnc&mxUL(0A7)8)Fm&+^gy^ajIk2HC$yk6d*o^F)4$XnIZZSr<`r`!+u z_~+E;J&w=&)U)~NOh2H~AC#X*-94Z#jB&>n1sL@lm?3n0w|*&)w|b!*)GvDxT1_AlkH@e`Y7~Ho(_0 zbJ>4Ex@lVER4T_b$yxIau=yFvz-$Bm9%BHmdeQBUR&y+TZ)%(Y`NnubONZ>(5Zsp6>3<`IM&;_3fmLAV!_`C{EfsP%o(GUu5j1` z1@_Q(49tQ|^(?`jX^=`)z8x_Pr7Cuy37E*%uDT!!HMOhkU@ILvABOKr-vmomkh+9l z=%CWGvOaYJelem_UVa!ZW7FYN;SZb`K&XUSMVbd+wv90z7;Pdj zX9q?A3b9U@v$ESR?yFvO1V{$tPIPaviWtts1eqaBh^?73n-0Oa<^l_sx*=0!{28BH z;#f{cF3w$nZz#WLEHuwESjU{jj><+9l^&vzMfnhajW*Spk#WG(2l=5==3>BaO3Vsc z7QajJDcRF5zWMrNrJ8#`zrns8|V z=+KKl{EI<}mBT=Z4qGEOm>Dcu#Hs#J(4ve&i|lujFpyUYnCCFl3j~K9#2t-|Ymty$<`X@^!qo{} zX)r@;kp_+#8X`I~Iovtb+}30czTx3(w(-?QkcWlA%_4;c$Ve0!5mm&Te1B}(hwgci zJ3ZZi*e^%nP9*^-W*>US-_FEgM60|65y5twD*_*V=^BB0LK02B?=O=lPYE5I)B zs4~PAv04}$9aCihUIDWVT^3+AGXM>WCxOYIQBL2_)Yb{Ef$70sc#yEG+R8I4~P6m@0_cgC2cD&!^>RSW$fsM>M(G_ywJK;un9H9Bhs zBrCAnKyeAWHnvDXq9U1&B2(guoRX+WHf$8_R!)s8GA*{q4D>#i_%pUcMJ7cY83Sh; z2{BB3*3$T_BY^4bnQ;>FGi@S1QZLixr-kcvW(Iv6TQg@q6NIwai6|oe>_oYx5&&t| zw9iqatO3Ae`|(l`^XC@fn!^|tJi&AXf~SHaU)mv9?D^6U(X^s`{SXxu<+~42sY&WP zdp)1`hmOr+3Z%G3<^=e@g%V-lI}M^HUhaiT1;t+Rmn(*;4(5sQE=#Ln+KKQzP%9B+ zX)g8}i*Pw(alN2{HsKpq5p)s0H^YT%6BvVls;vPx3HbH~w+%5AzL_11-uBU(K6cCV zhF9A?nw@y=tmU`f+75+a(B&_1aci6MfgSQf&Ra45CRSx(VEUpbBQU0S;k z7pAx2O5XxAxSlCjZwGU@YW2!hHLKUu*3>6aJY}lObnxS&qC&V?moubGeJCDrylspKtHaosb9H#UCnAYE=UY9st2HS-MsTJTDzil4aO~MHKG*- zfQ+12vtrF^OoztKsjYnT8`f|_0rTT%c3o|K?MmQ5OKqWO{YG3G>uhfa3M@x7oSnhO z@_FiOc`F<@0B$arw_r(4B96%EeZ3(eP2^N_4UcwZckJ%j2q9@k6=bL7789h?DPS)m zTw1DOcyJJ>ax6N`l%@aq?)){I=CwysZGIhoQ8z`HKoIGjdj=rF9O39;uuT^L`Ao7dQ} z0R!UiGhs3Xp9hI+QXbwCR#BU{0J{^7=N*~ooQf6ys9eJ691d>S^odeBC3pG4Dgd2z zOQsvJu&9*j8>sF8t?>$X$Cvv<9hN7wCYF$^h;M53!$k&k{9G?XAYQeRIdy>d4NO)*BK*$lX$zT;H@?s z736p7E|c+N&P}EEq^%i^i%;pi>S@Wa0i?qnf`@MGIERb2@Ebv$1NX$qhgr4HiM~xg zC-tBo+I{!8E(`AOy6h#)nlu<;mBQKBU7lr zUD(Ev2m=`uq#Zh43{pt#MFlx=0lOK|+6-zYTE_NFBP9N(pzf_C4+%>DRfqZqs8g)Yrzh} z1f7{+4(=J-DQu0 z&-et(?4>*C(>mRW!>y*>mQHdCiYeR41EQV zC6%vgz6(7p@e`lW=MDNQ-yTgHUc~4OxRHOsz!j|%7^)312Qc~LGRgzIn+FENBu;qA zioOT0H(_f)?%%5heFyh~5K@X=V6(O+k)t68BiLH4!n+v7);PU-Jy|I0VAl_8?A)NHat_Y{B7p7RL!`5LP?;8Ab3% zO$JA97a0cZ*dZ`+L`s?*y@#UP2f}~{X-w5NFRpNA86sPqC(8pizj$3|lkY@sut(5T zWDxlVZsNewFdWukT$Iu;q=FYjVi*YFGsIXi4gkZ9VX$t<>aiLX@y$8ui5#KBC;TKG zH`tvrf~d|IqCiZ>R1L)`KM)R_z0*zf6hA>WaSMIhjVtn&3&y6kkavXm)eJ)vu@2G- z1LqMXqD&X1_Drgkt3`Au&<`LZML83XQJ%D|m(^}|W#G2;A6)fm29i$4?po>*mEtTN zcb_d*NY%>nAFdL1DS6YEI_Xg*8Nvw=)i?oSwxHiRch>&`o#}w}A9Cx?&A}9`F6P8= zPAN$nzCNFdV6nIukC-nO>S6)3ND4T$z&q&L)wRGqaO)1Q$Byw?=ho_%l@Y#*=Toa2 ze%;B%$}6sKse${j00|sttR84`CtT9jiIuv5_|J@h5f(DAyn|N~vV=mbExTg79Y`0r z;&LS#;fmPtJ6{7qfDV?hfaI}HE3$G5`m|1*uj7KQH7G0H5Di?89aX~}W@}5Z(cjGZ zk==xK7Z`9Bu?c$>Iaaf1jf1o$0@3Ovn%OM2=mMPgXeY8X!IR2jz~KbMb6g)}BJCYu z6%*EHM`=TqTg7%AcL1Z^5t>!nxeU1NK-bvuJ)2-~nH`2`<}?mZ23eo!fu8%vp5eI* z44!LRn!E@IaET$>8AQT2wCxpC7wFn>yoh>m*Lo^X_ih8O;`gdW;e}#YkZ=J#xZ8;P z(3eAb>6|ljD~DZ3+a9MwR{Sw|>$u7s97pU5wIL2T&O*#o#?(<8&I-TPSBq@hDgv=gdbcsZ;)0z3?Rem;s*_U@{ee;5!{A=sYE6V@HariytO5 zMc<#~zJt#^H(7Th*;95F>JR~D^aA#z!DeN=evjy)&n7Aq1-+&{fS50B9$cD+=;O4o z!*~YVe5lPhfl42iELuP$n;A~9I_4lb#)*)(!Di(nnJFI}UfX$m6s$;Ez;Y_jzF|40 znVoYO;6B)(<9$NbCYVgB&KHQpy)G2mGKJEm|W zc79WgIW{ZE!S4aK)-Rb?T5(3DM|@v^KK}tQt)f3d0cZq+9ec~`0-+E@QwxLJ5Ko6k z^hPqb;3>*M_;E+r>%5y~BzwWWVYAuDN$odwLCk!@AsrX&Ek%KPflX+|$`wBqzc9ov zS^8ncddm>M7QfNOe}l*}2`3O4jY-|B|5m&Ssm5U3t8Ws&H^d*{S6)#KmZSHHHf`i+ zgwcG6ohNUc&y)CPL;M9k;3>Vc4S_TO*wWyg9g+Lc^S{A&JolW*5Tyd(J^oKa{3}eP znejx$zXk3C|0ik+mIU@JYHf$OgQ>K4LFq)zT=*>rm+y=B3^o#gmsW$;xVR(`eW?9qLg@y0#y2cO4Mj;{fV$$UeOlHei7Vv->gND!?A z2t9cqtwcdc;6d8Qo*Tx-5E2n^lAsksj<<=!Wst#g6%!4(RxdCCf=yYmFl^U~{?b)EpCyJYm1V$| z3b0zLnrQ>{IYK|&A`8)PaXT-P?bZE5c9zXkTWQEM*`GVpZ40&so0}k3=Y5+L-7slj zqjgy&=zMniqb$=Rtai zxT9R@219O?7hw18gsbR}D*TXJf}_k0wKtaUMBMLk)?{+noe+pnx!HhUj4i;1oZ7q# zlbuMOoRE?7QiE(VWI#e`m)^)>lks4ejuS+ha8@NNYiBI7^zuFkauv}17FZa@#(TDL zc=$JOiy0ok)13f$5}W+Pva04@v>Eae2Keb_JTguS8M1?8#iT=x2?UKKdYwNY<-0@f zQQoZaQt2(R`^Q9~C5H&sD=$aTM|lNo4HcS>nHgM{kyjb=WAbW18h=|`^WJ$Oh^-v= z6;*+-aBfX2-Y&9&l!AJjAg_T32YD^;A%B~CE5feQQx&z=RAsS5dZ-y977ml@mDkA| zbO{D>Y^2k%xeR$DpH3~*xl@-nW9_5amgFZwm*5kV1!zl%hjjKDUYM+YCrD%Tdb=U- zkYFbHsH*!f$%RO2_-aGCCDh2G2u3=C$n7-Au%uFR5b~qT7L+=K%JVw`lmqP^c^C5% zo8;YwyoWu0#{_-G>(`M-BAA4b4;b=6cKMyrXiJG=ND-?|Cn;hj;Z)MwTv4|nzsTOe zIb8elK+853iEyk#=rT6RM-2I>1lEg0sYWCp#E0g0j~ns{d5{Z6VoxWIP{_?ZWyq)H zAq)q5H_!q(PigY$i=P$HK%3-YZm?GgZ=ge<6RROQ2_xVCvW^aISnB3Qvaz&4>F`qr!R zJD3U0WoT&{5dKg@>S%7Pww94X;MK9c6^hae(7t9sCa7g{fY$_F-r&}<8GC}XKz~LU zS}w0Yhk1?!P~}a6$er?CUCYM?NWBwv4cyy8(INRkH*2bkbereZVg=CFoLgXQ?+Tjb}9~GqSmYup?)CwKKFcb*&P{0?Ef2 z)0kD((9Yr+42Mb9&Ok6htr}{bB-=JsKX7I_#5O$FNXUFXngFtbE=q#cRL>Yl+0zog zXl9DS(n%5A2Cp_>Tc~Rbk}=t_?XZCF%Uo!Yp)Jki?6T=gJS!ZY~G;qHhPMVeE?FduE#ze_^__#J0oStWBbF_20!g@nn!yV=d zBj8Kd)+J{P;%Wpsi3Gv|8JTbcT1Oc)8nmNLHM<~ERgn&-Xd4V|BTMzOVPOg`4lm}l zG%aRft6l9ZK}&{dDB)OcR)upgDZAE+4|Y5G19gdDnr(=IWz3$zQ7)&`2)!pG|Pn(LXPVf#nnXDVxK5u;Z%$rp_#uk@jKh`I|skbKvU-Xk@%gjoST`y;cy98 zG4sOZ$KW^aSE25)mX8*=WI;OdP`s_-eAc9dq|srV@> zIRYnOX7Ku4z0?k$;Km7fB**g}b`}o5<}eL0J%f+W{dh0!%e|gIZsd=fk?Yo8 zy8Q`XW@#^dwkqo=eGY4NPgS=4kR#ybJM9D&`3yvD$oF(n0iyA^^D}zsUKDw#D%Y2L zoD5%XAAJ$cjPPYWO^>P2PE~o`qRf}qhbR)5onr`D(@W3!a-XKJSB>6I?HIL71mdgurHX&JY)Nd4}mA64c1@{iMIUw$9`l;d@*J4(MmH-A+%>L~rX z0X;cPzwzb!MwRA!d+2uvtuv~R{)Cn4rV`)C6Y$j@t~b-F^6%)wNOsPi;~UvW|Kb=r z*}4DWmoMhN!(ZqTCgxr9T{o?bRFR_|eIt74eGa{WTF&x~?4f?lxNvn*FOiYyk@38;>mN5u$)Y0ATNM<#e= zH$BPSwyoYu-uA^hn=)YTuF-&K4l3)qtqhQ#WIAr zSPozGb>c-hmi{%JhX_Ke5kaC}o=t0IEzObjP%Uhu4YG|k%A25xy^SuE57B1%DEZ~r zX^Z?RHOe=sNxn@1+yUOIjihbbM7mfjqm5ufc4~{MMcYcP+U2y9VU#>d<4>~x^i6Z| z*AI789_40ylTHn~I0tF?N3i@QjNv5w+07O2!PkvWS1i)Sxk$igKW?h!CN2f1>I922 zxIBDZr$4KIsEu9Ij~qe)9f8(R=wqg1ckWVj-x9T=(=il-p)+FMWKA4BVI0T*8AhWS zLmgW2(0ci2;951h5*ju5DN!@Y1H|}7ze^1~IOX+zu#*#RcF5zKF{BAW$Pp|TRzCdM zfaiX!%20NU+fm-Ei)#T7$hPbj-u?^@FS~79S=1}$)fMAo1ws;vMO~EUdNfZgF6j}q z0JL3{gCBUoKlW3a_Gn43Sl%a~-s}-;5hN)5+#@zTx*krr@^% zlOIW)c)JU4cPorPf?V%mmx=lK=M%U_x)Oba#k|$WtE@gQga7f3E5@N07xjq1gXFUx z@26Sg&D4tz(5ald6B!Pe8Tj#j%Ha%ny!ZhgnB`=hsSgOl@Odf7m&-tZT#l>Fm#IGC z`u0gWRTrBJFkyU!$Lh>!=nNl^TzFjvt=dK2qap;%(D^JANG2pS38X7kJ)jOppn^+L zt)i5~<;cVi^S;b89yM{hmUmP7dYH7;$vM5^(msxj)h9l|fj{(PmFXIXnY;wy4bv(( zq)?xLhOCP&88L73089Ba(ob)`w^tygof*S5-zg>A=Tj9Mb4>T(D3D3A~#l z8TvR7-u<{Z{}5&Q+;!HkIr})GWq$gB{##3)K&RnUA6{S9!u>`owTIWWOgRp@8no%h zL6=?w8f_oI;kBUJK7pLqW0h}!ul5`10MZ|UVLnrMd&#{{r?n>$(?Qhr!{MOXsiF=b z57KMgvv`mJ*92#>E^cwWKzNT$D9ZWm7kK9Q5GzlTrYbFRmbNS9na9Ny48|fV#pjur zq^qztybb1nV*SzbV$f5?3eRGvizjd>y6X_GY)bL-;*l;ISNxoKWCh}0K{N#d#(d}% z2kS6N_6ATMFwgn4nt*KHTsX=P1364%Xj_C;*dOT@W*Oab zPe6Caf%pwPNz;17)AO+94ui&X9TmOUaYw4s1maCJRAn5ibZeD5M}^7A*DFBprXf%! zVz6NMK(WNn#}UA5pANNgz8g_tdc`+;#J7i6Hcx!FSG>k;^a+SQ`^0~tNA7M~#<9Mb z5JGe>y_hLY1eoX(KWEx=)05&?J>qvg;!m9PSI+cYA3_om#{oM|IUMRMk_8)-$*}%| zz2a?z*b*muB;u4j2~yJ4CtXbGKW+wQ+~sqF3@NyCKQ-e?BIcWdR2R7_Gx)d1(LpbQ zV*0${4ti7vk%9X{vwe8tj*7Zonb{+=_fv*ei35{~B4%HX=Vu_$rO*AuQJK?Fd|2jI zW!MCv$EMT~tJn_asuz48P-#uoXkJRBA$@> zN+tBjX%=yeL}-%xWHEn~bJj^_)~M8|{Hp$vaCI}e&;%dCB&nwRBoZtDv8$*@&f@el zxbNX0Lw^hh8G2rx-98Ph-`F0xFn7#Dkvx^Yv3YWFkF4EKh5Wc&J^J`@g?h~5$5nXj;^y?c@FMr_2wqCiNzZdYwj>EFKD%0BJt%`M;h+p{EC)>GR z95c0j`y{_r>7C4&%vO)PpyzWx(EqCXu^UbT`2LJ+y=FrK|CM6Yg%`LHEPg>;Zb3 z9-wc~gY+YMi2jJQf6*6kd9GXJf*UOZe49mILS)lN5Mty}u?No|r^m#NxEg#X9YhSe zC&k0K>ibm$B0i45Qr`it`aK<%91bNPeV&C8ETyBeiJrkoz9Mg-V+i%}tbBx?lP}Qo z@`vembALv2Rr!k3+)1nJPGzRdnsBz|MkTsd_(1q@2Wc z=rYIT3lz4*yZ~F)44fIU=y(9gUXJGB&TXLD!v(n5ZQROqgu-;rNVw0DF4=_`yKylc zOqexq{owe9Q(hO(SvC0FCoxXil#5tk>BFka?u!aoHRKsUsf?jabU&Q24SEq#`7Xo> z8)$xUyPn3r3%_Y@zPSy`g(;XXLkyXY@15g;yowNp`~63YdgZ596yu}HRjS}B@Fz>S z?ql*@5tEgr`L;xCQ=a^^BJC$R(l~ypCFL@Senkq-0fy%&sQo~75ROKMU*q#byl|)Z|mWKcy-;A#QswLW3epKFt z2#EXZv^m{$U(tjf`8f`cp0n|Osz=DH`}WhQVtn7f$6X{db;sx7;pW^%P%ls5sQL*#5ii#ZqG zM34K2>|m85A-*;0(gwX48!2d+8h;1MvYxllqa9blgE1Gb9wUX=EIZnc#nLE`v^HWKGX3{ z^dFRmT?5KR2xW7vzRPO`@auw{hs3_3Z0VeqktzrdU!Q#=u zzT% z;F&l?qW6g~*V9ncr)g1rUl!>*m<$yj91D-&=H&?8rg?fa?|x>W^0X{GGF#o~F&GWL3DsbVx%W>XPNRJa z&5*Fafu!lTR3P4jJnVPy$n<*(ia+4O(;w;M;!n6d@@Jslzt9WfEx3Jn6ZhQyM*qZR ziuXZeohJT<%as2THR5f!A^$gSmi|X<7VqGe;YqdcUZJc!h}M{2ldmaU#bO19s1TR| zJ4}myq03)buihu{dz*A^6v~}smyi$7!yn5t64nGjI3~dx$W!|vTg-4au4`^?V+{8L zZ=nluzExdAF;ut=f!Q3LR{6=Jjn&u(C$=;Ys2S_50MJt#kJaPv$vs+O7h06DsOT`n1zw zqM`Qwc>Fk9Z8oL3$n@!!=Hdy4FZr$m{jWG$RzeFYX_VAxHoj}5OZ8$jX^e5wb*-HH z!ZT9^<`VE$n=Rk7dc`5R6*pf5Nk6E(V!$%Qq|weX9gjLh7&6@+n|F~pHYn#&RzU)7 zu9~)o0VCLt{}u}pX!9bFKzkGeufcDv)*dH>FGyR?BTYY6nWk0Z)FEhgzN=zIDevc1 zO7OJLCGvrL4)ni?%&SqB-$byDIea(e7jMft2#P?((?oY4Ua&~UTr>l3@S-Y z!djvnnd4y{t~Ci^C#uX^YYhi60w??{KxQa+t;{>X$n%WNM>vKTi*x^Dv7wvr^IK4y z?5CpW#22AJD$adJWrKo9WpPH|V>oMSy*5EKaFIR9@6ue3zsE{ptKlj57MvL^L8$}e z&6D8l=rhX-w7uG;+Q*=IzB=mj8nYa3 zN(gNq)QiXPeJ#Fc&sI~) zVS?@QKve`A(wS7ru>z2Vt%Oa(dRE4tt*-RJDyW?ela>Yc!peY6>X*WFgw8Rb=!dYB zedw?y|I(-l4V&^tubj^Q?`I_KuW4CP+p-H5Nn&QrZPf>N zwlxRXCahYAhK|aTHl|Fv;#*wAZbz3wDI2l-U~4k?V__%KbQL^xdT0ezLeLxI7$9oQ zk$!OQ^9)!LV1D_s=%Qx-wh$-6$ST^YC(sC;uhaSjZd)4IuVuucU?K-D9=d?h&4qT? zozTGmh~JFn;Z(N+_ob4V1;OJFd|2xUi|h2wg!68|t2N4d?ef?QoOPQ)+d~Wd%%dLhIGd zG4tkrjsqQ(@dg0As%dF#ZVlmjyt!8lXpHvYAmAD(U%0JH0kL9l%{k`6GCsF%L4<9# z;>?uXY$&v(HPk@`ICD@8DLZKBAEr?z)#1Z?lj~x-k*#}FSuqKzOg(Vg6&~&G9U z;>qgiT9xHm|DE@OW>zUydH{AVs&yWx1f|2|3D40?fah7T6sUkDz-bU zHrNAP3wwZ1!XDrekQC2@FJ=xDk)Nl(hv3n!ksz{HoTSzGM3sPn)?}T9$zu6OH|8dH zQZKFTp^f{=br3&C^3Ogf`Y~69k>6Vpz5DZ64l9G`gSwyAA9Pi^N~_YkVR?a3wx|IY z^J94_{vL$XouAc^G0B{EEk5vg=iRI{05zMQuq;Bx%Bik3plRztT^9en{4z%z3$d9ust zJzNymX=X)Q`a9(6hj4+z+T#!8jnrui+yyhU6-m_0L}b904yn#`Kq<=`d=i9Ed-23* zhG|d!?^66BBttZP*E{9uLqEm_<-cNNNKFG20y2aGO)*5?3Pt2i9aK?8={Xz%hr=az zf~o*wN{j;It_p+lGq07~n1E8kg~b32mlIdVSS}W_0E`9p_!~PIrX{5QoW9)` zMALH4i%iNBSn_6VsL5X32k(x#pW1c6=RuyE^aX$vh*w-+z&Rd>HpziUaWizaSr%j^Lh9-q4tn5_p0Jz+!Sw%n z@asx9Z1rdy1K?^Cs8%!dtd4-gY!Jlw%g3Jz_Y^~8=^RL40PYbx+r%9z#MU4!!kLK} zyhV7J&_i1(gIzN{W>I|jW{MBQxo^@4yjBS=dSlCd4JqAodW9|2%#cO)znL-6ZKb{L{aW-KK$ z@>W-znG5Z}LeZHyrl;BO0(44Z_$v7Ij6}I5PGc#kXLB~oRM(wBu*;c(9>GDd^O=ku zqsZv-CI#(mwF0d;f?%^TJqMIZ1EI+@41&(%n}es$R%>w%2&lOryXJ%7;&h2O3qf{` z1=&>#p|KA}YFut1NG`(YBSElvD{w?1uHUf@qG_ZzT77H(g{kB}Ows@}QZ#WSb}Qm& zfYT966`2SDJ8>&6R18RsSWyIR7RU)sZHzEDRL4Zg@Z>uz;ov*-@Zl`zylAwNs6R?F zpgPQ67G_6btQbFXbE>&pVZ4a&)lR&aQi5dNK({AG;kTW`FQ2lT@o0FCYEOyyScnJ0 zEz=5}2|Y@FoQwe^lm~}6mr0^AmS_%~Bntr{q{Gc(MT~K{9fa4$RT0dIA41nQP|a}G zmwJJ1>PXE**lEc8AZ{bUO$XxccyTHURs5jXI0{o)Xs5$JR*k1%)z;Z6Ho*HEixl#odS})^*133EWJBEzjjz=NSw+afAoqg};Ut>oDC< z?1g^`4EYD}!oLh#+IfDX54#7vah(CC7=ppUI=hA9rCz7i@D>nXuj%X-EcRNnzCk*> zG(c6N(07Z`LQr$sxvh1}4uv*>TSvwx{}qXF?AFYIJh7Zd5Jqq10a>ndnZ<{%V#vxpxT(>s(~bnf&z*!S1`>Xr6BYuR66u8Z9wFeY zA$Z;Eq+O`d@@B3Ga0ur2K$M;u5*5z!K>Bwn!@|o1eMpKF59as^28vfIuN6t>Iig2W zpgCG}tkcyXb0Q*Z6Ln(WG_qUL;8uw4=SM@7=)If3-AMuuEpECN)7IItqos8>g!3Px z>kQaeL++Xm^yzO#7@ZsGW}R+|k@2TQbL=V|i{Vg|=ycmK{U$>!mCL8_b_14jcbc0_ zxynx%Lz{sQ-0Xgq2jG-!dmyFz94rrGM!}mtF33V8q|)7V4}_);A0(!eGCW@Q#NmeclSY!p>`! zLX}{-QDh}&k`6?LNb=GPbV8@&FbzrATW4m2UZii}456i!Z47Zi$L5A(PK7B*?(??{ zdWl}fYxLRNN`ZDqnP}n{fQHMwV$iGf9gNcqngT#1Xk|1sNqNLUz$F}lwaQ8nqto{= zGXsEMlL=>>@qHGZlzL!%h*&R~^o9yLm#~qKR^-Gm8 zdMlOAlsCd6)~n&%{*C?)R09{5?A%uQxEQDf4p}nrKJ&oP@t+2)7(wcUwXtscw}~tu zc5~A^JpX|XJnM8a73E_J-NJ!-bXc$rLAed{y&;<$g#J4ufVxUancjC>gKpO8GLTmnb0cpgG=hz8r&0^MO^?|BsW{(sC~}t9z@=$;^~6ur!pC`=Lw1}7#=RB=^Qq~2kTpr zyqhu2hD#e_u9%1M)~;Bvx~2|Mq!BzW`Fy4B!~#PsRFy5Pu`1IAIOs%yUv-XGlh~nO zn6w4Zjrc=?Pc-M?2IffCL24?N8wfOkEs&->%R)A zM!0zH>@-T6u`Wk6g0K`@4N)tW zX6qu5BbMe+1*ahn&>FEeJ6&9yBOtnn@g!uchQuWxU?Y3Ua-3w+2xbVsP~q~{)*YQ~ zx&VDX1l5oYJh561zd?LypdN8F5Wrns-aSH`4wM@M&s>DjUk9` z!bCD?H(_A$Id+#ru`FRs;K1HFDASggKP`h`0?i=)!Pp7ul-UpNmlMeRd`mYv5ncm? z^ajYsnC;+=(-aZYAxj|#E7WL3hpfpw&p$i6Lq1d;wD;?y_c^oobBcnu%IG2v_sB39 zN!WHN3f6bZrcP-DXtP#R;EUeU{dvFgYO+fA%l368&?>@N`($T_TpG^Oy|RF1UNibB&=IJLL;nYlq~HcDW#&bx`h5@K%?4&3#(d>DfIBw*L}!%?J2O zr|eZ?v{q_ohkTtgLL5Y8?UHY*rK0bIgPL}SJbO^ig}^M&5r+~}OTsyTm|oZ?FV$q) zOeje*kkk!jH16lLoT|9ktb?}!%jFqUdGu4sU_JXSaaoDO*#;Fee0 zWd-lp+__~1f486DtZ-0`AuI{!9Fl)}Vf6I9Ta@y9NV?l)nD( z7SKCAI=rPgubd8(qYU=L-5nG?96V(5+Tk$=51Kr^-C^?m=!42q(!el{4w-^>a-Mt8 z6sV=B4l^_yx>uI#gAx9N5o#=|!;I7iGxWhI|G}u5LE%9iX0$#yMjwp99DznPrY1j} z-(iZwq5B{>=hlSwbl(`4;)Hg1<^-GIr8rT8jnK3w?=|K3X)!ZwF{eGKkcvL=Nh0=45 zIpy!-zANR9JQp}BwGL*H41DQIM~>tNhP(?)NREW^2Q4JH6ew;mFw9!y$Ws#NYx5I;$m8bAz*O7S%YPbGj+7^23T)o90_o99rps}EOR!; zVVtd(47?C-GUwZ~M$=?An`WMspbl*3sQ+N+nid&}p_r|v4dzS*Q@L%T(~Ft*coAWA zVBRjzCE^CkDuac;&lb_YN&=-@nrmehaWnJ839GCN;T33Ai68>Olpc+&bN~^gLlii; zp<`;wSlg4|5|41)5U0Uga9{j1BesmKtFjTdF=_XplghW+<{)ub-@|W3_sa9^UbiFp z5zh%$yT_^Ig$?yer?GqGgCtM)#o1R+1Bmo?v1TVuk<#Qnrgi#r4VX5%*ADe&1mAoB zQ>9T#jbwczc_V!c7R3ICW>1bHCCmHSI=t!+S}sp?A1cDm>9POilW_dqDKKCu<^t+O zyjV)@dNMVIAH4dc_NDBqT&&3AjAhcS$pLo`&Qr5VDg&$ zpn?ik_ow?!9-!Z3bN0z7JdoLElz|eN zZ|cM%alWa@It8LJK}G6JWsKCJY%udlmr3Lx<`;uO&c%>&p2gR%FlYQKb4Hy64uv=E zz5QX{R(wrO>0ysNF{Jb{Mmr`MBXwvk#>8sDxDJ_sVHj1uOPCK-*ijCV7!Nk`ha<(nr4Wo!SRBBj{`5)a zkteZ5qaRJo6*iO5=FJQ6<_d}^_y z17Nc3AIs8WBAjWAOmI>1CbAmX4?NN&DRGn~>iO@s6!uJpw8NSd!Ea=zZV<;}xvqPxp zRXvZVtzyKm$Y6v>Aws0+QIEwo!jmK;dF)A&|DX#q9R`jd=-0;(xia5si9qF$ zGOjRMi!rjyyI9q-7vpu+;&_8VffAKfFvVM((HK2-`+lqQ()GqJa4aZTLA?Pw^K_0X z=x|1X)EJ4((EBqI`~PJVr~DuQ%2)p;5}>dCpV;LC`L@gVS?j5Lxg+cb&Pwi1J%B5< zCnF0su`}qa@7@aKR&=E03s%>zS#<7-r3;pyyXuS;D^6dzdJ*eqs>uZBZfI%*Y0tJq zg3e1bbNLh~yoEUU!TpHN0@w)K(z4a^)d`H2MJM&SJUuz!z9`VH4RgE@`*fsS8Inoy>A*MF_J&F-_(GEqWDulve)|7IeBT@Q6b&183I#=-$ z(NAgKoPRB+F`1MEv~*cfL@0TH%_J9RQMoZ8h1{_G_Bd&>e49E`2_eiTxvWW+s6=b_ zJl)!_Uz@|~WCfigB73dKgpktIP}l6DE+d9ASFbz+mCH9*5FY?|O_@~r#PmM&^AO|p zEJtSIlcLXNeHv4`rLfhvVbx((!`6!$w))QQ8QM`9ipgX-_s~LDsZrK39y1A{ko*#9#LVH*!i!_t$0Vd3* z{FblDvJ033B^!aBhM+Z^rZJ%MT39G=EMs01U*4pQTGZGMkYCvH)i+nrvecM_ng>{^ zH&z)*LdN%2hQzMtg^c7b)w5`Y`S}19h3VMdCiknQD8y;05aY5enF-i_3K8!%JY&l+~e!^+D~1qxi>S`CCVkJLGNE6wIW-$ z2-qd|Bm33^Qs=JKg_2E$6*S_wbwks(df!A&%U0yYwt+3?skv<|#qH?bnV=&VvwfOe znOaJK8wZH?XwYxMf?DE&P;I zEGmsT7U9llYix2cg!*aJ5en!%!YoU=4s9Vx>5!PU-Zu4|AiE_Nnok0n(HmUMNBb#w z$p$gs7J|7wc`Hul$S?wZW8)?qQr3I;E}D0GiypP_Gs!t}y9*rnPI|dY;pk{WQs*UB zP13OEjn9b{(5sY7AWB(vSk=VcK03TV6ViKSkAwN#)DzM#VNSJ=3F((p4*e02^+D@P z&tJC9#i-Xq4t99sXVol?yR6eZOg96jgZzqDu6^}2PTltJq=e+$D~~Gr8zC0qdF0TG zJ6+~|k8!Up-A;n$>+7yOE(d7mSk!_R*qbM8W{|rbIh4i|wj)mtbnPYwOaD)~?Cu>V zmjha_Q6B-{cI7+rpM0iX)-yDP@4A@s1P+?8Dx>}R`zq#sVC2EXrl}q8-_UDSJ@?g+ z6pWL2yJNJ30kwQovgaeyHM@`!LsPK_#`yCR!$rDzxR36X7v*JDCx6sKjP~B`;wGV* zZEx@8C$9WdUZD-u`8>xD_-)zd&YP_Eo}Yy79L2RI@E*S=KX>I-d5xwrFp^_3d+3d% zsX|Q9#(grCCcww9lIgkZ7ryCY6ZW^vBpH1?+%7hfJm(+DlPyyfB zs*g7Az)uAEj`!B=i+XK%?@+;Jgloc9xHq;g+PtN0r`{Xo0?lTbFVu>3Z);uKsQ%$o z&Qj0P;K#bASl2bOo2)B&y*17?m5%6U`LYOa;)K}N^USi+g z>J<+=(*>P88x1V2zv<&A`6hqt3QVLu#f_u>Eh^W{am`%)cs+b}TQG8JbZ>MoASl70 zf`tQ(ty)Zr9rlWU#uj=&r1*r z>#SzEMsj-6E@5wFKVm-Mf)QRt`>_pIeO}W~#-^7czS5ivD0Ho3)}$k|`O39x&a|s& zMDCdYWJUEH$DEy{GDSs{4depp1y~pAvT7qeE z6>35&X|+v07Nyo#8j0)I7A7#&Yf(by_CJdQMn%T=Lt;Bd_e4?&M8{mj6fa#ANpcEu z1hdj((}&h3FeeDkONPhPc7M6fTeAaB0;U9 zacaYk#W1Mq+FbKV^C_QYKb-N|W;ZbiMZFeHV%4b&%ByBnhs^cnM#tR1yxoRvWi=l1 z#Lg+pV5hKbdU7N7bBXpQbF(+cu5Cu{vaFiU*?BLq*kXRxHJ>v~C0*rd;QxE!PWYbM z;3uEGP&3o+np@OyRCc{hnUhIpFSO6DHn-!I-Y~_=K@Fm?l$sfyKJM9mgJw4yywfT- zZk$n9Id#L#`s&&BGiGh5uA9BSx^i~I#>$xu(;7BRuUubQxqim<88c^8uCJS3HLJRE zR^80%4I3(_O|P6@l?J z!XF>dHtdl3nt8~6$LXv`nkuH0B5c<;t3`n*)w{JgveQ7pIG7po2(Mor`VH%AB4;PDRSkG)3Q%a!|e#G_O&2 zIvZ_;fB&L+-8FBRUlL}+ztcp7R?M$;ta;PON8&q{Uoml6{Wsrpe-Zu}7G$9z&qomC zZ2lH9|1|G8=H31}qDRp``hVIHz4=Elz$Bl)V1NmGCc`ObS*&uVa!ub0$}fPj*H4sR zfR%;*bIcNmWiglX%uOs>N8xh8IEde|tQ6`$I6v;gGT6i8DB{>d&s@V}FdY0U9x!Hy zaGwlK4myND!!RzG0a96Q{{qqi!lRg)WAx?BlSm&ZUtqL4Ss$&$ukoIfb$fnXha6vR zovOz!v-e7=`c>ynhg4RR#Thlh{W51=IJi$vP}0$f>-NjSb>Yl?a`JvTWu1B$-6ysC zW$C&iXP+$JCm*N@?U&Ucj@H1J$?TN1o=0^DDo#GG&j}w6LXG-h4Zb&}&ZkjrB>eeL zDD_84Vb%CiZ{yBI@M7`~$@X?RH=K7+w$}^}yN6_FJC0Iu%YKQ{s21;)!R>NlI3I7= zR}?{=`dHDRcDYBdJLT$vYcsgxU0h#qlfB5ndv|`Pe8xL`VUKLbRbU7L)oqrURa3AB zQV#Ce(M}J_{di>04G$@Jpi920ZuYqUh?3P_^Yb;w%hwQ+7!sHM1ig+=pNmNPgoPLu z&VNMqb*X)nF13Q9sm_e1`i+_)J?kbXJOrQP>NovcdiG2>ms-pQeey%xpHrG}!DG0|BjKN6!`d3R48VF6?BGn|cPN0eBAU@Ks1Uf&acqW}`CKm?zO!e$4qBa)S8_ z7R-*y`~Yx;z(`quZ!+OM2rSnKG!=v4I!DG>YuMwB;qn4UK$DzkU2k71vc!?st*h48 z-@{6RBW>zkrm)W!c}oM+N9A6a0NoTHw{?A_7JW$xh67DQ-7@w69Y?-lbJopQ>i=6_ zX}bB611JoVh%G-vN=(EcS{(4jdMBgZ;NK0hW2CXccP#0GLI(M;OMXqm&fWv)(q1*%2A$8?y@cvqqQ>6^SE_s_b zpx!AT@SnHfeby~_*PdnXF*tD5L{&N*#kp8s^x2ef-qZ3{IPU-`#$Si?)E!r$3GiCG zysVd*Rep42_i*Msl2H>O>uQD^s2-eAoxexE93E1XU!?@TF0}+wX!7jKW6KWXl$Cq= z8oKb1l(pk_w(y{o(bWo;MMD?Z&>O;;hvh?l@S36Fp@(FAJ5;`*DwvBhAD8j#GQvfx zYBKl8EFPMn?J|{zW+>iQhaKQ?O{R6&S_qGJcz762TX}Jnb%o0F-QB;tGT#*C7Y*Ls zz52==1JO3L`{=i|VpV1xy=%0!sNZFV*$p=t@_|R8YBZ7O>L51GUVMNWAuzg4JG^XAip#m^2Vw?xN7j<$+3^7`tQ(i zkJHmlC_YvGYj96H&4ul_yXJK}x?P!TigJtG-QA6^Wu= zu2NcXMt?01S3b`w$8PL4ep%}Aan@iuadh>hY{Dn(`Q|5hIQgwyfPb?K@lVwhD3{HF zMbaENRn8Brlr4cX<->tS>jetKWWf#K$aEnik)_U})j;^_ zS^rSyeK3-fGwKEKepQ3e22#v%A%B09Av%OZ#Z4@n7ux%^$-_uhm&LsXbl8!gL%BhOdM zOBnGp)@77(>tQ=JgZaFVF(I?d>_>Y+m9hh2mOtv`9HNQh%ouM5SONSggcpSqmM$m95qsm^pW#R0C2Xq?5wg`q9g1fsrVgp5m%`P?&7@&ru z^G(Ka_$-Elc_6UPlsl=pyW|qtjoyC;|J`kCu~IUR$~?z>%{JB}>X$lXRdCy&Bex^X z?Vqxc7N^@^vDz|+Y_c!W_LOjTX^9;emzEZqCvBc_Ia%6ip6WE;TE-(5bC;KucbRAW z67sYh7Mp5&)(`7ER2u4HW;j!IJgdLBdY)OeM-5I`KdkohG4nl<#E58=kuh_kW!Q8g z>AFznAEQC-r+IuGV$0)FgW<}>@&r?%1GWl|5x8(@;p5*^{x9MO?)_wh?g}pQXd6I+ zC|gW@wlj3|ecR*irp#)C{u)czizw0AC8a1yS))754^&~ki*Fp2{>(5G^vVsR} z?F_+6ic{8QUSxKgVW{y4D>Q&auI75jUKJ28G7qlqq#5GOZ#OA%TLZ6%lhsX!%dubnb;?b*phc`_ zTW0+tRdcQ)6?*j?GPT=krqxxkIhwoiMJlV(PeJ2pui; zAdpTNcgE_)<+H}kpBI|zy)iFTTs#*%Z9}sLFW%Au&h`Z58&p8mZrsw^P{E#iMN?~q z?gmt_cawq_HBKWTn$%B=?GpM4NE&A(sB+aqg68#q&L;L_Wxh zB_=R5V<;b0|BXFo1D*0`T9gPbCoW63T!lEe`@5UlSAjUD$|2*yOZ!=H0gUngvKa{^lm6 zRZr&Q_i_54mIo(DPANO^^3`Z#s*xeHWB+!U#cKwf(xT?JtvgevbNjfrr&iHX^)IpZ z?*HI`)6xAF&?=j^q)zKM?@svhJ17~DKb4q*Ck#?K8&3Y>$u8|;ao)^eAK$i8#0h{W>fkOp;$%NC zv55D*+*ib@i=S*ECKlr#{+2$*2xqv5y_n<-CzI%7(U&qdrK^dg=sgxf>ij3ObmJ0W n;jFI&QpcG*X2s(CFc7`i2)1HLmX3?4CHUz?XFQATN|FBtD_wK$ delta 249 zcmey^z`E@dqj-QfGm8iV1SrP%TP~gz$H>4CH&J{$v!R~h#EW~GxVScNK6<8@)AB*n zL0O=j?Dh^zMsddJ&en|ToNx`(i>(95!$f(dj2aYB(I=A{^6Y9T>&z u;4T5Wh70O;5Y@owSIWRpkW-qTnWtBgn-k#8$_5hW0K#4-28PLHARYi9&^=xN diff --git a/plugins/java-usages/plugin.xml b/plugins/java-usages/plugin.xml index f654f9def..dad7e8d8c 100644 --- a/plugins/java-usages/plugin.xml +++ b/plugins/java-usages/plugin.xml @@ -4,5 +4,8 @@ + + + diff --git a/plugins/java-usages/src/ai/openidea/configuration/ConfigurationCommand.java b/plugins/java-usages/src/ai/openidea/configuration/ConfigurationCommand.java new file mode 100644 index 000000000..39fce3a9e --- /dev/null +++ b/plugins/java-usages/src/ai/openidea/configuration/ConfigurationCommand.java @@ -0,0 +1,525 @@ +package ai.openidea.configuration; + +import java.net.URI; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.*; +import java.util.concurrent.atomic.AtomicLong; +import java.util.zip.ZipFile; +import com.google.gson.*; +import org.eclipse.core.resources.ResourcesPlugin; +import org.eclipse.core.runtime.IProgressMonitor; +import org.eclipse.core.runtime.OperationCanceledException; +import org.eclipse.jdt.core.*; +import org.eclipse.jdt.core.dom.*; +import org.eclipse.jdt.ls.core.internal.IDelegateCommandHandler; +import org.eclipse.jdt.ls.core.internal.JDTUtils; +import org.eclipse.jdt.ls.core.internal.JavaLanguageServerPlugin; +import org.eclipse.jdt.ls.core.internal.handlers.ReferencesHandler; +import org.eclipse.lsp4j.Location; + +/** On-demand configuration index, scoped by JDT's resolved source/class path. + * Cached records contain strings and locations, never ASTs or bindings. Java model + * changes invalidate snapshots, including unsaved working copies and classpath edits. + */ +public final class ConfigurationCommand implements IDelegateCommandHandler { + private static final String PROPERTIES = "org.springframework.boot.context.properties.ConfigurationProperties"; + private static final String VALUE = "org.springframework.beans.factory.annotation.Value"; + private static final String RESOLVER = "org.springframework.core.env.PropertyResolver"; + private static final int MAX_UNITS = 20_000, MAX_ENTRIES = 100_000, MAX_BYTES = 2 * 1024 * 1024; + private static final AtomicLong REVISION = new AtomicLong(); + private static final Map CACHE = new LinkedHashMap<>(); + static { JavaCore.addElementChangedListener(event -> REVISION.incrementAndGet(), ElementChangedEvent.POST_CHANGE | ElementChangedEvent.POST_RECONCILE); } + + private record Entry(String name, String handle, Location location, boolean relaxed) {} + private record FileStamp(java.nio.file.attribute.FileTime modified, long size) {} + private static FileStamp stamp(Path path) { + try { return new FileStamp(Files.getLastModifiedTime(path), Files.size(path)); } + catch (java.io.IOException e) { return new FileStamp(null, -1); } + } + private record Index(long revision, String workingCopies, List entries, List roots, + Map files, boolean incomplete, int parsed) {} + + @Override + public Object executeCommand(String command, List args, IProgressMonitor monitor) throws Exception { + String uri = (String) args.get(0); + String key = args.get(1) instanceof String s ? s : null; + int line = ((Number) args.get(2)).intValue(), column = ((Number) args.get(3)).intValue(); + boolean references = args.size() > 4 && Boolean.TRUE.equals(args.get(4)); + boolean declarations = args.size() > 5 && Boolean.TRUE.equals(args.get(5)); + ITypeRoot selectedRoot = JDTUtils.resolveTypeRoot(uri); + IJavaProject project = selectedRoot == null ? project(uri) : selectedRoot.getJavaProject(); + if (project == null) return Map.of("locations", List.of(), "keys", List.of(), "roots", List.of(), "incomplete", true); + Index index = index(project, monitor); + Set> keys = new LinkedHashSet<>(); + Set locations = new LinkedHashSet<>(); + String selected = null; + if (key == null && selectedRoot != null && references) { + var element = JDTUtils.findElementAtSelection(selectedRoot, line, column, + JavaLanguageServerPlugin.getPreferencesManager(), monitor); + if (element != null) selected = element.getHandleIdentifier(); + } + for (Entry entry : index.entries) { + check(monitor); + Location location = entry.location; + if (key != null) { + if (matches(entry.name, key, entry.relaxed)) { + if (!entry.handle.isEmpty() && references) { + var element = JavaCore.create(entry.handle); + if (element != null && element.exists()) { + List usages = new ArrayList<>(); + new ReferencesHandler(JavaLanguageServerPlugin.getPreferencesManager()) + .search(element, usages, monitor, declarations); + locations.addAll(usages); + } + } else { + if (location == null && !entry.handle.isEmpty()) location = location(entry.handle); + if (location != null) locations.add(location); + } + keys.add(Map.of("name", entry.name, "relaxed", entry.relaxed)); + } + } else if ((!entry.handle.isEmpty() && entry.handle.equals(selected)) || + (location != null && sameUri(location.getUri(), uri) && contains(location, line, column))) { + keys.add(Map.of("name", entry.name, "relaxed", entry.relaxed)); + } + if (locations.size() >= 2000) break; + } + // Reverse Find Usages joins property consumers with the binding member's + // ordinary Java usages. Definition on a string only returns config keys. + if (key == null && references && !keys.isEmpty()) { + Set searched = new HashSet<>(); + for (Entry entry : index.entries) { + check(monitor); + boolean related = keys.stream().anyMatch(pattern -> + matches(entry.name, (String) pattern.get("name"), entry.relaxed) || + matches((String) pattern.get("name"), entry.name, Boolean.TRUE.equals(pattern.get("relaxed")))); + if (!related) continue; + if (entry.location != null) locations.add(entry.location); + else if (!entry.handle.isEmpty() && searched.add(entry.handle)) { + IJavaElement element = JavaCore.create(entry.handle); + if (element != null && element.exists()) { + List usages = new ArrayList<>(); + new ReferencesHandler(JavaLanguageServerPlugin.getPreferencesManager()).search(element, usages, monitor, declarations); + locations.addAll(usages); + } + } + if (locations.size() >= 2000) break; + } + } + return Map.of("locations", locations, "keys", keys, "roots", index.roots, + "incomplete", index.incomplete || locations.size() >= 2000, + "parsedUnits", index.parsed, "revision", index.revision); + } + + private static boolean sameUri(String a, String b) { + URI left = URI.create(a), right = URI.create(b); + if (left.equals(right)) return true; + if (!"file".equals(left.getScheme()) || !"file".equals(right.getScheme())) return false; + try { return Path.of(left).toFile().getCanonicalFile().equals(Path.of(right).toFile().getCanonicalFile()); } + catch (java.io.IOException e) { return false; } + } + private static boolean contains(Location l, int line, int col) { + var r = l.getRange(); + return (line > r.getStart().getLine() || line == r.getStart().getLine() && col >= r.getStart().getCharacter()) && + (line < r.getEnd().getLine() || line == r.getEnd().getLine() && col < r.getEnd().getCharacter()); + } + private static void check(IProgressMonitor monitor) { if (monitor.isCanceled()) throw new OperationCanceledException(); } + private static IJavaProject project(String uri) { + Path path; + try { path = Path.of(URI.create(uri)).toFile().getCanonicalFile().toPath(); } catch (Exception e) { return null; } + IJavaProject best = null; + int length = -1; + for (var p : ResourcesPlugin.getWorkspace().getRoot().getProjects()) { + if (!p.isOpen() || p.getLocation() == null) continue; + var candidate = JavaCore.create(p); + Path root; + try { root = p.getLocation().toFile().getCanonicalFile().toPath(); } catch (java.io.IOException e) { continue; } + if (candidate.exists() && path.startsWith(root) && root.getNameCount() > length) { + best = candidate; length = root.getNameCount(); + } + } + return best; + } + private static Location location(String handle) throws JavaModelException { + IJavaElement element = JavaCore.create(handle); + if (element == null || !element.exists()) return null; + Location result = JDTUtils.toLocation(element); + if (result == null && element instanceof IMember member && member.getClassFile() != null) + result = JDTUtils.toLocation(member.getClassFile()); + return result; + } + + private static synchronized Index index(IJavaProject project, IProgressMonitor monitor) throws Exception { + long revision = REVISION.get(); + String id = project.getHandleIdentifier(); + Map copies = new TreeMap<>(); + for (ICompilationUnit copy : JavaCore.getWorkingCopies(null)) copies.put(copy.getPrimary().getHandleIdentifier(), copy); + for (ICompilationUnit copy : JavaCore.getWorkingCopies(JavaLanguageServerPlugin.getInstance().getWorkingCopyOwner())) + copies.put(copy.getPrimary().getHandleIdentifier(), copy); + var digest = java.security.MessageDigest.getInstance("SHA-256"); + for (var entry : copies.entrySet()) { + check(monitor); + digest.update(entry.getKey().getBytes(java.nio.charset.StandardCharsets.UTF_8)); + String source = entry.getValue().getSource(); + if (source != null) digest.update(source.getBytes(java.nio.charset.StandardCharsets.UTF_8)); + } + String workingCopies = HexFormat.of().formatHex(digest.digest()); + Index cached = CACHE.get(id); + if (cached != null && cached.revision == revision && cached.workingCopies.equals(workingCopies)) { + boolean valid = true; + for (var entry : cached.files.entrySet()) { + check(monitor); + if (!entry.getValue().equals(stamp(entry.getKey()))) { valid = false; break; } + } + if (valid) return cached; + } + Builder builder = new Builder(monitor); + Set units = new HashSet<>(), roots = new LinkedHashSet<>(); + int visited = 0; + for (IPackageFragmentRoot root : project.getAllPackageFragmentRoots()) { + check(monitor); + if (root.getKind() == IPackageFragmentRoot.K_SOURCE) { + if (root.getResource() != null && root.getResource().getLocationURI() != null) + roots.add(root.getResource().getLocationURI().toString()); + if (root.getJavaProject().getProject().getLocationURI() != null) + roots.add(root.getJavaProject().getProject().getLocationURI().toString()); + if (root.getResource() != null && root.getResource().getLocation() != null) + builder.metadataDirectory(root.getResource().getLocation().toFile().toPath(), project); + for (IJavaElement child : root.getChildren()) { + if (!(child instanceof IPackageFragment pkg)) continue; + for (ICompilationUnit unit : pkg.getCompilationUnits()) { + check(monitor); + unit = copies.getOrDefault(unit.getPrimary().getHandleIdentifier(), unit); + if (!units.add(unit.getHandleIdentifier())) continue; + if (++visited > MAX_UNITS || builder.entries.size() >= MAX_ENTRIES) { builder.incomplete = true; break; } + String source = unit.getSource(); + if (source == null) continue; + if (source.length() > MAX_BYTES) { builder.incomplete = true; continue; } + // Cheap lexical filter only chooses candidates; all accepted symbols + // below are verified by JDT bindings, including constants and imports. + if (source.contains("ConfigurationProperties") || source.contains("Value") || source.contains("getProperty") || source.contains("getRequiredProperty")) + builder.parse(unit); + } + } + } else if (root.isArchive()) { + builder.metadata(root, project); + } + } + // Resource roots are not necessarily Java package roots (e.g. Maven resources). + // Return project locations as well; the client excludes nested independent modules. + roots.add(project.getProject().getLocationURI().toString()); + builder.metadataDirectory(project.getProject().getLocation().toFile().toPath().resolve("src/main/resources"), project); + Index result = new Index(revision, workingCopies, List.copyOf(builder.entries), List.copyOf(roots), Map.copyOf(builder.files), builder.incomplete, builder.parsed); + CACHE.remove(id); + while (!CACHE.isEmpty() && (CACHE.size() >= 8 || + CACHE.values().stream().mapToInt(value -> value.entries.size()).sum() + result.entries.size() > MAX_ENTRIES)) + CACHE.remove(CACHE.keySet().iterator().next()); + CACHE.put(id, result); + return result; + } + + /** Relaxed matching is reserved for binder/metadata entries. @Value and + * Environment lookups retain their literal names; dots remain boundaries. */ + static boolean matches(String pattern, String key, boolean relaxed) { + if (!relaxed) return pattern.equals(key); + List a = parts(pattern), b = parts(key); + if (a.size() != b.size()) return false; + for (int i = 0; i < a.size(); i++) if (!a.get(i).equals("*") && !a.get(i).equals(b.get(i))) return false; + return true; + } + private static List parts(String key) { + List result = new ArrayList<>(); + StringBuilder part = new StringBuilder(); + for (int i = 0; i < key.length(); i++) { + char ch = key.charAt(i); + if (ch == '[') { + if (!part.isEmpty()) { result.add(canonical(part.toString())); part.setLength(0); } + int end = key.indexOf(']', i + 1); + if (end < 0) { part.append(key.substring(i)); break; } + result.add(canonical(key.substring(i + 1, end))); i = end; + } else if (ch == '.') { + if (!part.isEmpty() || i == 0 || key.charAt(i - 1) != ']') result.add(canonical(part.toString())); + part.setLength(0); + } else part.append(ch); + } + if (!part.isEmpty()) result.add(canonical(part.toString())); + return result; + } + private static String canonical(String key) { + return key.replace("-", "").replace("_", "").toLowerCase(Locale.ROOT); + } + + private static final class Builder { + final IProgressMonitor monitor; + final List entries = new ArrayList<>(); + final Map files = new LinkedHashMap<>(); + boolean incomplete; + int parsed; + Builder(IProgressMonitor monitor) { this.monitor = monitor; } + void add(String key, IJavaElement element) { + if (key.length() > 1024 || entries.size() >= MAX_ENTRIES) { incomplete = true; return; } + if (element != null) entries.add(new Entry(key, element.getHandleIdentifier(), null, true)); + } + void use(String key, ICompilationUnit unit, ASTNode node) { + if (key.length() > 1024 || entries.size() >= MAX_ENTRIES) { incomplete = true; return; } + if (key.isBlank() || key.contains("${") || key.contains("#{")) return; + try { entries.add(new Entry(key, "", JDTUtils.toLocation(unit, node.getStartPosition(), node.getLength()), false)); } + catch (JavaModelException e) { incomplete = true; } + } + void parse(ICompilationUnit unit) { + check(monitor); + ASTParser parser = ASTParser.newParser(AST.getJLSLatest()); + parser.setSource(unit); + parser.setResolveBindings(true); + parser.setWorkingCopyOwner(JavaLanguageServerPlugin.getInstance().getWorkingCopyOwner()); + parser.setBindingsRecovery(false); + CompilationUnit tree = (CompilationUnit) parser.createAST(monitor); + parsed++; + tree.accept(new ASTVisitor() { + @Override public void preVisit(ASTNode node) { check(monitor); } + @Override public boolean visit(NormalAnnotation node) { annotation(node); return true; } + @Override public boolean visit(SingleMemberAnnotation node) { annotation(node); return true; } + @Override public boolean visit(MarkerAnnotation node) { annotation(node); return true; } + private void annotation(Annotation node) { + IAnnotationBinding binding = node.resolveAnnotationBinding(); + if (binding == null) return; + String name = binding.getAnnotationType().getQualifiedName(); + if (name.equals(PROPERTIES)) { + String prefix = annotationValue(binding, "prefix"); + if (prefix == null || prefix.isEmpty()) prefix = annotationValue(binding, "value"); + if (prefix == null) prefix = ""; + ASTNode owner = node.getParent(); + if (owner instanceof AbstractTypeDeclaration type) { + bind(prefix, type.resolveBinding(), new HashSet<>(), 0); + } else if (owner instanceof MethodDeclaration method && method.resolveBinding() != null) { + add(prefix, method.resolveBinding().getJavaElement()); + bind(prefix, method.resolveBinding().getReturnType(), new HashSet<>(), 0); + } + } else if (name.equals(VALUE)) { + String value = annotationValue(binding, "value"); + ASTNode expression = node; + if (node instanceof SingleMemberAnnotation single) expression = single.getValue(); + else if (node instanceof NormalAnnotation normal) { + for (Object pair : normal.values()) if (pair instanceof MemberValuePair p && p.getName().getIdentifier().equals("value")) expression = p.getValue(); + } + if (value != null) placeholders(value, unit, expression); + } + } + @Override public boolean visit(MethodInvocation node) { + IMethodBinding method = node.resolveMethodBinding(); + if (method != null && (method.getName().equals("getProperty") || method.getName().equals("getRequiredProperty")) && + subtype(method.getDeclaringClass(), RESOLVER, new HashSet<>()) && !node.arguments().isEmpty()) { + Expression arg = (Expression) node.arguments().get(0); + Object constant = arg.resolveConstantExpressionValue(); + if (constant instanceof String key) use(key, unit, arg); + } + return true; + } + }); + } + void placeholders(String text, ICompilationUnit unit, ASTNode node) { + // Include nested default placeholders, but never a dynamically composed name. + for (int at = text.indexOf("${"); at >= 0; at = text.indexOf("${", at + 2)) { + int end = at + 2; + while (end < text.length() && text.charAt(end) != ':' && text.charAt(end) != '}') end++; + if (end < text.length()) use(text.substring(at + 2, end), unit, node); + } + } + void bind(String prefix, ITypeBinding type, Set visiting, int depth) { + check(monitor); + if (type == null || type.isRecovered() || type.isPrimitive() || type.isEnum()) return; + String qualified = type.getErasure().getQualifiedName(); + if (qualified.startsWith("java.") || qualified.startsWith("javax.")) return; + if (depth > 12 || !visiting.add(type.getKey())) return; + try { + if (!prefix.isEmpty()) add(prefix, type.getJavaElement()); + List constructors = Arrays.stream(type.getDeclaredMethods()) + .filter(m -> m.isConstructor() && !Modifier.isPrivate(m.getModifiers())).toList(); + for (IMethodBinding constructor : constructors) { + boolean explicit = Arrays.stream(constructor.getAnnotations()).anyMatch(a -> + a.getAnnotationType().getQualifiedName().equals("org.springframework.boot.context.properties.bind.ConstructorBinding") || + a.getAnnotationType().getQualifiedName().equals("org.springframework.boot.context.properties.ConstructorBinding")); + if (!explicit && constructors.size() != 1) continue; + String[] parameters = constructor.getParameterNames(); + for (int i = 0; i < parameters.length; i++) { + String name = parameters[i]; + for (IAnnotationBinding annotation : constructor.getParameterAnnotations(i)) { + if (annotation.getAnnotationType().getQualifiedName().equals("org.springframework.boot.context.properties.bind.Name")) { + String alias = annotationValue(annotation, "value"); + if (alias != null) name = alias; + } + } + // Prefer fields/accessors for ordinary parameters; aliases and + // constructor-only properties still have a navigable declaration. + final String parameterName = name; + if (Arrays.stream(type.getDeclaredFields()).noneMatch(f -> f.getName().equals(parameterName))) { + add(join(prefix, name), constructor.getJavaElement()); + nested(join(prefix, name), constructor.getParameterTypes()[i], constructor.getJavaElement(), visiting, depth + 1); + } + } + } + Set names = new HashSet<>(); + for (ITypeBinding current = type; current != null && !current.getQualifiedName().equals("java.lang.Object"); current = current.getSuperclass()) { + for (IVariableBinding field : current.getDeclaredFields()) { + if (Modifier.isStatic(field.getModifiers()) || field.isSynthetic() || !names.add(field.getName())) continue; + String key = join(prefix, field.getName()); + add(key, field.getJavaElement()); + nested(key, field.getType(), field.getJavaElement(), visiting, depth + 1); + } + for (IMethodBinding method : current.getDeclaredMethods()) { + if (Modifier.isStatic(method.getModifiers()) || method.isConstructor()) continue; + String name = method.getName(); + String property = null; + ITypeBinding propertyType = null; + if (name.startsWith("set") && name.length() > 3 && method.getParameterTypes().length == 1) { + property = name.substring(3); propertyType = method.getParameterTypes()[0]; + } else if (name.startsWith("get") && name.length() > 3 && method.getParameterTypes().length == 0) { + property = name.substring(3); propertyType = method.getReturnType(); + } + if (property != null) { + property = Character.toLowerCase(property.charAt(0)) + property.substring(1); + String key = join(prefix, property); + add(key, method.getJavaElement()); + if (names.add(property)) nested(key, propertyType, method.getJavaElement(), visiting, depth + 1); + } + } + } + } finally { visiting.remove(type.getKey()); } + } + void nested(String key, ITypeBinding type, IJavaElement owner, Set visiting, int depth) { + if (type == null) return; + if (type.isArray()) { add(key + "[*]", owner); bind(key + "[*]", type.getElementType(), visiting, depth); return; } + String raw = type.getErasure().getQualifiedName(); + if (subtype(type, "java.util.Map", new HashSet<>())) { + add(key + ".*", owner); + var args = type.getTypeArguments(); + if (args.length == 2) bind(key + ".*", args[1], visiting, depth); + } else if (subtype(type, "java.util.Collection", new HashSet<>())) { + add(key + "[*]", owner); + var args = type.getTypeArguments(); + if (args.length == 1) bind(key + "[*]", args[0], visiting, depth); + } else if (!type.isPrimitive() && !type.isEnum() && !raw.startsWith("java.") && !raw.startsWith("javax.")) { + bind(key, type, visiting, depth); + } + } + void metadata(IPackageFragmentRoot root, IJavaProject project) { + check(monitor); + Path path = root.getPath().toFile().toPath(); + if (!root.isExternal() && root.getResource() != null && root.getResource().getLocation() != null) + path = root.getResource().getLocation().toFile().toPath(); + files.put(path, stamp(path)); + if (!Files.isRegularFile(path)) return; + try (ZipFile zip = new ZipFile(path.toFile())) { + for (String filename : List.of("spring-configuration-metadata.json", "additional-spring-configuration-metadata.json")) { + var entry = zip.getEntry("META-INF/" + filename); + if (entry == null) continue; + if (entry.getSize() > MAX_BYTES) { incomplete = true; continue; } + JsonObject metadata; + try (var stream = zip.getInputStream(entry)) { + byte[] bytes = stream.readNBytes(MAX_BYTES + 1); + if (bytes.length > MAX_BYTES) { incomplete = true; continue; } + metadata = JsonParser.parseString(new String(bytes, java.nio.charset.StandardCharsets.UTF_8)).getAsJsonObject(); + } + metadataObject(metadata, project); + } + } catch (Exception e) { if (e instanceof OperationCanceledException) throw (OperationCanceledException)e; incomplete = true; } + } + void metadataObject(JsonObject metadata, IJavaProject project) throws JavaModelException { + JsonArray groups = metadata.has("groups") ? metadata.getAsJsonArray("groups") : new JsonArray(); + for (JsonElement value : groups) { + check(monitor); + JsonObject group = value.getAsJsonObject(); + String name = string(group, "name"), source = string(group, "sourceType"); + if (source == null) source = string(group, "type"); + if (name != null && source != null) add(name, project.findType(source.replace('$', '.'), monitor)); + } + if (!metadata.has("properties")) return; + for (JsonElement value : metadata.getAsJsonArray("properties")) { + check(monitor); + if (entries.size() >= MAX_ENTRIES) { incomplete = true; return; } + JsonObject property = value.getAsJsonObject(); + String key = string(property, "name"), source = string(property, "sourceType"), prefix = ""; + if (key == null) continue; + JsonObject owner = null; + for (JsonElement groupValue : groups) { + JsonObject group = groupValue.getAsJsonObject(); + String name = string(group, "name"); + if (name != null && key.startsWith(name + ".") && name.length() > prefix.length()) { + prefix = name; owner = group; + } + } + if (source == null && owner != null) source = string(owner, "type"); + if (source == null && owner != null) source = string(owner, "sourceType"); + if (source == null) continue; + IType type = project.findType(source.replace('$', '.'), monitor); + if (type == null) continue; + String leaf = key.substring(prefix.isEmpty() ? 0 : prefix.length() + 1); + IJavaElement target = member(type, leaf, new HashSet<>()); + if (target == null && owner != null && string(owner, "sourceMethod") != null) { + String declaring = string(owner, "sourceType"); + IType factory = declaring == null ? null : project.findType(declaring.replace('$', '.'), monitor); + if (factory != null) { + String methodName = string(owner, "sourceMethod").split("\\(", 2)[0]; + for (IMethod method : factory.getMethods()) if (method.getElementName().equals(methodName)) { target = method; break; } + } + } + add(key, target == null ? type : target); + } + } + void metadataDirectory(Path directory, IJavaProject project) { + for (String name : List.of("spring-configuration-metadata.json", "additional-spring-configuration-metadata.json")) { + Path file = directory.resolve("META-INF").resolve(name); + files.put(file, stamp(file)); + if (!Files.isRegularFile(file)) continue; + try { + if (Files.size(file) > MAX_BYTES) { incomplete = true; continue; } + try (var stream = Files.newInputStream(file)) { + byte[] bytes = stream.readNBytes(MAX_BYTES + 1); + if (bytes.length > MAX_BYTES) { incomplete = true; continue; } + metadataObject(JsonParser.parseString(new String(bytes, java.nio.charset.StandardCharsets.UTF_8)).getAsJsonObject(), project); + } + } catch (Exception e) { if (e instanceof OperationCanceledException) throw (OperationCanceledException)e; incomplete = true; } + } + } + } + private static IJavaElement member(IType type, String key, Set seen) throws JavaModelException { + if (type == null || !seen.add(type.getHandleIdentifier())) return null; + String part = key.split("\\.", 2)[0]; + for (IField field : type.getFields()) if (canonical(field.getElementName()).equals(canonical(part))) { + if (!key.contains(".")) return field; + String signature = Signature.toString(Signature.getTypeErasure(field.getTypeSignature())); + String[][] resolved = type.resolveType(signature); + IType nested = type.getJavaProject().findType(signature); + if (nested == null && resolved != null && resolved.length == 1) + nested = type.getJavaProject().findType(resolved[0][0] + "." + resolved[0][1]); + IJavaElement found = member(nested, key.substring(key.indexOf('.') + 1), seen); + return found == null ? field : found; + } + for (IMethod method : type.getMethods()) { + String name = method.getElementName(); + if (name.length() > 3 && (name.startsWith("get") || name.startsWith("set")) && canonical(name.substring(3)).equals(canonical(part))) return method; + } + String parent = type.getSuperclassName(); + if (parent != null) { + String[][] resolved = type.resolveType(parent); + if (resolved != null && resolved.length == 1) + return member(type.getJavaProject().findType(resolved[0][0] + "." + resolved[0][1]), key, seen); + } + return null; + } + private static String string(JsonObject object, String key) { + return object.has(key) && object.get(key).isJsonPrimitive() ? object.get(key).getAsString() : null; + } + private static String annotationValue(IAnnotationBinding binding, String name) { + for (IMemberValuePairBinding pair : binding.getAllMemberValuePairs()) + if (pair.getName().equals(name) && pair.getValue() instanceof String s) return s; + return null; + } + private static String join(String prefix, String name) { return prefix.isEmpty() ? name : prefix + "." + name; } + private static boolean subtype(ITypeBinding type, String name, Set seen) { + if (type == null || !seen.add(type.getKey())) return false; + if (type.getErasure().getQualifiedName().equals(name)) return true; + for (ITypeBinding face : type.getInterfaces()) if (subtype(face, name, seen)) return true; + return subtype(type.getSuperclass(), name, seen); + } +} diff --git a/plugins/java-usages/tests/configuration.py b/plugins/java-usages/tests/configuration.py new file mode 100644 index 000000000..9a42f4a0d --- /dev/null +++ b/plugins/java-usages/tests/configuration.py @@ -0,0 +1,220 @@ +"""Real JDT LS configuration-navigation regression and warm-query benchmark. +Run with Java 25 on PATH: python3 configuration.py /path/to/jdtls +Fixtures use small Spring API stubs; no Maven/network/build of a user's project. +""" +import os +import json +import pathlib +import platform +import queue +import subprocess +import sys +import tempfile +import threading +import time +import zipfile + +home = pathlib.Path(sys.argv[1]).resolve() +bundle = pathlib.Path(__file__).resolve().parents[1] / 'openidea-java-usages.jar' +work = pathlib.Path(tempfile.mkdtemp(prefix='openidea-configuration-')) +project = work / 'project' +source_dir = project / 'src' +source_dir.mkdir(parents=True) + +def write(relative, text): + file = source_dir / relative + file.parent.mkdir(parents=True, exist_ok=True) + file.write_text(text) + return file + +write('org/springframework/boot/context/properties/ConfigurationProperties.java', '''package org.springframework.boot.context.properties; +public @interface ConfigurationProperties { String value() default ""; String prefix() default ""; }''') +write('org/springframework/boot/context/properties/bind/Name.java', 'package org.springframework.boot.context.properties.bind; public @interface Name { String value(); }') +write('org/springframework/beans/factory/annotation/Value.java', '''package org.springframework.beans.factory.annotation; +public @interface Value { String value(); }''') +write('org/springframework/core/env/PropertyResolver.java', '''package org.springframework.core.env; +public interface PropertyResolver { String getProperty(String key); String getRequiredProperty(String key); }''') +write('org/springframework/core/env/Environment.java', '''package org.springframework.core.env; +public interface Environment extends PropertyResolver {}''') +source = '''package demo; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.env.Environment; +@ConfigurationProperties(prefix="app") +class Settings extends Parent { + int timeOut; + Nested nested; + java.util.List clients; + java.util.Map routes; + java.util.List tags; + java.util.Map labels; + public int getTimeOut() { return timeOut; } + public void setTimeOut(int value) { timeOut = value; } +} +class Parent { boolean enabled; } +class Nested { String url; } +@ConfigurationProperties("record") +record RecordSettings(String name, int count) {} +@ConfigurationProperties("constructor") +class ConstructorSettings { + ConstructorSettings(@org.springframework.boot.context.properties.bind.Name("remote-name") String name) {} +} +class Consumers { + static final String KEY = "app.time-out"; + @Value("${app.time-out:30}") String timeout; + @Value("${app.missing:${app.backup:42}}") String fallback; + String call(Environment env) { return env.getProperty(KEY); } + String required(Environment env) { return env.getRequiredProperty("app.required"); } + String dynamic(Environment env, String key) { return env.getProperty(key); } + String unrelated(Fake env) { return env.getProperty("app.time-out"); } + @ConfigurationProperties("bean") Nested bean() { return new Nested(); } +} +class Fake { String getProperty(String name) { return name; } } +''' +file = write('demo/Settings.java', source) +if os.environ.get('OPENIDEA_CONFIGURATION_STRESS') == '1': + for i in range(3000): + write(f'noise/Plain{i}.java', f'package noise; class Plain{i} {{ int number; }}') + for i in range(100): + write(f'noise/Config{i}.java', f'package noise; @org.springframework.boot.context.properties.ConfigurationProperties(prefix="stress{i}") class Config{i} {{ int value; }}') +write('demo/NotSpring.java', '''package demo; +@interface ConfigurationProperties { String prefix(); } +@ConfigurationProperties(prefix="fake") class NotSpring { int timeOut; } +''') +local_metadata = project / 'src/main/resources/META-INF' +local_metadata.mkdir(parents=True) +(local_metadata / 'additional-spring-configuration-metadata.json').write_text(json.dumps({ + 'groups': [{'name': 'extra', 'sourceType': 'demo.Nested', 'type': 'demo.Nested'}], + 'properties': [{'name': 'extra.url', 'sourceType': 'demo.Nested'}] +})) +# A dependency with metadata and attached source exercises actual library locations. +libsource = work / 'libsrc/vendor/LibrarySettings.java' +libsource.parent.mkdir(parents=True) +libsource.write_text('package vendor; public class LibrarySettings { public int port; public int getPort() { return port; } }') +classes = work / 'libclasses' +classes.mkdir() +subprocess.run(['javac', '--release', '21', '-d', str(classes), str(libsource)], check=True) +library = work / 'library.jar' +metadata = {'groups': [{'name': 'vendor', 'type': 'vendor.LibrarySettings', 'sourceType': 'vendor.LibrarySettings'}], + 'properties': [{'name': 'vendor.port', 'type': 'java.lang.Integer', 'sourceType': 'vendor.LibrarySettings'}]} +with zipfile.ZipFile(library, 'w') as z: + z.write(classes / 'vendor/LibrarySettings.class', 'vendor/LibrarySettings.class') + z.writestr('META-INF/spring-configuration-metadata.json', json.dumps(metadata)) +sourcejar = work / 'library-sources.jar' +with zipfile.ZipFile(sourcejar, 'w') as z: + z.write(libsource, 'vendor/LibrarySettings.java') +(project / '.project').write_text('configurationorg.eclipse.jdt.core.javabuilderorg.eclipse.jdt.core.javanature') +spring_jar = os.environ.get('OPENIDEA_SPRING_BOOT_JAR') +extra_classpath = f'' if spring_jar else '' +(project / '.classpath').write_text(f'{extra_classpath}') +config = work / 'config' +config.mkdir() +name = {'Darwin': 'config_mac', 'Windows': 'config_win'}.get(platform.system(), 'config_linux') +names = ([name + '_arm'] if platform.machine().lower() in ('arm64', 'aarch64') else []) + [name] +(config / 'config.ini').write_bytes(next(home / n / 'config.ini' for n in names if (home / n / 'config.ini').exists()).read_bytes()) +log = (work / 'stderr.log').open('w') +proc = subprocess.Popen(['java', '-Declipse.application=org.eclipse.jdt.ls.core.id1', '-Dosgi.bundles.defaultStartLevel=4', '-Declipse.product=org.eclipse.jdt.ls.core.product', '-Xmx1G', '--add-modules=ALL-SYSTEM', '--add-opens', 'java.base/java.util=ALL-UNNAMED', '--add-opens', 'java.base/java.lang=ALL-UNNAMED', '-jar', str(next((home / 'plugins').glob('org.eclipse.equinox.launcher_*.jar'))), '-configuration', str(config), '-data', str(work / 'metadata')], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=log) +messages = queue.Queue() +lock = threading.Lock() +def send(message): + data = json.dumps(message).encode() + with lock: + proc.stdin.write(f'Content-Length: {len(data)}\r\n\r\n'.encode() + data) + proc.stdin.flush() +def read(): + while True: + headers = {} + while True: + line = proc.stdout.readline() + if not line: return + if line == b'\r\n': break + k, v = line.decode().split(':', 1) + headers[k.lower()] = v.strip() + message = json.loads(proc.stdout.read(int(headers['content-length']))) + if 'method' in message and 'id' in message: + send({'jsonrpc': '2.0', 'id': message['id'], 'result': [] if message['method'] == 'workspace/configuration' else None}) + else: messages.put(message) +threading.Thread(target=read, daemon=True).start() +sequence = 0 +transcript = [] +def request(method, params): + global sequence + sequence += 1 + send({'jsonrpc': '2.0', 'id': sequence, 'method': method, 'params': params}) + deadline = time.monotonic() + 180 + while time.monotonic() < deadline: + msg = messages.get(timeout=max(1, deadline - time.monotonic())) + transcript.append(msg) + if msg.get('id') == sequence: + if 'error' in msg: raise RuntimeError(msg['error']) + return msg.get('result') + raise TimeoutError(method) +def query(key, uri=None, line=0, col=0): + return request('workspace/executeCommand', {'command': 'openidea.java.configuration', 'arguments': [uri or (project / 'application.yml').as_uri(), key, line, col, key is None, False]}) +def names(result): + return {p['name'] for p in result['keys']} +try: + init = request('initialize', {'processId': None, 'rootUri': project.as_uri(), 'workspaceFolders': [{'uri': project.as_uri(), 'name': 'configuration'}], 'capabilities': {'textDocument': {'definition': {'linkSupport': False}}}, 'initializationOptions': {'bundles': [str(bundle)], 'extendedClientCapabilities': {'classFileContentsSupport': True}, 'settings': {'java': {'import': {'gradle': {'enabled': False}, 'maven': {'enabled': False}}}}}}) + send({'jsonrpc': '2.0', 'method': 'initialized', 'params': {}}) + send({'jsonrpc': '2.0', 'method': 'textDocument/didOpen', 'params': {'textDocument': {'uri': file.as_uri(), 'languageId': 'java', 'version': 1, 'text': source}}}) + request('java/buildWorkspace', True) + assert 'openidea.java.configuration' in init['capabilities']['executeCommandProvider']['commands'], init + results = {} + for key in ['app', 'vendor', 'app.time-out', 'app.enabled', 'app.nested.url', 'app.clients[0].url', 'app.routes.east.url', 'app.tags[0]', 'app.labels.region', 'app.labels[region.name]', 'constructor.remote-name', 'record.name', 'bean.url', 'app.backup', 'app.required', 'vendor.port', 'extra.url']: + start = time.perf_counter() + result = query(key) + results[key] = {'seconds': time.perf_counter() - start, 'response': result} + assert result['locations'], (key, result) + attached = request('java/classFileContents', {'uri': results['vendor.port']['response']['locations'][0]['uri']}) + assert attached and 'LibrarySettings' in attached and 'port' in attached, attached + if spring_jar: + for key in ['server.port', 'spring.datasource.url', 'spring.jackson.date-format']: + result = query(key) + assert result['locations'], (key, result) + assert all(loc['uri'].startswith('jdt://') for loc in result['locations']), result + results[key] = {'response': result} + if key == 'server.port': + decompiled = request('java/classFileContents', {'uri': result['locations'][0]['uri']}) + assert decompiled and 'ServerProperties' in decompiled and 'port' in decompiled, decompiled + assert not query('app.tags[0].value')['locations'] + assert not query('app.labels.region.coder')['locations'] + assert not query('fake.time-out')['locations'] + assert not query('app.does-not-exist')['locations'] + metadata_file = local_metadata / 'additional-spring-configuration-metadata.json' + metadata_file.write_text(metadata_file.read_text().replace('extra', 'newer')) + assert query('newer.url')['locations'] + assert not query('extra.url')['locations'] + # Property references include member usages and consumers, without declarations. + references = request('workspace/executeCommand', {'command': 'openidea.java.configuration', + 'arguments': [(project / 'application.yml').as_uri(), 'app.time-out', 0, 0, True, False]}) + assert references['locations'], references + assert any(loc['range']['start']['line'] == next(i for i, line in enumerate(source.splitlines()) if 'getProperty(KEY)' in line) for loc in references['locations']), references + # 3 binding targets + @Value + Environment constant; Fake.getProperty excluded. + timeout = query('app.time-out') + assert len(timeout['locations']) == 5, timeout + for needle, expected in [('int timeOut;', 'app.timeOut'), ('"${app.time-out:30}"', 'app.time-out'), ('env.getProperty(KEY)', 'app.time-out')]: + line, text = next((i, s) for i, s in enumerate(source.splitlines()) if needle in s) + col = text.index(needle) + (4 if needle == 'int timeOut;' else (len('env.getProperty(') if needle.startswith('env.') else 4)) + reverse = query(None, file.as_uri(), line, col) + assert expected in names(reverse), (needle, reverse) + warm = [] + for _ in range(20): + start = time.perf_counter(); query('app.time-out'); warm.append(time.perf_counter() - start) + # Unsaved changes must replace old keys, and closing must restore disk keys. + changed = source.replace('prefix="app"', 'prefix="edited"').replace('${app.time-out:30}', '${edited.timeout:30}') + send({'jsonrpc': '2.0', 'method': 'textDocument/didChange', 'params': {'textDocument': {'uri': file.as_uri(), 'version': 2}, 'contentChanges': [{'text': changed}]}}) + edit_start = time.perf_counter() + updated = query('edited.time-out') + edit_ms = (time.perf_counter() - edit_start) * 1000 + assert updated['locations'], updated + assert not query('app.nested.url')['locations'] + send({'jsonrpc': '2.0', 'method': 'textDocument/didClose', 'params': {'textDocument': {'uri': file.as_uri()}}}) + restored = query('app.nested.url') + assert restored['locations'], restored + evidence = {'stress': os.environ.get('OPENIDEA_CONFIGURATION_STRESS') == '1', 'editRebuildMs': edit_ms, 'cases': results, 'warmMedianMs': sorted(warm)[len(warm)//2] * 1000, 'warmMaxMs': max(warm) * 1000} + (work / 'evidence.json').write_text(json.dumps(evidence, indent=2)) + print(json.dumps(evidence, indent=2)) +finally: + (work / 'transcript.json').write_text(json.dumps(transcript, indent=2)) + proc.terminate(); proc.wait(timeout=20) + print('Evidence:', work, file=sys.stderr) From d939a197470a7cead514c5aa78d41fa099e1abe0 Mon Sep 17 00:00:00 2001 From: Andrea Basile Date: Mon, 2 Nov 2026 15:49:04 +0100 Subject: [PATCH 030/106] feat(run): Django test configurations with results in the test tree (#869) Django projects get a detected `manage.py test` configuration next to runserver and a Django tests configuration type (command, runner arguments, target, settings module, options). Results stream into the test tree with failures, errors, skips and subtests, failed tests navigate to their source line, Rerun Failed Tests narrows the command to the failed labels, and single tests run from the gutter. Co-authored-by: andreabasile <7715018+andreabasile@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 --- crates/ide-run/src/detect.rs | 13 + crates/ide-run/src/process.rs | 4 +- crates/ide-run/tests/detect.rs | 9 +- crates/ide-run/tests/process.rs | 7 +- crates/ide-sdk/src/apply.rs | 4 +- crates/ide-sdk/src/tests.rs | 7 + src/features/run/configs.test.ts | 24 ++ src/features/run/context.ts | 16 +- src/features/run/djangoTypes.ts | 121 +++++++ src/features/run/producers.test.ts | 50 +++ src/features/run/producers.ts | 14 +- src/features/run/runConfigTypes.test.tsx | 54 ++++ src/features/run/targets.test.ts | 39 +++ src/features/run/targets.ts | 49 ++- src/features/run/testing/index.ts | 28 +- src/features/run/testing/parsers.test.ts | 11 +- src/features/run/testing/rerun.test.ts | 45 +++ src/features/run/testing/rerun.ts | 7 + src/features/run/testing/states.test.ts | 13 + src/features/run/testing/states.ts | 13 +- src/features/run/testing/unittest.test.ts | 371 ++++++++++++++++++++++ src/features/run/testing/unittest.ts | 169 ++++++++++ src/features/run/types.ts | 4 +- src/platform/locales/text/zh.json | 10 +- src/platform/locales/textCatalog.test.ts | 3 + 25 files changed, 1049 insertions(+), 36 deletions(-) create mode 100644 src/features/run/djangoTypes.ts create mode 100644 src/features/run/testing/unittest.test.ts create mode 100644 src/features/run/testing/unittest.ts diff --git a/crates/ide-run/src/detect.rs b/crates/ide-run/src/detect.rs index 82f365949..0f56ad500 100644 --- a/crates/ide-run/src/detect.rs +++ b/crates/ide-run/src/detect.rs @@ -890,6 +890,19 @@ fn detect_python(root: &Path, out: &mut Vec) { if uses_pytest(root) || env.has_tool(root, "pytest") { out.push(py_config("pytest".into(), "pytest".into(), "pytest", env.tool(root, "pytest"), &[]).test()); } + // After pytest: a project set up for pytest (pytest-django) keeps it as the default test runner. + if root.join("manage.py").is_file() { + out.push( + py_config( + "django:test".into(), + "manage.py test".into(), + "django-test", + env.python(), + &["manage.py", "test"], + ) + .test(), + ); + } } // ---------------------------------------------------------------- make / just diff --git a/crates/ide-run/src/process.rs b/crates/ide-run/src/process.rs index 0808e9522..969114d23 100644 --- a/crates/ide-run/src/process.rs +++ b/crates/ide-run/src/process.rs @@ -317,7 +317,7 @@ impl ProcessManager { return Err(IdeError::invalid("input cannot be redirected from a file when emulating a terminal")); } let mut env = launch_env(root, config)?; - if matches!(config.kind.as_str(), "python" | "pytest") { + if matches!(config.kind.as_str(), "python" | "pytest" | "django-test") { crate::python_path::extend(&mut env.vars, &options.python_paths, !env.clear)?; } let on_output: OutputFn = Arc::from(on_output); @@ -745,7 +745,7 @@ fn launch_env(root: &Path, config: &RunConfig) -> IdeResult { let paths: Vec = extras.env_files.iter().map(|f| resolve_cwd(root, Some(Path::new(f)))).collect(); crate::envfile::load(&paths, &base).map_err(|e| IdeError::not_found(format!("cannot read .env file: {e}")))? }; - if matches!(config.kind.as_str(), "python" | "pytest") + if matches!(config.kind.as_str(), "python" | "pytest" | "django-test") && !config.env.contains_key("PYTHONUNBUFFERED") && !vars.contains_key("PYTHONUNBUFFERED") { diff --git a/crates/ide-run/tests/detect.rs b/crates/ide-run/tests/detect.rs index 578019334..48db7c561 100644 --- a/crates/ide-run/tests/detect.rs +++ b/crates/ide-run/tests/detect.rs @@ -372,13 +372,19 @@ fn python_projects() { ("manage.py", ""), ]); let c = detect(dir.path()); - assert_eq!(ids(&c), ["django:runserver", "python:module:mypkg", "pytest"]); + // pytest stays ahead of `manage.py test`: the first test configuration is the default for context runs. + assert_eq!(ids(&c), ["django:runserver", "python:module:mypkg", "pytest", "django:test"]); let pytest = find(&c, "pytest"); assert_eq!(cmdline(pytest), "uv run pytest"); assert!(pytest.is_test); assert_eq!(pytest.debug_adapter.as_deref(), Some("debugpy")); assert_eq!(cmdline(find(&c, "python:module:mypkg")), "uv run python -m mypkg"); assert_eq!(cmdline(find(&c, "django:runserver")), "uv run python manage.py runserver"); + let django = find(&c, "django:test"); + assert_eq!(cmdline(django), "uv run python manage.py test"); + assert_eq!(django.kind, "django-test"); + assert!(django.is_test); + assert_eq!(django.debug_adapter.as_deref(), Some("debugpy")); let dir = project(&[("pytest.ini", "[pytest]\n"), ("poetry.lock", "")]); assert_eq!(cmdline(find(&detect(dir.path()), "pytest")), "poetry run pytest"); @@ -398,6 +404,7 @@ fn python_virtualenv() { let c = detect(dir.path()); assert_eq!(cmdline(find(&c, "pytest")), ".venv/bin/pytest"); assert_eq!(cmdline(find(&c, "django:runserver")), ".venv/bin/python manage.py runserver"); + assert_eq!(cmdline(find(&c, "django:test")), ".venv/bin/python manage.py test"); } #[cfg(unix)] diff --git a/crates/ide-run/tests/process.rs b/crates/ide-run/tests/process.rs index c4af2bcaa..176be79c9 100644 --- a/crates/ide-run/tests/process.rs +++ b/crates/ide-run/tests/process.rs @@ -447,7 +447,7 @@ fn build_tools_get_the_console_encoding_for_every_jvm() { #[test] fn python_scripts_import_sibling_packages_and_preserve_env_file_paths() { - for (kind, source_dir) in [("python", ""), ("pytest", "src")] { + for (kind, source_dir) in [("python", ""), ("pytest", "src"), ("django-test", "")] { let h = harness(); let root = h.root.path(); let source = root.join(source_dir); @@ -465,6 +465,11 @@ fn python_scripts_import_sibling_packages_and_preserve_env_file_paths() { let mut config = cfg("python-imports", "python3", &["app/main.py"]); config.kind = kind.into(); config.extras.env_files = vec![".env".into()]; + // Unbuffered, so output printed by a test stays next to its result line. + assert_eq!( + ide_run::resolved_env(root, &config).unwrap().get("PYTHONUNBUFFERED").map(String::as_str), + Some("1") + ); let options = StartOptions { python_paths: vec![root.to_owned(), source], ..Default::default() }; let chunks: Chunks = Arc::default(); let sink = chunks.clone(); diff --git a/crates/ide-sdk/src/apply.rs b/crates/ide-sdk/src/apply.rs index 8c484f9fd..ec173c954 100644 --- a/crates/ide-sdk/src/apply.rs +++ b/crates/ide-sdk/src/apply.rs @@ -629,10 +629,10 @@ pub fn apply_to_run_configs(configs: &mut [RunConfig], root: &Path, s: &Settings resolve_run_command(c, root, s); let mut path_dirs: Vec = Vec::new(); match c.kind.as_str() { - "python" | "pytest" => { + "python" | "pytest" | "django-test" => { if let Some(py) = &explicit_python { let runner = PY_RUNNERS.contains(&c.command.as_str()); - if c.kind == "python" && (runner || is_python_command(&c.command)) { + if (c.kind == "python" || c.kind == "django-test") && (runner || is_python_command(&c.command)) { if runner && c.args.first().map(String::as_str) == Some("run") { // `uv run python …` → ` …` let skip = if c.args.get(1).is_some_and(|a| is_python_command(a)) { 2 } else { 1 }; diff --git a/crates/ide-sdk/src/tests.rs b/crates/ide-sdk/src/tests.rs index 3e121f241..e777b10b1 100644 --- a/crates/ide-sdk/src/tests.rs +++ b/crates/ide-sdk/src/tests.rs @@ -152,6 +152,8 @@ fn run_configs_use_selected_interpreter_and_package_manager() { run("pytest", "uv", &["run", "pytest", "-x"]), run("npm", "npm", &["run", "dev"]), run("shell", "python3", &["x.py"]), + run("django-test", "uv", &["run", "python", "manage.py", "test"]), + run("django-test", ".venv/bin/python", &["manage.py", "test", "polls"]), ]; apply_to_run_configs(&mut configs, tmp.path(), &s); assert_eq!(configs[0].command, "/opt/py/bin/python3"); @@ -161,6 +163,11 @@ fn run_configs_use_selected_interpreter_and_package_manager() { assert_eq!(configs[2].args, ["-m", "pytest", "-x"]); assert_eq!(configs[3].command, "pnpm"); assert_eq!(configs[4].command, "python3", "shell configs are left alone"); + // Django tests run on the selected interpreter, like `manage.py runserver`. + assert_eq!(configs[5].command, "/opt/py/bin/python3"); + assert_eq!(configs[5].args, ["manage.py", "test"]); + assert_eq!(configs[6].command, "/opt/py/bin/python3"); + assert_eq!(configs[6].args, ["manage.py", "test", "polls"]); } fn fake_jdk(dir: &Path) { diff --git a/src/features/run/configs.test.ts b/src/features/run/configs.test.ts index 0f61d66c2..ce70a443f 100644 --- a/src/features/run/configs.test.ts +++ b/src/features/run/configs.test.ts @@ -158,6 +158,9 @@ describe("context configuration", () => { expect(fileRole("src/a.test.ts")).toBe("test"); expect(fileRole("pkg/x_test.go")).toBe("test"); expect(fileRole("tests/test_api.py")).toBe("test"); + // Django's default test module (pattern `test*.py`); a script outside Django projects. + expect(fileRole("polls/tests.py", undefined, false, true)).toBe("test"); + expect(fileRole("polls/tests.py")).toBe("script"); expect(fileRole("src/test/kotlin/OrderControllerTest.kt")).toBe("test"); expect(fileRole("tests/integration.rs")).toBe("test"); expect(fileRole("src/main.rs")).toBe("main"); @@ -185,6 +188,27 @@ describe("context configuration", () => { expect(contextConfig("internal/calc/calc_test.go", configs)!.args).toEqual(["test", "./internal/calc"]); expect(contextConfig("tests/api.rs", configs)!.args).toEqual(["test", "--test", "api"]); expect(contextConfig("tests/test_math.py", configs)!.args).toEqual(["run", "pytest", "tests/test_math.py"]); + // `tests.py` is a test module in Django projects only. + expect(contextConfig("polls/tests.py", configs)).toMatchObject({ + kind: "python", + isTest: false, + command: ".venv/bin/python", + args: ["polls/tests.py"], + }); + const django = cfg({ + id: "django:test", + kind: "django-test", + command: "uv", + args: ["run", "python", "manage.py", "test"], + isTest: true, + }); + expect(contextConfig("polls/tests.py", [django])!.args).toEqual([ + "run", + "python", + "manage.py", + "test", + "polls.tests", + ]); expect(contextConfig("src/test/kotlin/OrderControllerTest.kt", configs)!.args).toEqual([ "test", "--tests", diff --git a/src/features/run/context.ts b/src/features/run/context.ts index acab57def..768e3c017 100644 --- a/src/features/run/context.ts +++ b/src/features/run/context.ts @@ -4,17 +4,20 @@ * direct interpreter invocation for main files. Best effort, IDEA-style. */ import type { RunConfig } from "@/platform/ipc"; +import { djangoLabel, djangoTestsOf, hasDjangoTests } from "./djangoTypes"; import { goTestParts } from "./goArguments"; import { isWindows } from "./jvmTypes"; export type FileRole = "test" | "main" | "script" | null; -export function fileRole(relPath: string, content?: string, windows = isWindows()): FileRole { +/** `django`: a Django project, whose default test module is `tests.py` (elsewhere a script). */ +export function fileRole(relPath: string, content?: string, windows = isWindows(), django = false): FileRole { const name = relPath.split("/").pop() ?? relPath; if ( /\.(test|spec)\.[cm]?[jt]sx?$/.test(name) || /(^|\/)__tests__\//.test(relPath) || /^test_.*\.py$|_test\.py$/.test(name) || + (django && name === "tests.py") || /_test\.go$/.test(name) || /(Test|Tests|Spec|IT)\.(kt|java|scala|groovy)$/.test(name) || (/(^|\/)tests\/[^/]+\.rs$/.test(relPath) && name !== "mod.rs") @@ -35,7 +38,7 @@ const stem = (p: string) => (p.split("/").pop() ?? p).replace(/\.[^.]+$/, ""); /** Kinds whose test configs can run a file of this extension. */ function testKinds(relPath: string): string[] { if (/\.[cm]?[jt]sx?$/.test(relPath)) return ["npm", "deno", "bun"]; - if (/\.py$/.test(relPath)) return ["pytest", "python"]; + if (/\.py$/.test(relPath)) return ["pytest", "python", "django-test"]; if (/\.go$/.test(relPath)) return ["go"]; if (/\.rs$/.test(relPath)) return ["cargo"]; if (/\.(kt|java|scala|groovy)$/.test(relPath)) return ["gradle", "maven"]; @@ -61,7 +64,8 @@ function closest(configs: RunConfig[], relPath: string): RunConfig | undefined { return allClosest(configs, relPath)[0]; } -function relToCwd(relPath: string, config: RunConfig): string { +/** `relPath` relative to the working directory of `config`. */ +export function relToCwd(relPath: string, config: RunConfig): string { const cwd = (config.kind === "go" ? (config.params?.module ?? config.cwd ?? "") : (config.cwd ?? "")) .replace(/^\.\/?/, "") .replace(/\/+$/, ""); @@ -156,6 +160,10 @@ export function testConfigFor(relPath: string, configs: RunConfig[], base?: RunC case "pytest": case "python": return derived(base, relPath, { args: [...base.args, file] }); + case "django-test": { + const tests = djangoTestsOf(base, [djangoLabel(file)]); + return tests ? derived(base, relPath, tests) : null; + } case "go": { const dir = dirOf(file); const target = dir ? `./${dir}` : "."; @@ -248,7 +256,7 @@ export function contextConfig( content?: string, windows = isWindows(), ): RunConfig | null { - const role = fileRole(relPath, content, windows); + const role = fileRole(relPath, content, windows, hasDjangoTests(configs)); if (role === "test") return testConfigFor(relPath, configs); if (role === "main") return mainConfigFor(relPath, configs) ?? scriptConfigFor(relPath, configs, windows); if (role === "script") return scriptConfigFor(relPath, configs, windows); diff --git a/src/features/run/djangoTypes.ts b/src/features/run/djangoTypes.ts new file mode 100644 index 000000000..f5817ead1 --- /dev/null +++ b/src/features/run/djangoTypes.ts @@ -0,0 +1,121 @@ +/** + * Django tests (PyCharm's "Django tests" configuration): `manage.py test` with a + * target (app labels, modules or dotted test paths), a settings module and + * further options. The command is the interpreter (`python3`, `.venv/bin/python`, + * `uv`), the runner args its leading arguments (`run python` for uv / poetry). + */ +import type { RunConfig } from "@/platform/ipc"; +import type { RunConfigTypeContribution } from "@/workbench/contributions"; +import { formatArgs, parseArgs } from "./configs"; + +/** A Django project: it has a Django tests configuration (detected from `manage.py`, or made in the form). */ +export const hasDjangoTests = (configs: readonly RunConfig[]) => configs.some((c) => c.kind === "django-test"); + +/** Index of `manage.py` when `test` follows it, else -1. */ +function manageTest(args: readonly string[]): number { + return args.findIndex((a, i) => /(^|[/\\])manage\.py$/.test(a) && args[i + 1] === "test"); +} + +/** Number of leading positional arguments (the test labels). */ +const labelCount = (args: readonly string[]) => { + const n = args.findIndex((a) => a.startsWith("-")); + return n < 0 ? args.length : n; +}; + +/** Dotted test label of a module file: `polls/tests.py` → `polls.tests`, `polls/tests/__init__.py` → `polls.tests`. */ +export function djangoLabel(relPath: string): string { + return relPath + .replace(/\.py$/, "") + .replace(/(^|\/)__init__$/, "") + .replace(/\//g, "."); +} + +/** + * `args` of a `manage.py test` command line running `labels` in place of its + * own target (the labels right after `test`); null for other command lines. + */ +function withDjangoLabels(args: readonly string[], labels: readonly string[]): string[] | null { + const at = manageTest(args); + if (at < 0) return null; + const rest = args.slice(at + 2); + return [...args.slice(0, at + 2), ...labels, ...rest.slice(labelCount(rest))]; +} + +/** + * The command line (and, for a configuration made in the form, the Target field) + * of `config` running `labels`; null when it doesn't run `manage.py test`. + */ +export function djangoTestsOf(config: RunConfig, labels: readonly string[]): Pick | null { + const args = withDjangoLabels(config.args, labels); + if (!args) return null; + return config.params?.target === undefined + ? { args } + : { args, params: { ...config.params, target: formatArgs([...labels]) } }; +} + +export const djangoTestType: RunConfigTypeContribution = { + id: "django-test", + label: "Django tests", + icon: "file-python", + description: "Run Django tests with manage.py test", + defaults: { command: "python3" }, + debugAdapter: "debugpy", + fields: [ + { + key: "command", + label: "Command", + mono: true, + placeholder: "python3", + required: true, + hint: "e.g. python3, .venv/bin/python, uv, poetry", + }, + { + key: "runnerArgs", + label: "Runner arguments", + mono: true, + placeholder: "run python", + hint: "e.g. `run python` for uv/poetry; empty calls the interpreter itself", + }, + { + key: "target", + label: "Target", + mono: true, + placeholder: "polls.tests", + hint: "App labels, modules or dotted test paths; empty runs every test", + }, + { + key: "settings", + label: "Settings module", + mono: true, + placeholder: "mysite.settings_test", + hint: "Passed as --settings; empty keeps DJANGO_SETTINGS_MODULE or the default in manage.py", + }, + { key: "options", label: "Options", mono: true, placeholder: "e.g. --keepdb --parallel" }, + ], + commandLine(p) { + const args = [...parseArgs(p.runnerArgs ?? ""), "manage.py", "test", ...parseArgs(p.target ?? "")]; + if (p.settings?.trim()) args.push("--settings", p.settings.trim()); + args.push(...parseArgs(p.options ?? "")); + return { command: p.command?.trim() || "python3", args }; + }, + paramsFrom(c) { + if (c.kind !== "django-test") return null; + const at = manageTest(c.args); + if (at < 0) return null; + const rest = c.args.slice(at + 2); + const n = labelCount(rest); + let options = rest.slice(n); + let settings = ""; + if (options[0] === "--settings" && options[1] !== undefined) { + settings = options[1]; + options = options.slice(2); + } + return { + command: c.command, + runnerArgs: formatArgs(c.args.slice(0, at)), + target: formatArgs(rest.slice(0, n)), + settings, + options: formatArgs(options), + }; + }, +}; diff --git a/src/features/run/producers.test.ts b/src/features/run/producers.test.ts index 64a102519..2fb9cb445 100644 --- a/src/features/run/producers.test.ts +++ b/src/features/run/producers.test.ts @@ -498,6 +498,56 @@ describe("project producers", () => { expect(produceContextChoices({ ...input, configs }, ALL)).toHaveLength(1); }); + it("django: tests.py, TestCase classes and test packages run through manage.py test", () => { + const django = [ + cfg({ id: "django:runserver", kind: "python", command: ".venv/bin/python", args: ["manage.py", "runserver"] }), + cfg({ + id: "django:test", + kind: "django-test", + command: ".venv/bin/python", + args: ["manage.py", "test", "--keepdb"], + isTest: true, + }), + ]; + const src = [ + "from django.test import TestCase", + "", + "class QuestionTests(TestCase):", + " def test_recent(self):", + " pass", + ].join("\n"); + const input = { path: `${ROOT}/polls/tests.py`, root: ROOT, configs: django, content: src }; + expect(produceContext({ ...input, line: 4 }, ALL)).toMatchObject({ + name: "QuestionTests.test_recent", + config: { kind: "django-test", args: ["manage.py", "test", "polls.tests.QuestionTests.test_recent", "--keepdb"] }, + }); + expect(produceContext({ ...input, line: 2 }, ALL)!.config!.args).toEqual([ + "manage.py", + "test", + "polls.tests.QuestionTests", + "--keepdb", + ]); + expect(produceContext(input, ALL)).toMatchObject({ + name: "tests.py", + config: { args: ["manage.py", "test", "polls.tests", "--keepdb"] }, + }); + expect(produceDirectory({ path: `${ROOT}/polls/tests`, root: ROOT, configs: django })).toMatchObject({ + name: "Tests in 'tests'", + config: { args: ["manage.py", "test", "polls.tests", "--keepdb"] }, + }); + expect(produceDirectory({ path: `${ROOT}/polls/migrations`, root: ROOT, configs: django })).toBeNull(); + // Outside Django projects `tests.py` is a script: no test targets, and the file runs as one. + const plain = { ...input, configs: [configs[1]!] }; + expect(fileTargets(plain, src)).toEqual([]); + expect(produceContext(plain, ALL)).toMatchObject({ name: "tests.py", config: { kind: "python", isTest: false } }); + // pytest detected as well (pytest-django): the user chooses, pytest first. + const both = [configs[1]!, ...django]; + expect(produceContextChoices({ ...input, configs: both, line: 4 }, ALL).map((c) => c.detail)).toEqual([ + "pytest", + "django:test", + ]); + }); + it("python: test function > test class > file; __main__ runs the file", () => { const src = [ "class TestMath:", diff --git a/src/features/run/producers.ts b/src/features/run/producers.ts index 6a784cad4..c0425628f 100644 --- a/src/features/run/producers.ts +++ b/src/features/run/producers.ts @@ -17,6 +17,7 @@ import { formatArgs } from "./configs"; import { goGenerateConfig } from "./goGenerate"; import { goTestParts } from "./goArguments"; import { contextConfig, fileRole, projectPython, testBasesFor, testConfigFor } from "./context"; +import { djangoLabel, djangoTestsOf, hasDjangoTests } from "./djangoTypes"; import { isWindows } from "./jvmTypes"; import { detectRunTargets, targetConfig, targetLanguage, type RunTarget } from "./targets"; import { tx } from "@/platform/i18n"; @@ -590,7 +591,7 @@ export function produceFile(input: ContextInput, env: ToolEnv): Produced | null } if (/\.t?scn$/i.test(rel)) return godotScene(rel, configs); // Project-aware: test files through the project's test runner, Cargo / Go / Gradle / Maven mains. - const role = fileRole(rel, input.content); + const role = fileRole(rel, input.content, undefined, hasDjangoTests(configs)); if (role === "test") { const found = testConfigFor(rel, configs); const test = found && goTestNamed(found, input); @@ -823,7 +824,7 @@ export function fileTargets(input: Pick t.kind === "main" && (!t.className || /\.(java|kt)$/.test(input.path)), ); } - return detectRunTargets(content, s.rel); + return detectRunTargets(content, s.rel, hasDjangoTests(input.configs)); } function elementProduction(input: ContextInput, env: ToolEnv, target: RunTarget): Produced | null { @@ -969,10 +970,19 @@ export function produceDirectory(input: ContextInput): Produced | null { } const js = closest(["npm", "bun", "deno"]); const py = closest(["pytest"]); + const django = closest(["django-test"]); const go = closest(["go"]); const cargo = closest(["cargo"]); if (cargo && /(^|\/)tests$/.test(rel) && !js && !py) return make(cargo, "All Tests", [...cargo.args, "--tests"]); if (py && (!js || /(^|\/)tests?$/.test(rel))) return make(py, testsIn, [...py.args, relToCwd(py)]); + // Django runs a test package by its dotted label (`polls/tests` → `polls.tests`). + if (django && !js && relToCwd(django)) { + const tests = djangoTestsOf(django, [djangoLabel(relToCwd(django))]); + if (tests) { + const produced = make(django, testsIn, tests.args); + return { ...produced, config: { ...produced.config!, ...tests } }; + } + } if (js) { const dir = relToCwd(js); if (js.kind === "deno") return make(js, testsIn, [...js.args, dir]); diff --git a/src/features/run/runConfigTypes.test.tsx b/src/features/run/runConfigTypes.test.tsx index d37509ca5..fd207b13f 100644 --- a/src/features/run/runConfigTypes.test.tsx +++ b/src/features/run/runConfigTypes.test.tsx @@ -172,6 +172,60 @@ describe("configuration types", () => { expect(godot.paramsFrom!(cfg("x", { command: "cargo" }))).toBeNull(); }); + it("Django tests round-trip commandLine / paramsFrom and always report to the test tree", () => { + const django = typeFor("django-test")!; + expect(django.debugAdapter).toBe("debugpy"); + expect(newConfigOfType(django, [])).toMatchObject({ + kind: "django-test", + command: "python3", + args: ["manage.py", "test"], + env: {}, + isTest: true, + debugAdapter: "debugpy", + }); + // No settings module: neither `--settings` nor DJANGO_SETTINGS_MODULE, manage.py's default applies. + expect(django.commandLine({ command: "python3", settings: " " })).toEqual({ + command: "python3", + args: ["manage.py", "test"], + }); + + const cases: Array<{ command: string; args: string[]; params: Record }> = [ + { + command: ".venv/bin/python", + args: ["manage.py", "test"], + params: { command: ".venv/bin/python", runnerArgs: "", target: "", settings: "", options: "" }, + }, + { + command: "uv", + args: ["run", "python", "manage.py", "test", "polls", "blog.tests.PostTests", "--settings", "mysite.test"], + params: { + command: "uv", + runnerArgs: "run python", + target: "polls blog.tests.PostTests", + settings: "mysite.test", + options: "", + }, + }, + { + command: "python3", + args: ["manage.py", "test", "--keepdb", "--parallel", "4"], + params: { command: "python3", runnerArgs: "", target: "", settings: "", options: "--keepdb --parallel 4" }, + }, + ]; + for (const { command, args, params } of cases) { + const c = cfg("django:test", { kind: "django-test", command, args, detected: true }); + expect(django.paramsFrom!(c)).toEqual(params); + expect(formParams(c)).toEqual(params); + expect(django.commandLine(params)).toEqual({ command, args }); + } + // Saved from the form, a plain command line becomes a test configuration. + expect(withParams(cfg("d", { kind: "django-test" }), cases[1]!.params).isTest).toBe(true); + // Not `manage.py test`: edited as a plain command. + expect( + django.paramsFrom!(cfg("d", { kind: "django-test", command: "python3", args: ["manage.py", "check"] })), + ).toBeNull(); + }); + it("pytest round-trips commandLine / paramsFrom and opens implicit context configs in the form", () => { const pytest = typeFor("pytest")!; expect(pytest.debugAdapter).toBe("debugpy"); diff --git a/src/features/run/targets.test.ts b/src/features/run/targets.test.ts index e0053b2e2..263b34a49 100644 --- a/src/features/run/targets.test.ts +++ b/src/features/run/targets.test.ts @@ -89,6 +89,24 @@ describe("detectRunTargets", () => { ]); }); + it("python: unittest TestCase subclasses in tests.py (Django's default test module)", () => { + const src = [ + "from django.test import TestCase", + "class QuestionTests(TestCase):", + " def test_recent(self):", + " pass", + "class Helper(Mixin):", + " def test_no(self): pass", + ].join("\n"); + expect(pick(detectRunTargets(src, "polls/tests.py", true))).toEqual([ + [1, "suite", "QuestionTests"], + [2, "test", "QuestionTests/test_recent"], + ]); + expect(pick(detectRunTargets(src, "polls/models.py", true))).toEqual([]); + // Outside Django projects `tests.py` is a script: no test targets. + expect(pick(detectRunTargets(src, "polls/tests.py"))).toEqual([]); + }); + it("go and jvm", () => { expect( pick(detectRunTargets("package x\n\nfunc TestAdd(t *testing.T) {}\nfunc helper() {}", "pkg/add_test.go")), @@ -160,6 +178,27 @@ describe("targetConfig", () => { expect(first("class TestC:\n def test_a(self): pass", "tests/test_c.py").args).toEqual([ "tests/test_c.py::TestC::test_a", ]); + // pytest also collects TestCase subclasses. + expect(first("class QTests(TestCase):\n def test_a(self): pass", "app/test_q.py").args).toEqual([ + "app/test_q.py::QTests::test_a", + ]); + const django = [cfg({ id: "django:test", kind: "django-test", command: "python3", args: ["manage.py", "test"] })]; + const test = detectRunTargets("class QTests(TestCase):\n def test_a(self): pass", "polls/tests.py", true)[1]!; + expect(targetConfig(test, "polls/tests.py", django)!.args).toEqual([ + "manage.py", + "test", + "polls.tests.QTests.test_a", + ]); + // A module-level function or a plain class: unittest's loader would call it, so no Django run. + const fn = detectRunTargets("def test_top():\n pass", "polls/tests.py", true)[0]!; + expect(targetConfig(fn, "polls/tests.py", django)).toBeNull(); + // Relative to the working directory, where manage.py is. + const nested = [{ ...django[0]!, cwd: "backend" }]; + expect(targetConfig(test, "backend/polls/tests/test_q.py", nested)!.args).toEqual([ + "manage.py", + "test", + "polls.tests.test_q.QTests.test_a", + ]); expect(first("func TestAdd(t *testing.T) {}", "pkg/add_test.go").args).toEqual([ "test", "./pkg", diff --git a/src/features/run/targets.ts b/src/features/run/targets.ts index 2d4efff4f..cb28903d4 100644 --- a/src/features/run/targets.ts +++ b/src/features/run/targets.ts @@ -1,7 +1,8 @@ /** * Run targets inside a file, for the gutter ▶ icons and "Run test" code lenses: * `fn main`, `#[test]` functions and `mod tests`, JS/TS `test`/`it`/`describe`, - * pytest `def test_*` / `class Test*`, Go `func TestX(t *testing.T)`, JUnit `@Test`. + * pytest `def test_*` / `class Test*`, unittest `TestCase` subclasses, Go `func TestX(t *testing.T)`, + * JUnit `@Test`. * Line-based regex detection (cheap, no parser), plus the run configuration for * one target derived from the project's detected test configurations. */ @@ -9,7 +10,8 @@ import { formatArgs } from "./configs"; import type { RunConfig } from "@/platform/ipc"; import { applicationConfig, jvmTestConfig } from "./application"; import { goTestParts, withoutGoSelection } from "./goArguments"; -import { contextConfig, testConfigFor } from "./context"; +import { contextConfig, relToCwd, testConfigFor } from "./context"; +import { djangoLabel, djangoTestsOf } from "./djangoTypes"; import { scanJvmTargets, simpleClassName } from "./jvmTargets"; import { escapeRegExp } from "./testing/rerun"; @@ -29,6 +31,8 @@ export interface RunTarget { className?: string; /** JVM: a `@SpringBootApplication` main class. */ spring?: boolean; + /** Python: a unittest `TestCase` subclass or one of its methods (what Django's runner can run). */ + unittest?: boolean; } /** Above this size we skip detection (generated / minified files). */ @@ -142,23 +146,33 @@ function jsTargets(lines: string[]): RunTarget[] { function pythonTargets(lines: string[]): RunTarget[] { const out: RunTarget[] = []; - const classes: Array<{ indent: number; name: string; test: boolean }> = []; + const classes: Array<{ indent: number; name: string; test: boolean; unittest: boolean }> = []; for (let i = 0; i < lines.length; i++) { const line = lines[i]!; if (!line.trim() || /^\s*#/.test(line)) continue; const indent = indentOf(line); while (classes.length && classes[classes.length - 1]!.indent >= indent) classes.pop(); - const cls = /^\s*class\s+(\w+)/.exec(line); + const cls = /^\s*class\s+(\w+)\s*(?:\(([^)]*))?/.exec(line); if (cls) { - const test = /^Test/.test(cls[1]!) && classes.every((c) => c.test); - classes.push({ indent, name: cls[1]!, test }); - if (test) out.push({ line: i, kind: "suite", name: cls[1]!, path: classes.map((c) => c.name) }); + // pytest collects `Test*` classes; unittest (and Django) any `TestCase` subclass. + const unittest = /TestCase\b/.test(cls[2] ?? ""); + const test = (/^Test/.test(cls[1]!) || unittest) && classes.every((c) => c.test); + classes.push({ indent, name: cls[1]!, test, unittest }); + if (test) + out.push({ + line: i, + kind: "suite", + name: cls[1]!, + path: classes.map((c) => c.name), + ...(unittest ? { unittest } : {}), + }); continue; } const def = /^\s*(?:async\s+)?def\s+(test\w*)\s*\(/.exec(line); if (def && classes.every((c) => c.test)) { const path = [...classes.map((c) => c.name), def[1]!]; - out.push({ line: i, kind: "test", name: path.join("."), path }); + const unittest = classes.length > 0 && classes[classes.length - 1]!.unittest; + out.push({ line: i, kind: "test", name: path.join("."), path, ...(unittest ? { unittest } : {}) }); continue; } if (indent === 0 && /^if\s+__name__\s*==\s*['"]__main__['"]\s*:/.test(line)) @@ -298,8 +312,11 @@ function jvmTargets(lines: string[], relPath: string): RunTarget[] { return out; } -/** Run targets of a file (relative path picks the language). */ -export function detectRunTargets(text: string, relPath: string): RunTarget[] { +/** + * Run targets of a file (relative path picks the language). `django`: a Django project, where + * `tests.py` (Django's default test module) holds tests too. + */ +export function detectRunTargets(text: string, relPath: string, django = false): RunTarget[] { if (text.length > MAX_SCAN) return []; const lines = text.split("\n"); switch (targetLanguage(relPath)) { @@ -309,7 +326,10 @@ export function detectRunTargets(text: string, relPath: string): RunTarget[] { return /\.(test|spec)\.[cm]?[jt]sx?$|(^|\/)__tests__\//.test(relPath) ? jsTargets(lines) : []; case "python": return pythonTargets(lines).filter( - (t) => t.kind === "main" || /(^|\/)(test_[^/]*|[^/]*_test)\.py$|(^|\/)tests?\//.test(relPath), + (t) => + t.kind === "main" || + /(^|\/)(test_[^/]*|[^/]*_test)\.py$|(^|\/)tests?\//.test(relPath) || + (django && /(^|\/)tests\.py$/.test(relPath)), ); case "go": return goTargets(lines).filter((t) => t.kind === "main" || t.kind === "generate" || /_test\.go$/.test(relPath)); @@ -372,6 +392,13 @@ export function targetConfig( case "pytest": case "python": return named(withLast(file.args, (f) => `${f}::${target.path.join("::")}`)); + case "django-test": { + // unittest's loader would call a module-level `test_*` function (or a plain class) to build the suite. + if (!target.unittest) return null; + const label = [djangoLabel(relToCwd(relPath, file)), ...target.path].join("."); + const tests = djangoTestsOf(file, [label]); + return tests ? { ...named(tests.args), ...(tests.params ? { params: tests.params } : {}) } : null; + } case "go": { const pattern = `^${target.path.map(escapeRegExp).join("$/^")}$`; const benchmark = target.path[0]?.startsWith("Benchmark"); diff --git a/src/features/run/testing/index.ts b/src/features/run/testing/index.ts index 93579630e..56fcc76cb 100644 --- a/src/features/run/testing/index.ts +++ b/src/features/run/testing/index.ts @@ -5,12 +5,13 @@ import { goParser } from "./go"; import { jsParser } from "./js"; import { jvmParser } from "./jvm"; import { pytestParser } from "./pytest"; +import { unittestParser } from "./unittest"; import type { TestEvent, TestOutputParser } from "./types"; export type { TestEvent, TestOutputParser, TestStatus } from "./types"; export { parseDuration } from "./types"; export { TestResults, nodeStatus, type TestNode } from "./results"; -export { cargoParser, goParser, jsParser, jvmParser, pytestParser }; +export { cargoParser, goParser, jsParser, jvmParser, pytestParser, unittestParser }; /** Runs several parsers side by side (unknown tool: e.g. `make test`). */ export function combinedParser(parsers: TestOutputParser[]): TestOutputParser { @@ -20,7 +21,7 @@ export function combinedParser(parsers: TestOutputParser[]): TestOutputParser { }; } -export type Framework = "cargo" | "js" | "pytest" | "go" | "jvm" | "auto"; +export type Framework = "cargo" | "js" | "pytest" | "unittest" | "go" | "jvm" | "auto"; export function frameworkFor(config: Pick): Framework { const cmd = [config.command, ...config.args].join(" "); @@ -32,8 +33,12 @@ export function frameworkFor(config: Pick>(config: T): T { if (frameworkFor(config) === "go" && config.args[0] === "test") { @@ -64,6 +70,10 @@ export function withTestReporting /^(-v|--verbosity)(=?\d)?$/.test(a)); + return chosen ? config : { ...config, args: [...config.args, "-v", "2"] }; + } if (frameworkFor(config) !== "pytest") return config; const cmd = [config.command, ...config.args]; const pytestAt = cmd.findIndex((a) => /(^|[/\\])pytest(\.exe)?$/.test(a)); @@ -80,12 +90,14 @@ export function createTestParser(config: Pick { expect(frameworkFor(cfg("cargo", "cargo", ["test"]))).toBe("cargo"); expect(frameworkFor(cfg("npm", "pnpm", ["run", "test"]))).toBe("js"); expect(frameworkFor(cfg("pytest", "uv", ["run", "pytest"]))).toBe("pytest"); + expect(frameworkFor(cfg("django-test", ".venv/bin/python", ["manage.py", "test"]))).toBe("unittest"); + expect(frameworkFor(cfg("shell", "python3 manage.py test polls"))).toBe("unittest"); + expect(frameworkFor(cfg("python", ".venv/bin/python", ["manage.py", "test"]))).toBe("unittest"); + expect(frameworkFor(cfg("python", ".venv/bin/python", ["-m", "pytest"]))).toBe("pytest"); expect(frameworkFor(cfg("go", "go", ["test", "./..."]))).toBe("go"); expect(frameworkFor(cfg("gradle", "./gradlew", ["test"]))).toBe("jvm"); expect(frameworkFor(cfg("shell", "npx vitest run"))).toBe("js"); @@ -417,9 +421,14 @@ describe("parser selection", () => { it("the combined parser understands any supported format", () => { const r = results( createTestParser(cfg("make", "make", ["test"])), - "test a ... ok\n--- PASS: TestB (0.00s)\nok \tpkg\t0.1s", + "test a ... ok\n--- PASS: TestB (0.00s)\nok \tpkg\t0.1s\ntest_c (app.tests.CTests.test_c) ... ok\n" + + "Finished (0.5s)\ncompile (gcc.exe)", ); expect(leaf(r, "a")?.own).toBe("passed"); + expect(leaf(r, "app.tests", "CTests", "test_c")?.own).toBe("passed"); + // Lines of other tools that only look like a unittest header make no tests. + expect(r.roots.map((n) => n.name)).not.toContain("0"); + expect(r.roots.map((n) => n.name)).not.toContain("gcc"); expect(leaf(r, "pkg", "TestB")?.own).toBe("passed"); }); }); diff --git a/src/features/run/testing/rerun.test.ts b/src/features/run/testing/rerun.test.ts index ea426180f..4379d855e 100644 --- a/src/features/run/testing/rerun.test.ts +++ b/src/features/run/testing/rerun.test.ts @@ -103,6 +103,32 @@ describe("rerunFailedConfig", () => { expect(c.args).toEqual(["-q", "tests/test_x.py::TestC::test_y[1]", "tests/test_z.py::test_a"]); }); + it("django: dotted labels in place of the run's target, options kept", () => { + const c = rerunFailedConfig( + cfg("django-test", ".venv/bin/python", ["manage.py", "test", "polls", "--keepdb", "-v", "2"]), + [["polls.tests", "QuestionTests", "test_a"], ["polls.tests", "BrokenSetupTests"], ["broken.tests"]], + )!; + expect(c.args).toEqual([ + "manage.py", + "test", + "polls.tests.QuestionTests.test_a", + "polls.tests.BrokenSetupTests", + "broken.tests", + "--keepdb", + "-v", + "2", + ]); + // A configuration made in the form keeps its Target field in step. + const form = { + ...cfg("django-test", "uv", ["run", "python", "manage.py", "test", "polls"]), + params: { command: "uv", runnerArgs: "run python", target: "polls", settings: "", options: "" }, + }; + expect(rerunFailedConfig(form, [["polls.tests", "QuestionTests", "test_a"]])).toMatchObject({ + args: ["run", "python", "manage.py", "test", "polls.tests.QuestionTests.test_a"], + params: { target: "polls.tests.QuestionTests.test_a" }, + }); + }); + it("go: -run with top-level names and packages", () => { const c = rerunFailedConfig(cfg("go", "go", ["test", "-v", "./..."]), [ ["example.com/m/pkg", "TestAdd"], @@ -152,6 +178,25 @@ describe("withTestReporting", () => { expect(withTestReporting(cfg("pytest", "uv", ["run", "pytest"])).args).toEqual(["run", "pytest", "-v"]); expect(withTestReporting(cfg("pytest", ".venv/bin/pytest", ["-q"])).args).toEqual(["-q"]); expect(withTestReporting(cfg("pytest", "pytest", ["-vv"])).args).toEqual(["-vv"]); + // Django prints one line per test from verbosity 2. + expect(withTestReporting(cfg("django-test", "python3", ["manage.py", "test", "--keepdb"])).args).toEqual([ + "manage.py", + "test", + "--keepdb", + "-v", + "2", + ]); + expect(withTestReporting(cfg("django-test", "python3", ["manage.py", "test", "-v", "3"])).args).toEqual([ + "manage.py", + "test", + "-v", + "3", + ]); + expect(withTestReporting(cfg("shell", "python3", ["manage.py", "test", "--verbosity=0"])).args).toEqual([ + "manage.py", + "test", + "--verbosity=0", + ]); // A plain script run (no pytest) and other frameworks are left alone. expect(withTestReporting(cfg("python", "python3", ["main.py"])).args).toEqual(["main.py"]); expect(withTestReporting(cfg("cargo", "cargo", ["test"])).args).toEqual(["test"]); diff --git a/src/features/run/testing/rerun.ts b/src/features/run/testing/rerun.ts index 2d0bc69a4..46415d2a6 100644 --- a/src/features/run/testing/rerun.ts +++ b/src/features/run/testing/rerun.ts @@ -4,6 +4,7 @@ */ import type { RunConfig } from "@/platform/ipc"; import { formatArgs } from "../configs"; +import { djangoTestsOf } from "../djangoTypes"; import { goTestParts, withoutGoSelection } from "../goArguments"; import { frameworkFor } from "./index"; import { nodeStatus, type TestNode, type TestResults } from "./results"; @@ -157,6 +158,12 @@ export function testsConfig(config: RunConfig, paths: string[][], name: string): case "pytest": args = pytestArgs(config.args, paths); break; + case "unittest": { + // Django: dotted labels (`polls.tests.QuestionTests.test_x`) in place of the run's own target. + const tests = djangoTestsOf(config, unique(paths.map((p) => p.join(".")))); + if (!tests) return null; + return { ...config, ...tests, name, detected: true, env: { ...config.env } }; + } case "go": args = goArgs(config.args, paths); break; diff --git a/src/features/run/testing/states.test.ts b/src/features/run/testing/states.test.ts index dca6da2ec..8ff5698de 100644 --- a/src/features/run/testing/states.test.ts +++ b/src/features/run/testing/states.test.ts @@ -45,6 +45,19 @@ describe("test states for the run gutter", () => { expect(testState({ path: ["math", "adds"], file: "/work/app/src/math.test.ts" })).toBe("passed"); }); + it("matches unittest results rooted at the dotted module of the file", () => { + run([ + { type: "result", path: ["polls.tests", "QuestionTests", "test_recent"], status: "failed" }, + { type: "result", path: ["polls.tests.test_views", "ViewTests", "test_ok"], status: "passed" }, + { type: "result", path: ["città.tests", "QTests", "test_a"], status: "passed" }, + ]); + expect(testState({ path: ["QuestionTests", "test_recent"], file: "/work/app/polls/tests.py" })).toBe("failed"); + expect(testState({ path: ["QuestionTests"], file: "/work/app/polls/tests/__init__.py" })).toBe("failed"); + expect(testState({ path: ["QuestionTests", "test_recent"], file: "/work/app/blog/tests.py" })).toBeNull(); + expect(testState({ path: ["ViewTests", "test_ok"], file: "/work/app/polls/tests/test_views.py" })).toBe("passed"); + expect(testState({ path: ["QTests", "test_a"], file: "/work/app/città/tests.py" })).toBe("passed"); + }); + it("newer runs win and listeners hear about changes", () => { const listener = vi.fn(); const off = onTestStatesChanged(listener); diff --git a/src/features/run/testing/states.ts b/src/features/run/testing/states.ts index bc23a1f91..4f255bb69 100644 --- a/src/features/run/testing/states.ts +++ b/src/features/run/testing/states.ts @@ -2,7 +2,7 @@ * Last known result per test, across runs, for the gutter ▶ icons: like IDEA, a * test (or suite) that failed last time shows a red run icon, a passed one green. * Results are matched to gutter targets by their test path (the trailing name - * parts), plus the file for frameworks whose results are rooted at the file. + * parts), plus the file for frameworks whose results are rooted at the file (or its module). */ import { nodeStatus, type TestNode, type TestResults } from "./results"; import type { TestStatus } from "./types"; @@ -70,8 +70,17 @@ function segmentMatches(result: string, target: string): boolean { const cleanPath = (p: string) => p.replace(/\\/g, "/").replace(/^\.\//, ""); function sameFile(resultRoot: string, relPath: string): boolean { - const a = cleanPath(resultRoot); const b = cleanPath(relPath); + // unittest / Django results are rooted at the dotted module (`polls.tests`). + if ( + /^[\p{L}_][\p{L}\p{N}_]*(\.[\p{L}_][\p{L}\p{N}_]*)*$/u.test(resultRoot) && + !resultRoot.endsWith(".py") && + b.endsWith(".py") + ) { + const module = resultRoot.replace(/\./g, "/"); + return [`${module}.py`, `${module}/__init__.py`].some((f) => b === f || b.endsWith(`/${f}`)); + } + const a = cleanPath(resultRoot); return a === b || b.endsWith(`/${a}`) || a.endsWith(`/${b}`); } diff --git a/src/features/run/testing/unittest.test.ts b/src/features/run/testing/unittest.test.ts new file mode 100644 index 000000000..009639e4a --- /dev/null +++ b/src/features/run/testing/unittest.test.ts @@ -0,0 +1,371 @@ +import { describe, expect, it } from "vitest"; +import { parseAll } from "./index"; +import { TestResults, nodeStatus } from "./results"; +import { unittestParser } from "./unittest"; + +// Captured from `manage.py test` (Django 4.2 on Python 3.10, Django 6.1 on Python 3.12) as the Run +// console splits it into lines: stdout and stderr on two pipes, never sharing a line. +const PY312 = `System check identified no issues (0 silenced). +test_documented (polls.tests.QuestionModelTests.test_documented) +Explains what is checked. ... ok +test_errors (polls.tests.QuestionModelTests.test_errors) ... ERROR +test_expected_failure (polls.tests.QuestionModelTests.test_expected_failure) ... expected failure +test_fails (polls.tests.QuestionModelTests.test_fails) ... FAIL +test_passes (polls.tests.QuestionModelTests.test_passes) ... ok +test_prints (polls.tests.QuestionModelTests.test_prints) ... hello from stderr +ok +hello from stdout +test_skipped (polls.tests.QuestionModelTests.test_skipped) ... skipped 'not ready' +test_subtests (polls.tests.QuestionModelTests.test_subtests) ... + test_subtests (polls.tests.QuestionModelTests.test_subtests) (i=1) ... FAIL +test_unexpected_success (polls.tests.QuestionModelTests.test_unexpected_success) ... unexpected success +test_ok (polls.more.test_views.ViewTests.test_ok) ... ok +setUpClass (polls.tests.BrokenSetupTests) ... ERROR + +====================================================================== +ERROR: test_errors (polls.tests.QuestionModelTests.test_errors) +---------------------------------------------------------------------- +Traceback (most recent call last): + File "/e2e/proj/polls/tests.py", line 15, in test_errors + raise ValueError("boom") +ValueError: boom + +====================================================================== +ERROR: setUpClass (polls.tests.BrokenSetupTests) +---------------------------------------------------------------------- +Traceback (most recent call last): + File "/e2e/proj/polls/tests.py", line 50, in setUpClass + raise RuntimeError("setup broke") +RuntimeError: setup broke + +====================================================================== +FAIL: test_fails (polls.tests.QuestionModelTests.test_fails) +---------------------------------------------------------------------- +Traceback (most recent call last): + File "/e2e/proj/polls/tests.py", line 12, in test_fails + self.assertEqual("left", "right") +AssertionError: 'left' != 'right' +- left ++ right + + +====================================================================== +FAIL: test_subtests (polls.tests.QuestionModelTests.test_subtests) (i=1) +---------------------------------------------------------------------- +Traceback (most recent call last): + File "/e2e/proj/polls/tests.py", line 43, in test_subtests + self.assertNotEqual(i, 1) +AssertionError: 1 == 1 + +====================================================================== +UNEXPECTED SUCCESS: test_unexpected_success (polls.tests.QuestionModelTests.test_unexpected_success) +---------------------------------------------------------------------- +Ran 10 tests in 0.001s + +FAILED (failures=2, errors=2, skipped=1, expected failures=1, unexpected successes=1) +Destroying test database for alias 'default' ('file:memorydb_default?mode=memory&cache=shared')...`; + +const PY310 = `System check identified no issues (0 silenced). +test_documented (polls.tests.QuestionModelTests) +Explains what is checked. ... ok +test_errors (polls.tests.QuestionModelTests) ... ERROR +test_expected_failure (polls.tests.QuestionModelTests) ... expected failure +test_fails (polls.tests.QuestionModelTests) ... FAIL +test_passes (polls.tests.QuestionModelTests) ... ok +test_prints (polls.tests.QuestionModelTests) ... hello from stderr +ok +test_skipped (polls.tests.QuestionModelTests) ... skipped 'not ready' +test_subtests (polls.tests.QuestionModelTests) ... test_unexpected_success (polls.tests.QuestionModelTests) ... unexpected success +test_ok (polls.more.test_views.ViewTests) ... ok +ERROR + +====================================================================== +ERROR: test_errors (polls.tests.QuestionModelTests) +---------------------------------------------------------------------- +Traceback (most recent call last): + File "/e2e/proj/polls/tests.py", line 15, in test_errors + raise ValueError("boom") +ValueError: boom + +====================================================================== +ERROR: setUpClass (polls.tests.BrokenSetupTests) +---------------------------------------------------------------------- +Traceback (most recent call last): + File "/e2e/proj/polls/tests.py", line 50, in setUpClass + raise RuntimeError("setup broke") +RuntimeError: setup broke + +====================================================================== +FAIL: test_fails (polls.tests.QuestionModelTests) +---------------------------------------------------------------------- +Traceback (most recent call last): + File "/e2e/proj/polls/tests.py", line 12, in test_fails + self.assertEqual("left", "right") +AssertionError: 'left' != 'right' +- left ++ right + + +====================================================================== +FAIL: test_subtests (polls.tests.QuestionModelTests) (i=1) +---------------------------------------------------------------------- +Traceback (most recent call last): + File "/e2e/proj/polls/tests.py", line 43, in test_subtests + self.assertNotEqual(i, 1) +AssertionError: 1 == 1 + +---------------------------------------------------------------------- +Ran 10 tests in 0.001s + +FAILED (failures=2, errors=2, skipped=1, expected failures=1, unexpected successes=1) +Destroying test database for alias 'default' ('file:memorydb_default?mode=memory&cache=shared')... +hello from stdout`; + +/** Default verbosity: progress characters only, failures in the sections. */ +const PY310_DOTS = `System check identified no issues (0 silenced). +.ExF. +hello from stdout +hello from stderr +.su.E +====================================================================== +ERROR: test_errors (polls.tests.QuestionModelTests) +---------------------------------------------------------------------- +Traceback (most recent call last): + File "/e2e/proj/polls/tests.py", line 15, in test_errors + raise ValueError("boom") +ValueError: boom + +====================================================================== +ERROR: setUpClass (polls.tests.BrokenSetupTests) +---------------------------------------------------------------------- +Traceback (most recent call last): + File "/e2e/proj/polls/tests.py", line 50, in setUpClass + raise RuntimeError("setup broke") +RuntimeError: setup broke + +====================================================================== +FAIL: test_fails (polls.tests.QuestionModelTests) +---------------------------------------------------------------------- +Traceback (most recent call last): + File "/e2e/proj/polls/tests.py", line 12, in test_fails + self.assertEqual("left", "right") +AssertionError: 'left' != 'right' +- left ++ right + + +====================================================================== +FAIL: test_subtests (polls.tests.QuestionModelTests) (i=1) +---------------------------------------------------------------------- +Traceback (most recent call last): + File "/e2e/proj/polls/tests.py", line 43, in test_subtests + self.assertNotEqual(i, 1) +AssertionError: 1 == 1 + +---------------------------------------------------------------------- +Ran 10 tests in 0.001s + +FAILED (failures=2, errors=2, skipped=1, expected failures=1, unexpected successes=1) +Destroying test database for alias 'default'...`; + +/** `--parallel`: tracebacks rebuilt by tblib start in unittest's own frames. */ +const PARALLEL = `====================================================================== +FAIL: test_fails (polls.tests.QuestionModelTests.test_fails) +---------------------------------------------------------------------- +Traceback (most recent call last): + File "/venvs/pythons/cpython-3.12.16-linux-aarch64-gnu/lib/python3.12/unittest/case.py", line 58, in testPartExecutor + yield + File "/venvs/pythons/cpython-3.12.16-linux-aarch64-gnu/lib/python3.12/unittest/case.py", line 634, in run + self._callTestMethod(testMethod) + ^^^^^^^^^^^^^^^^^ + File "/venvs/pythons/cpython-3.12.16-linux-aarch64-gnu/lib/python3.12/unittest/case.py", line 589, in _callTestMethod + if method() is not None: + ^^^^^^^^^^^^^^^^^ + File "/e2e/proj/polls/tests.py", line 12, in test_fails + self.assertEqual("left", "right") + ^^^^^^^^^^^^^^^^^ + File "/venvs/pythons/cpython-3.12.16-linux-aarch64-gnu/lib/python3.12/unittest/case.py", line 885, in assertEqual + assertion_func(first, second, msg=msg) + ^^^^^^^^^^^^^^^^^ + File "/venvs/pythons/cpython-3.12.16-linux-aarch64-gnu/lib/python3.12/unittest/case.py", line 1251, in assertMultiLineEqual + self.fail(self._formatMessage(msg, standardMsg)) + ^^^^^^^^^^^^^^^^^ + File "/venvs/pythons/cpython-3.12.16-linux-aarch64-gnu/lib/python3.12/unittest/case.py", line 715, in fail + raise self.failureException(msg) + ^^^^^^^^^^^^^^^^^ +AssertionError: 'left' != 'right' +- left ++ right`; + +/** Django 4.2 / Python 3.10: `setUpTestData` fails inside Django's own `setUpClass`; a non-ASCII test name. */ +const SETUP_TEST_DATA = `System check identified no issues (0 silenced). +ERROR +test_città (polls.test_extra.UnicodeTests) ... FAIL + +====================================================================== +ERROR: setUpClass (polls.test_extra.DataTests) +---------------------------------------------------------------------- +Traceback (most recent call last): + File "/venvs/py310/lib/python3.10/site-packages/django/test/testcases.py", line 1466, in setUpClass + cls.setUpTestData() + File "/e2e/proj/polls/test_extra.py", line 7, in setUpTestData + raise ValueError("bad data") +ValueError: bad data + +====================================================================== +FAIL: test_città (polls.test_extra.UnicodeTests) +---------------------------------------------------------------------- +Traceback (most recent call last): + File "/e2e/proj/polls/test_extra.py", line 15, in test_città + self.assertEqual(1, 2) +AssertionError: 1 != 2 + +---------------------------------------------------------------------- +Ran 1 test in 0.001s + +FAILED (failures=1, errors=1) +Destroying test database for alias 'default' ('file:memorydb_default?mode=memory&cache=shared')...`; + +/** Python 3.12: no test matched (`-k nomatch`). */ +const NO_TESTS = `System check identified no issues (0 silenced). + +---------------------------------------------------------------------- +Ran 0 tests in 0.000s + +NO TESTS RAN`; + +const IMPORT_ERROR = `broken.tests (unittest.loader._FailedTest.broken.tests) ... ERROR +test_ok (polls.more.test_views.ViewTests.test_ok) ... ok + +====================================================================== +ERROR: broken.tests (unittest.loader._FailedTest.broken.tests) +---------------------------------------------------------------------- +ImportError: Failed to import test module: broken.tests +Traceback (most recent call last): + File "/venvs/pythons/cpython-3.12.16-linux-aarch64-gnu/lib/python3.12/unittest/loader.py", line 396, in _find_test_path + module = self._get_module_from_name(name) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/venvs/pythons/cpython-3.12.16-linux-aarch64-gnu/lib/python3.12/unittest/loader.py", line 339, in _get_module_from_name + __import__(name) + File "/e2e/proj/broken/tests.py", line 1, in + import does_not_exist +ModuleNotFoundError: No module named 'does_not_exist' + + +---------------------------------------------------------------------- +Ran 2 tests in 0.000s + +FAILED (errors=1)`; + +function tree(text: string) { + const results = new TestResults(); + results.apply(parseAll(unittestParser(), text)); + results.finish(1); + const status = (...path: string[]) => { + const node = results.get(path.join("\0")); + return node ? nodeStatus(node) : undefined; + }; + return { results, status }; +} + +const Q = ["polls.tests", "QuestionModelTests"]; + +describe("unittest (Django) output", () => { + it.each([ + ["Python 3.12", PY312], + ["Python 3.10", PY310], + ])("reports every test of a verbose run under its module and class (%s)", (_, text) => { + const { results, status } = tree(text); + expect(results.roots.map((n) => n.name)).toEqual(["polls.tests", "polls.more.test_views"]); + expect(status(...Q, "test_documented")).toBe("passed"); + expect(status(...Q, "test_errors")).toBe("failed"); + expect(status(...Q, "test_expected_failure")).toBe("passed"); + expect(status(...Q, "test_fails")).toBe("failed"); + expect(status(...Q, "test_passes")).toBe("passed"); + // Its output split the result line in two. + expect(status(...Q, "test_prints")).toBe("passed"); + expect(status(...Q, "test_skipped")).toBe("skipped"); + expect(status(...Q, "test_subtests")).toBe("failed"); + expect(status(...Q, "test_unexpected_success")).toBe("failed"); + expect(status("polls.more.test_views", "ViewTests", "test_ok")).toBe("passed"); + // `setUpClass` failed: the class itself. + expect(status("polls.tests", "BrokenSetupTests")).toBe("failed"); + expect(results.counts()).toMatchObject({ passed: 5, failed: 5, skipped: 1, running: 0, total: 11 }); + expect(results.summary).toMatchObject({ failed: 5, skipped: 1, durationMs: 1 }); + + const fails = results.get([...Q, "test_fails"].join("\0"))!; + expect(fails.location).toEqual({ path: "/e2e/proj/polls/tests.py", line: 12, resolve: "path" }); + expect(fails.output).toContain("AssertionError: 'left' != 'right'"); + expect(results.get(["polls.tests", "BrokenSetupTests"].join("\0"))!.location).toMatchObject({ line: 50 }); + expect(results.get([...Q, "test_subtests"].join("\0"))!.output).toContain("AssertionError: 1 == 1"); + }); + + it("reports failures of a run without -v 2 from their sections", () => { + const { results, status } = tree(PY310_DOTS); + expect(status(...Q, "test_fails")).toBe("failed"); + expect(status(...Q, "test_errors")).toBe("failed"); + expect(status(...Q, "test_subtests")).toBe("failed"); + expect(status("polls.tests", "BrokenSetupTests")).toBe("failed"); + // The summary line counts the unexpected success, which has no section in Python 3.10. + expect(results.counts()).toMatchObject({ passed: 4, failed: 5, skipped: 1 }); + }); + + it("locates a failure at the test's own frame, past unittest's frames", () => { + const { results } = tree(PARALLEL); + expect(results.get([...Q, "test_fails"].join("\0"))!.location).toMatchObject({ + path: "/e2e/proj/polls/tests.py", + line: 12, + }); + }); + + it("locates fixture failures in the project, past Django's frames, and reads non-ASCII names", () => { + const { results, status } = tree(SETUP_TEST_DATA); + expect(status("polls.test_extra", "DataTests")).toBe("failed"); + expect(results.get(["polls.test_extra", "DataTests"].join("\0"))!.location).toEqual({ + path: "/e2e/proj/polls/test_extra.py", + line: 7, + resolve: "path", + }); + expect(status("polls.test_extra", "UnicodeTests", "test_città")).toBe("failed"); + expect(results.get(["polls.test_extra", "UnicodeTests", "test_città"].join("\0"))!.location).toMatchObject({ + line: 15, + }); + }); + + it("keeps a project's own unittest/ folder as a location", () => { + const { results } = tree( + [ + "=".repeat(70), + "FAIL: test_a (tests.unittest.test_x.C.test_a)", + "-".repeat(70), + "Traceback (most recent call last):", + ' File "/e2e/proj/tests/unittest/test_x.py", line 9, in test_a', + " self.assertTrue(False)", + "AssertionError: False is not true", + "", + ].join("\n"), + ); + expect(results.get(["tests.unittest.test_x", "C", "test_a"].join("\0"))!.location).toMatchObject({ + path: "/e2e/proj/tests/unittest/test_x.py", + line: 9, + }); + }); + + it("reports a run where no test matched", () => { + const { results } = tree(NO_TESTS); + expect(results.isEmpty).toBe(true); + expect(results.summary).toMatchObject({ passed: 0, failed: 0, skipped: 0 }); + }); + + it("ignores other tools' lines that look like a name and a dotted word", () => { + const { results } = tree("Finished (0.5s)\ncompile (gcc.exe)\nbuilding (app.main) ... done"); + expect(results.isEmpty).toBe(true); + }); + + it("reports a test module that fails to import as the module", () => { + const { results, status } = tree(IMPORT_ERROR); + expect(status("broken.tests")).toBe("failed"); + expect(results.get("broken.tests")!.location).toMatchObject({ path: "/e2e/proj/broken/tests.py", line: 1 }); + expect(status("polls.more.test_views", "ViewTests", "test_ok")).toBe("passed"); + }); +}); diff --git a/src/features/run/testing/unittest.ts b/src/features/run/testing/unittest.ts new file mode 100644 index 000000000..510657d1d --- /dev/null +++ b/src/features/run/testing/unittest.ts @@ -0,0 +1,169 @@ +/** + * unittest's text runner, as Django's `manage.py test` prints it: verbose result + * lines (`-v 2`), failure sections and the summary. Tests are reported as + * `[module, Class, test]`; class and module fixtures (`setUpClass`, a test module + * that fails to import) as their class or module. + */ +import { parseDuration, type TestEvent, type TestOutputParser, type TestStatus } from "./types"; + +/** A Python identifier (Unicode letters, not starting with a digit). */ +const ID = String.raw`[\p{L}_][\p{L}\p{N}_]*`; +// `test_x (polls.tests.QuestionTests.test_x)` (Python ≥ 3.11 adds the test name), +// `test_x (polls.tests.QuestionTests)`, then a subtest's `[msg]` / `(i=1)`. +const HEADER_RE = new RegExp( + String.raw`^\s*(${ID}(?:\.${ID})*) \((${ID}(?:\.${ID})+)\)(?: \[.*?\])?(?: \(.*?\))?(?= \.\.\.|$)`, + "u", +); +const OUTCOME_RE = /^(ok|FAIL|ERROR|skipped(?: .*)?|expected failure|unexpected success)$/; +// A docstring's first line follows the name when the test has one: `Checks x. ... ok`. +const TRAILING_OUTCOME_RE = / \.\.\. (ok|FAIL|ERROR|skipped(?: .*)?|expected failure|unexpected success)$/; +const SECTION_RE = /^(FAIL|ERROR|UNEXPECTED SUCCESS): (.+)$/; +const FRAME_RE = /^\s*File "([^"]+)", line (\d+), in (\S+)/; +// Installed packages and the standard library with its unittest (`lib/python3.12/…`, Windows `Lib\…`); +// a project's own `unittest/` folder is not a library. +const LIBRARY_RE = + /[/\\](?:site-packages|dist-packages)[/\\]|\/lib\/python[\d.]*\/(?:unittest\/|[^/]+\.py$)|\\Lib\\(?:unittest\\|[^\\]+\.py$)/; +const RAN_RE = /^Ran (\d+) tests? in ([\d.]+s)$/; +// Python ≥ 3.12 prints `NO TESTS RAN` when nothing matched the labels. +const RESULT_RE = /^(OK|FAILED|NO TESTS RAN)(?: \((.*)\))?$/; +/** unittest's `separator1` / `separator2`. */ +const RULE1 = "=".repeat(70); +const RULE2 = "-".repeat(70); + +function status(outcome: string): Exclude { + if (outcome === "ok" || outcome === "expected failure") return "passed"; + if (outcome.startsWith("skipped")) return "skipped"; + return "failed"; +} + +/** Tree path of a reported test: its module, class and name. */ +function testPath(name: string, dotted: string): string[] { + // A test module that failed to import: `polls.tests (unittest.loader._FailedTest.polls.tests)`. + if (/^unittest\.loader\._Failed/.test(dotted)) return [name]; + if (name === "setUpModule" || name === "tearDownModule") return [dotted]; + const id = dotted.endsWith(`.${name}`) ? dotted.slice(0, -name.length - 1) : dotted; + const dot = id.lastIndexOf("."); + const parts = dot < 0 ? [id] : [id.slice(0, dot), id.slice(dot + 1)]; + return name === "setUpClass" || name === "tearDownClass" ? parts : [...parts, name]; +} + +export function unittestParser(): TestOutputParser { + /** A test whose outcome hasn't been printed yet (output, a docstring or subtests came first). */ + let pending: string[] | null = null; + let section: { + path: string[]; + /** The function whose frame locates the failure (the test, `setUpClass`, a module's ``). */ + name: string; + ruled: boolean; + location: TestEvent | null; + fallback: TestEvent | null; + } | null = null; + let ran: { count: number; durationMs?: number } | null = null; + + const closeSection = (): TestEvent[] => { + const s = section; + section = null; + const location = s && (s.location ?? s.fallback); + return location ? [location] : []; + }; + + /** Result lines; a line may hold several (Python < 3.11 prints nothing after a test with failed subtests). */ + const results = (line: string, events: TestEvent[]): boolean => { + let rest = line; + let matched = false; + let m: RegExpExecArray | null; + while ((m = HEADER_RE.exec(rest))) { + const after = rest.slice(m[0].length).replace(/^ \.\.\. ?/, ""); + const outcome = OUTCOME_RE.exec(after); + // A result that comes later (after a docstring or the test's output) only for test methods + // (unittest's `test` prefix); fixtures and modules print theirs at once. Lines of other tools + // such as `Finished (0.5s)` or `compile (gcc.exe)` are no tests. + if (!outcome && !m[1]!.startsWith("test")) break; + matched = true; + const path = testPath(m[1]!, m[2]!); + rest = after; + if (outcome) { + events.push({ type: "result", path, status: status(outcome[1]!) }); + pending = null; + return true; + } + events.push({ type: "start", path }); + pending = path; + } + return matched; + }; + + return { + feed(line) { + const events: TestEvent[] = []; + let m: RegExpExecArray | null; + if (line === RULE1) { + events.push(...closeSection()); + pending = null; + return events; + } + // An unexpected success's section has no traceback: the summary follows its rule. + if (section && RAN_RE.test(line)) events.push(...closeSection()); + if (section) { + if (line === RULE2) { + if (!section.ruled) section.ruled = true; + else events.push(...closeSection()); + return events; + } + events.push({ type: "output", path: section.path, line }); + const frame = FRAME_RE.exec(line); + // Library frames never locate a failure, even when named like the test or fixture + // (Django's own `setUpClass` runs the test's `setUpTestData`). + if (frame && !LIBRARY_RE.test(frame[1]!)) { + const location: TestEvent = { + type: "location", + path: section.path, + location: { path: frame[1]!, line: Number(frame[2]), resolve: "path" }, + }; + if (frame[3] === section.name) section.location ??= location; + else section.fallback ??= location; + } + return events; + } + if ((m = SECTION_RE.exec(line))) { + const header = HEADER_RE.exec(m[2]!); + if (header) { + const path = testPath(header[1]!, header[2]!); + const name = /^unittest\.loader\._Failed/.test(header[2]!) ? "" : header[1]!; + section = { path, name, ruled: false, location: null, fallback: null }; + events.push({ type: "result", path, status: "failed" }, { type: "output", path, line }); + return events; + } + } + if ((m = RAN_RE.exec(line))) { + ran = { count: Number(m[1]), durationMs: parseDuration(m[2]) }; + return events; + } + if (ran && (m = RESULT_RE.exec(line))) { + const counts: Record = {}; + for (const c of (m[2] ?? "").matchAll(/([a-z ]+)=(\d+)/g)) counts[c[1]!.trim()] = Number(c[2]); + const failed = (counts.failures ?? 0) + (counts.errors ?? 0) + (counts["unexpected successes"] ?? 0); + const skipped = counts.skipped ?? 0; + events.push({ + type: "summary", + passed: Math.max(0, ran.count - failed - skipped), + failed, + skipped, + durationMs: ran.durationMs, + }); + ran = null; + return events; + } + if (results(line, events)) return events; + if (pending) { + const outcome = OUTCOME_RE.exec(line) ?? TRAILING_OUTCOME_RE.exec(line); + if (outcome) { + events.push({ type: "result", path: pending, status: status(outcome[1]!) }); + pending = null; + } + } + return events; + }, + end: closeSection, + }; +} diff --git a/src/features/run/types.ts b/src/features/run/types.ts index 1ad2edf87..a5b51f4fa 100644 --- a/src/features/run/types.ts +++ b/src/features/run/types.ts @@ -23,6 +23,7 @@ import { tomcatType } from "./tomcat"; import { goBuildType, goTestType } from "./goTypes"; import { dotnetProjectType, dotnetTestType } from "./dotnetTypes"; +import { djangoTestType } from "./djangoTypes"; export const COMPOUND = "compound"; @@ -398,6 +399,7 @@ export const BUILTIN_TYPES: readonly RunConfigTypeContribution[] = [ maven, godot, pytest, + djangoTestType, dotnetProjectType, dotnetTestType, goBuildType, @@ -451,7 +453,7 @@ export function withParams(config: RunConfig, params: Record, ty ...(config.kind === "dotnet" && config.beforeLaunch === undefined ? { beforeLaunch: [{ kind: "dotnetBuild", target: "" }] } : {}), - ...(config.kind === "dotnet-test" ? { isTest: true } : {}), + ...(config.kind === "dotnet-test" || config.kind === "django-test" ? { isTest: true } : {}), params: params.goKind ? { ...params, programArgv: JSON.stringify(parseArgs(params.programArgs ?? "")) } : params, ...(config.kind === "go-test" && !config.id.startsWith("template:") ? { kind: "go" } : {}), ...(params.goKind ? { isTest: params.goKind === "test" } : {}), diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 4240050a1..ed9b91318 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -5504,5 +5504,13 @@ "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": "刚刚" + "now": "刚刚", + "Django tests": "Django 测试", + "Run Django tests with manage.py test": "使用 manage.py test 运行 Django 测试", + "e.g. python3, .venv/bin/python, uv, poetry": "例如 python3、.venv/bin/python、uv、poetry", + "e.g. `run python` for uv/poetry; empty calls the interpreter itself": "例如 uv/poetry 使用 `run python`;留空则直接调用解释器本身", + "App labels, modules or dotted test paths; empty runs every test": "应用标签、模块或以点分隔的测试路径;留空则运行全部测试", + "Settings module": "设置模块", + "Passed as --settings; empty keeps DJANGO_SETTINGS_MODULE or the default in manage.py": "作为 --settings 传入;留空则沿用 DJANGO_SETTINGS_MODULE 或 manage.py 中的默认值", + "e.g. --keepdb --parallel": "例如 --keepdb --parallel" } diff --git a/src/platform/locales/textCatalog.test.ts b/src/platform/locales/textCatalog.test.ts index e23a830dd..1bba83ce9 100644 --- a/src/platform/locales/textCatalog.test.ts +++ b/src/platform/locales/textCatalog.test.ts @@ -212,13 +212,16 @@ const UNTRANSLATED = new Set([ "go env GOPATH", "kebab-case", "KEY=value; OTHER=value", + "mysite.settings_test", "NAME", "NAME=value; OTHER=value", "node_modules/typescript", "PascalCase", + "polls.tests", "pylyzer --server", "pyproject.toml, .git", "run pytest", + "run python", "snake_case", "tests", '{ "section": { "key": "value" } }', From edfe6c7b6502027e2944a183da7f1379c4ae2d6d Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Mon, 2 Nov 2026 23:11:56 +0800 Subject: [PATCH 031/106] chore: v0.1.34 Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 70 +++++++++++++++++++-------------------- Cargo.toml | 2 +- package.json | 2 +- src-tauri/tauri.conf.json | 2 +- 4 files changed, 38 insertions(+), 38 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5275a63ff..56a55e9bd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4038,7 +4038,7 @@ checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" [[package]] name = "ide-agent" -version = "0.1.33" +version = "0.1.34" dependencies = [ "dunce", "encoding_rs", @@ -4061,7 +4061,7 @@ dependencies = [ [[package]] name = "ide-analysis" -version = "0.1.33" +version = "0.1.34" dependencies = [ "ast-grep-core", "ide-core", @@ -4075,7 +4075,7 @@ dependencies = [ [[package]] name = "ide-build" -version = "0.1.33" +version = "0.1.34" dependencies = [ "dirs 6.0.0", "dunce", @@ -4090,7 +4090,7 @@ dependencies = [ [[package]] name = "ide-clipboard" -version = "0.1.33" +version = "0.1.34" dependencies = [ "objc2 0.6.5", "objc2-app-kit 0.3.2", @@ -4099,7 +4099,7 @@ dependencies = [ [[package]] name = "ide-core" -version = "0.1.33" +version = "0.1.34" dependencies = [ "dunce", "encoding_rs", @@ -4116,7 +4116,7 @@ dependencies = [ [[package]] name = "ide-coverage" -version = "0.1.33" +version = "0.1.34" dependencies = [ "ide-core", "ide-run", @@ -4130,7 +4130,7 @@ dependencies = [ [[package]] name = "ide-dap" -version = "0.1.33" +version = "0.1.34" dependencies = [ "ide-core", "ide-jsonrpc", @@ -4148,7 +4148,7 @@ dependencies = [ [[package]] name = "ide-db" -version = "0.1.33" +version = "0.1.34" dependencies = [ "bb8", "dirs 6.0.0", @@ -4170,7 +4170,7 @@ dependencies = [ [[package]] name = "ide-docker" -version = "0.1.33" +version = "0.1.34" dependencies = [ "ide-core", "serde", @@ -4183,7 +4183,7 @@ dependencies = [ [[package]] name = "ide-fonts" -version = "0.1.33" +version = "0.1.34" dependencies = [ "objc2-core-foundation", "objc2-core-text", @@ -4195,7 +4195,7 @@ dependencies = [ [[package]] name = "ide-github" -version = "0.1.33" +version = "0.1.34" dependencies = [ "ide-core", "ide-vcs", @@ -4208,7 +4208,7 @@ dependencies = [ [[package]] name = "ide-host" -version = "0.1.33" +version = "0.1.34" dependencies = [ "dirs 6.0.0", "dunce", @@ -4255,7 +4255,7 @@ dependencies = [ [[package]] name = "ide-http" -version = "0.1.33" +version = "0.1.34" dependencies = [ "ide-core", "parking_lot", @@ -4273,7 +4273,7 @@ dependencies = [ [[package]] name = "ide-index" -version = "0.1.33" +version = "0.1.34" dependencies = [ "blake3", "criterion", @@ -4295,7 +4295,7 @@ dependencies = [ [[package]] name = "ide-jsonrpc" -version = "0.1.33" +version = "0.1.34" dependencies = [ "encoding_rs", "futures", @@ -4311,7 +4311,7 @@ dependencies = [ [[package]] name = "ide-language" -version = "0.1.33" +version = "0.1.34" dependencies = [ "blake3", "criterion", @@ -4366,7 +4366,7 @@ dependencies = [ [[package]] name = "ide-lsp" -version = "0.1.33" +version = "0.1.34" dependencies = [ "dunce", "futures", @@ -4391,7 +4391,7 @@ dependencies = [ [[package]] name = "ide-notebook" -version = "0.1.33" +version = "0.1.34" dependencies = [ "futures", "ide-core", @@ -4414,7 +4414,7 @@ dependencies = [ [[package]] name = "ide-plugin" -version = "0.1.33" +version = "0.1.34" dependencies = [ "ide-core", "ide-language", @@ -4432,7 +4432,7 @@ dependencies = [ [[package]] name = "ide-profiler" -version = "0.1.33" +version = "0.1.34" dependencies = [ "ide-core", "ide-run", @@ -4445,7 +4445,7 @@ dependencies = [ [[package]] name = "ide-remote" -version = "0.1.33" +version = "0.1.34" dependencies = [ "globset", "ide-core", @@ -4462,7 +4462,7 @@ dependencies = [ [[package]] name = "ide-run" -version = "0.1.33" +version = "0.1.34" dependencies = [ "dunce", "encoding_rs", @@ -4485,7 +4485,7 @@ dependencies = [ [[package]] name = "ide-sdk" -version = "0.1.33" +version = "0.1.34" dependencies = [ "dunce", "ide-core", @@ -4504,7 +4504,7 @@ dependencies = [ [[package]] name = "ide-search" -version = "0.1.33" +version = "0.1.34" dependencies = [ "criterion", "globset", @@ -4524,7 +4524,7 @@ dependencies = [ [[package]] name = "ide-spell" -version = "0.1.33" +version = "0.1.34" dependencies = [ "ide-core", "parking_lot", @@ -4537,7 +4537,7 @@ dependencies = [ [[package]] name = "ide-ssh" -version = "0.1.33" +version = "0.1.34" dependencies = [ "dirs 6.0.0", "flate2", @@ -4556,7 +4556,7 @@ dependencies = [ [[package]] name = "ide-syntax" -version = "0.1.33" +version = "0.1.34" dependencies = [ "ide-core", "ide-language", @@ -4571,7 +4571,7 @@ dependencies = [ [[package]] name = "ide-terminal" -version = "0.1.33" +version = "0.1.34" dependencies = [ "ide-core", "libc", @@ -4584,7 +4584,7 @@ dependencies = [ [[package]] name = "ide-text" -version = "0.1.33" +version = "0.1.34" dependencies = [ "criterion", "ide-core", @@ -4596,7 +4596,7 @@ dependencies = [ [[package]] name = "ide-tools" -version = "0.1.33" +version = "0.1.34" dependencies = [ "flate2", "hex", @@ -4617,7 +4617,7 @@ dependencies = [ [[package]] name = "ide-vcs" -version = "0.1.33" +version = "0.1.34" dependencies = [ "dunce", "encoding_rs", @@ -4636,7 +4636,7 @@ dependencies = [ [[package]] name = "ide-vfs" -version = "0.1.33" +version = "0.1.34" dependencies = [ "chardetng", "dunce", @@ -4658,7 +4658,7 @@ dependencies = [ [[package]] name = "ide-web" -version = "0.1.33" +version = "0.1.34" dependencies = [ "ide-core", "open", @@ -4674,7 +4674,7 @@ dependencies = [ [[package]] name = "ide-workspace" -version = "0.1.33" +version = "0.1.34" dependencies = [ "blake3", "dunce", @@ -6146,7 +6146,7 @@ dependencies = [ [[package]] name = "openidea" -version = "0.1.33" +version = "0.1.34" dependencies = [ "blake3", "block2 0.6.2", diff --git a/Cargo.toml b/Cargo.toml index 9da1d526f..9906e9948 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ resolver = "3" members = ["crates/*", "src-tauri"] [workspace.package] -version = "0.1.33" +version = "0.1.34" edition = "2024" rust-version = "1.96" license = "Apache-2.0" diff --git a/package.json b/package.json index 9fb5ec265..6bad3b8c8 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "openidea", "private": true, "description": "An open-source IDE inspired by IntelliJ IDEA", - "version": "0.1.33", + "version": "0.1.34", "license": "Apache-2.0", "type": "module", "scripts": { diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 9a1c84fbf..0083fc079 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "OpenIDEA", - "version": "0.1.33", + "version": "0.1.34", "identifier": "ai.l0ng.openidea", "build": { "beforeDevCommand": "pnpm dev", From ece2b2a59ea939014350a0a732253d45c596796b Mon Sep 17 00:00:00 2001 From: arookieofc <2128194521@qq.com> Date: Mon, 2 Nov 2026 23:37:41 +0800 Subject: [PATCH 032/106] feat(templates): New Go Module template for go.mod files (#884) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New ▸ Go Module creates go.mod with `module $PROJECT_NAME$` and a `go 1.20` directive. The go.mod language, icon and gopls were already registered; only the file template was missing. It also appears in Search Everywhere and Settings ▸ File and Code Templates. Fixes #882 Co-authored-by: a-rookie-of-C-language <186315048+a-rookie-of-C-language@users.noreply.github.com> --- src/features/templates/__tests__/files.test.ts | 6 ++++-- src/features/templates/files/builtins.ts | 9 +++++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/features/templates/__tests__/files.test.ts b/src/features/templates/__tests__/files.test.ts index f1aa7872f..431bbddb0 100644 --- a/src/features/templates/__tests__/files.test.ts +++ b/src/features/templates/__tests__/files.test.ts @@ -70,15 +70,17 @@ describe("file naming", () => { expect(withKind(css, kind("css"))).toBe(css); }); - it("prefills fixed names (Dockerfile, .editorconfig, .gitignore)", () => { + it("prefills fixed names (Dockerfile, .editorconfig, .gitignore, go.mod)", () => { for (const [id, name] of [ ["dockerfile", "Dockerfile"], ["editorconfig", ".editorconfig"], ["gitignore", ".gitignore"], + ["go-mod", "go"], ] as const) { const t = tpl(id); expect(t.defaultName).toBe(name); - expect(planFile(t, "/p", t.defaultName!)?.path).toBe(`/p/${name}`); + const ext = t.extension ? `.${t.extension}` : ""; + expect(planFile(t, "/p", t.defaultName!)?.path).toBe(`/p/${name}${ext}`); } }); diff --git a/src/features/templates/files/builtins.ts b/src/features/templates/files/builtins.ts index 7f92c16ad..6e0083116 100644 --- a/src/features/templates/files/builtins.ts +++ b/src/features/templates/files/builtins.ts @@ -140,6 +140,15 @@ export const BUILTIN_FILE_TEMPLATES: readonly FileTemplate[] = [ { id: "main", label: "Main", body: "package main\n\nfunc main() {\n\t$END$\n}\n" }, ], }, + { + id: "go-mod", + name: "Go Module", + icon: "file-gomod", + extension: "mod", + nameStyle: "asis", + defaultName: "go", + kinds: [{ id: "module", label: "Module", body: "module $PROJECT_NAME$\n\ngo 1.20\n" }], + }, { id: "java", name: "Java Class", From 51988985c5b2d87d79950d00a113b8c1068c9b10 Mon Sep 17 00:00:00 2001 From: Andrea Basile Date: Mon, 2 Nov 2026 16:37:47 +0100 Subject: [PATCH 033/106] fix(run): run a Python script with the project interpreter, not the runserver command line (#897) Root cause: projectPython built the interpreter prefix from the first Python configuration by dropping only the .py arguments, so a Django `manage.py runserver` config left `runserver` in the prefix. The prefix now ends where the program starts (`-m` or the script), so program arguments such as `runserver` and `test` never reach it; `uv run python` is kept. Covers both plain scripts and pytest files derived from the same prefix. Co-authored-by: andreabasile <7715018+andreabasile@users.noreply.github.com> --- src/features/run/configs.test.ts | 22 ++++++++++++++++++++++ src/features/run/context.ts | 7 +++++-- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/src/features/run/configs.test.ts b/src/features/run/configs.test.ts index ce70a443f..162e0adf8 100644 --- a/src/features/run/configs.test.ts +++ b/src/features/run/configs.test.ts @@ -271,4 +271,26 @@ describe("context configuration", () => { const uv = [cfg({ id: "pytest", kind: "pytest", command: "uv", args: ["run", "pytest"], isTest: true })]; expect(contextConfig("tools/gen.py", uv)).toMatchObject({ command: "uv", args: ["run", "python", "tools/gen.py"] }); }); + + it("takes the interpreter from a Django runserver config without its arguments", () => { + const runserver = (command: string, args: string[]) => + cfg({ id: "django:runserver", kind: "python", command, args: [...args, "manage.py", "runserver"] }); + expect(contextConfig("tools/seed.py", [runserver(".venv/bin/python", [])])).toMatchObject({ + command: ".venv/bin/python", + args: ["tools/seed.py"], + }); + expect(contextConfig("tools/seed.py", [runserver("uv", ["run", "python"])])).toMatchObject({ + command: "uv", + args: ["run", "python", "tools/seed.py"], + }); + expect(contextConfig("worker/test_worker.py", [runserver(".venv/bin/python", [])])).toMatchObject({ + args: ["-m", "pytest", "worker/test_worker.py"], + }); + }); + + it("keeps the interpreter of a plain python config", () => { + const plain = [cfg({ id: "python:module:app", kind: "python", command: "python3", args: ["-m", "app"] })]; + expect(contextConfig("tools/gen.py", plain)).toMatchObject({ command: "python3", args: ["tools/gen.py"] }); + expect(contextConfig("tools/gen.py", [])).toMatchObject({ command: "python3", args: ["tools/gen.py"] }); + }); }); diff --git a/src/features/run/context.ts b/src/features/run/context.ts index 768e3c017..c71bf6e6d 100644 --- a/src/features/run/context.ts +++ b/src/features/run/context.ts @@ -91,8 +91,11 @@ function derived(base: RunConfig, relPath: string, patch: Partial): R export function projectPython(configs: RunConfig[]): { command: string; prefix: string[] } | null { const py = configs.find((c) => c.kind === "python"); if (py) { - const m = py.args.indexOf("-m"); - return { command: py.command, prefix: m >= 0 ? py.args.slice(0, m) : py.args.filter((a) => !a.endsWith(".py")) }; + // The interpreter part ends where the program starts: `-m`, or the script (`manage.py`). What follows + // (`runserver`, `test`…) belongs to that program, never to the interpreter. + const end = py.args.findIndex((a) => a === "-m" || a.endsWith(".py")); + const python = py.args.findIndex((a) => /^python[\d.]*$/.test(a)); + return { command: py.command, prefix: py.args.slice(0, end >= 0 ? end : python + 1) }; } const pytest = configs.find((c) => c.kind === "pytest"); if (!pytest) return null; From c0c56dac8a8dc670c335dcd694e4e98ced781baf Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Mon, 2 Nov 2026 23:37:52 +0800 Subject: [PATCH 034/106] fix(run): enable Spring console colours with manual classpaths (#888) Spring Boot runs with a manual classpath (or no classpath placeholder) skip Java classpath resolution and so also skipped the console colour VM option. The shared console VM options are now applied on that launch path too; explicit spring.output.ansi.enabled settings are kept and resolved classpaths get the option once. --- src/features/run/javaModule.test.ts | 38 +++++++++++++++++++++++++++++ src/features/run/store.ts | 10 ++++++-- 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/src/features/run/javaModule.test.ts b/src/features/run/javaModule.test.ts index d01939584..e5b270f67 100644 --- a/src/features/run/javaModule.test.ts +++ b/src/features/run/javaModule.test.ts @@ -87,6 +87,44 @@ it("finds the source module for an old saved configuration without a module", as expect(ipc.runStart).toHaveBeenCalledWith(expect.objectContaining({ cwd: "/outside/modules" }), expect.anything()); }); +it.each([["-cp", "lib/*"], []])("colours Spring Boot runs without classpath resolution (%j)", async (...classpath) => { + const args = ["-Xmx256m", ...classpath, "a.Main", "--spring.profiles.active=dev"]; + const boot = { ...config, kind: "spring-boot", args }; + const session = await startRun(boot); + expect(session?.status).toBe("running"); + expect(ipc.runStart).toHaveBeenCalledWith( + expect.objectContaining({ args: ["-Dspring.output.ansi.enabled=always", ...args] }), + expect.anything(), + ); + expect(ipc.javaResolveLaunch).not.toHaveBeenCalled(); + expect(boot.args).toBe(args); + expect(args).not.toContain("-Dspring.output.ansi.enabled=always"); +}); + +it.each(["never", "detect", "always"])("preserves an explicit Spring Boot console setting (%s)", async (value) => { + const args = [`-Dspring.output.ansi.enabled=${value}`, "-cp", "lib/*", "a.Main"]; + await startRun({ ...config, kind: "spring-boot", args }); + expect(ipc.runStart).toHaveBeenCalledWith(expect.objectContaining({ args }), expect.anything()); + expect(ipc.javaResolveLaunch).not.toHaveBeenCalled(); +}); + +it("keeps plain Java runs with an explicit classpath free of Spring Boot options", async () => { + const args = ["-cp", "lib/*", "a.Main"]; + await startRun({ ...config, args }); + expect(ipc.runStart).toHaveBeenCalledWith(expect.objectContaining({ args }), expect.anything()); + expect(ipc.javaResolveLaunch).not.toHaveBeenCalled(); +}); + +it("adds the Spring Boot console option once when resolving the module classpath", async () => { + await startRun({ ...config, kind: "spring-boot", module: "app" }); + expect(ipc.runStart).toHaveBeenCalledWith( + expect.objectContaining({ + args: ["-Dspring.output.ansi.enabled=always", "-cp", "/p/app/target/classes", "a.Main"], + }), + expect.anything(), + ); +}); + it("keeps an explicitly stored root module", async () => { expect(await findJavaModule({ ...config, module: "", cwd: "sibling" })).toBe(""); expect(ipc.findFiles).not.toHaveBeenCalled(); diff --git a/src/features/run/store.ts b/src/features/run/store.ts index 3d7f35529..0e038ae67 100644 --- a/src/features/run/store.ts +++ b/src/features/run/store.ts @@ -17,7 +17,13 @@ import { type RunId, type TestCaseReport, } from "@/platform/ipc"; -import { findJavaModule, isJvmApplication, parseJavaArgs, prepareJavaLaunch } from "@/platform/javaLaunch"; +import { + consoleVmArgs, + findJavaModule, + isJvmApplication, + parseJavaArgs, + prepareJavaLaunch, +} from "@/platform/javaLaunch"; import { downloadJdkActions } from "@/platform/jdk"; import { useProject } from "@/platform/project"; import { executeAction } from "@/platform/actions"; @@ -721,7 +727,7 @@ async function resolveJvmApplication(sessionId: string, config: RunConfig): Prom say(`No main class in '${config.name}': expected java [options] -cp \${classpath}
[args]\n`, "stderr"); return null; } - if (!parsed.classpath) return config; + if (!parsed.classpath) return { ...config, args: [...consoleVmArgs(config), ...config.args] }; const rt = runtime(sessionId)!; const request = { id: nextRequestId(), controller: new AbortController() }; rt.resolveRequest = request; From b3c0efd80419c854ab9aa5adbad35d4110358071 Mon Sep 17 00:00:00 2001 From: Jonas Zuberbuehler Date: Mon, 2 Nov 2026 16:39:32 +0100 Subject: [PATCH 035/106] fix(web): keep the built-in server inside the served root (#887) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: the built-in web server rejected `..` segments but followed symlinks, so a committed link such as `x -> /` served any file on disk, also in Restricted Mode. The served root is canonicalized per request, and the requested file — and the index.html served for a directory — is refused with 404 when its real path lies outside it. The server opens the checked real path, and Content-Type still follows the requested name. Links inside the project (e.g. pnpm node_modules) keep working. Fixes #886 Co-authored-by: jonaszuberbuehler <3812998+jonaszuberbuehler@users.noreply.github.com> --- crates/ide-web/src/server.rs | 74 +++++++++++++++++++++++++++++++----- 1 file changed, 65 insertions(+), 9 deletions(-) diff --git a/crates/ide-web/src/server.rs b/crates/ide-web/src/server.rs index b909bcdb3..cfb801930 100644 --- a/crates/ide-web/src/server.rs +++ b/crates/ide-web/src/server.rs @@ -173,7 +173,12 @@ async fn read_head(sock: &mut TcpStream) -> std::io::Result> { } enum Response { - File { path: PathBuf, len: u64 }, + /// `path` is the resolved file to open; `mime` comes from the requested name. + File { + path: PathBuf, + len: u64, + mime: &'static str, + }, Redirect(String), Error(u16, &'static str), } @@ -191,9 +196,9 @@ async fn serve(mut sock: TcpStream, shared: &Shared, port: u16) -> std::io::Resu }; let mut out = String::new(); match &response { - Response::File { path, len } => { + Response::File { len, mime, .. } => { out.push_str("HTTP/1.1 200 OK\r\n"); - out.push_str(&format!("Content-Type: {}\r\nContent-Length: {len}\r\n", content_type(path))); + out.push_str(&format!("Content-Type: {mime}\r\nContent-Length: {len}\r\n")); } Response::Redirect(to) => { out.push_str(&format!("HTTP/1.1 301 Moved Permanently\r\nLocation: {to}\r\nContent-Length: 0\r\n")); @@ -233,7 +238,9 @@ async fn resolve(shared: &Shared, target: &str) -> Response { return NOT_FOUND; } let Some(key) = segments.next().and_then(dec) else { return NOT_FOUND }; - let Some(mut file) = shared.roots.read().get(&key).cloned() else { return NOT_FOUND }; + let Some(root) = shared.roots.read().get(&key).cloned() else { return NOT_FOUND }; + let Ok(real_root) = tokio::fs::canonicalize(&root).await else { return NOT_FOUND }; + let mut file = root; let mut trailing_slash = true; for seg in segments { trailing_slash = seg.is_empty(); @@ -245,19 +252,29 @@ async fn resolve(shared: &Shared, target: &str) -> Response { _ => return NOT_FOUND, } } - let Ok(meta) = tokio::fs::metadata(&file).await else { return NOT_FOUND }; + let Some(real) = confined(&real_root, &file).await else { return NOT_FOUND }; + let Ok(meta) = tokio::fs::metadata(&real).await else { return NOT_FOUND }; if meta.is_dir() { // Relative URLs in an index page resolve against the directory. if !trailing_slash { return Response::Redirect(format!("{path}/")); } - file.push("index.html"); - return match tokio::fs::metadata(&file).await { - Ok(m) if m.is_file() => Response::File { path: file, len: m.len() }, + let index = file.join("index.html"); + let Some(real_index) = confined(&real_root, &index).await else { return NOT_FOUND }; + return match tokio::fs::metadata(&real_index).await { + Ok(m) if m.is_file() => Response::File { path: real_index, len: m.len(), mime: content_type(&index) }, _ => NOT_FOUND, }; } - Response::File { path: file, len: meta.len() } + Response::File { path: real, len: meta.len(), mime: content_type(&file) } +} + +/// `path` with every symlink resolved, if it still lies inside `real_root` (already +/// canonical). Rejecting `..` segments is not enough: a project can commit symlinks +/// that point anywhere on disk. +async fn confined(real_root: &Path, path: &Path) -> Option { + let real = tokio::fs::canonicalize(path).await.ok()?; + real.starts_with(real_root).then_some(real) } fn dec(s: &str) -> Option { @@ -340,6 +357,45 @@ mod tests { assert!(get(&url, Some("evil.example:80")).await.starts_with("HTTP/1.1 403")); } + #[cfg(unix)] + #[tokio::test] + async fn symlinks_cannot_escape_the_served_root() { + use std::os::unix::fs::symlink; + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().join("repo"); + std::fs::create_dir_all(root.join("site/escape")).unwrap(); + std::fs::create_dir_all(root.join("site/assets")).unwrap(); + std::fs::write(root.join("site/index.html"), "

hi

").unwrap(); + std::fs::write(root.join("site/assets/app.js"), "export {}").unwrap(); + std::fs::write(root.join("site/raw"), "

raw

").unwrap(); + std::fs::write(dir.path().join("secret.txt"), "outside").unwrap(); + symlink("index.html", root.join("site/inside.html")).unwrap(); + symlink("assets", root.join("site/lib")).unwrap(); + symlink("raw", root.join("site/raw.html")).unwrap(); + symlink("../../secret.txt", root.join("site/leak.txt")).unwrap(); + symlink("../..", root.join("site/up")).unwrap(); + symlink("/", root.join("site/fsroot")).unwrap(); + symlink("../../../secret.txt", root.join("site/escape/index.html")).unwrap(); + + let server = WebServer::new(); + let url = server.url_for(Some(&root), &root.join("site/index.html")).await.unwrap(); + let base = url.trim_end_matches("index.html"); + let page = get(&format!("{base}inside.html"), None).await; + assert!(page.starts_with("HTTP/1.1 200") && page.ends_with("

hi

"), "{page}"); + let page = get(&format!("{base}lib/app.js"), None).await; + assert!(page.starts_with("HTTP/1.1 200") && page.contains("text/javascript"), "{page}"); + // The type follows the requested name, not the link target. + let page = get(&format!("{base}raw.html"), None).await; + assert!(page.contains("text/html") && page.ends_with("

raw

"), "{page}"); + + let secret = dir.path().join("secret.txt"); + let via_fsroot = format!("fsroot{}", secret.display()); + for escape in ["leak.txt", "up/secret.txt", via_fsroot.as_str(), "escape/"] { + let page = get(&format!("{base}{escape}"), None).await; + assert!(page.starts_with("HTTP/1.1 404"), "{escape}: {page}"); + } + } + #[tokio::test] async fn files_outside_the_project_get_their_own_root() { let dir = tempfile::tempdir().unwrap(); From 7c7d656a50ee579393c322c5580cd0a90ca258a6 Mon Sep 17 00:00:00 2001 From: ntbang0901 <121003585+ntbang0901@users.noreply.github.com> Date: Mon, 2 Nov 2026 22:41:21 +0700 Subject: [PATCH 036/106] fix(run): select and copy the whole console output with Select All (#903) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: Cmd/Ctrl+A in the Run console fell through to the browser and selected the whole window, and copying only saw the rows the virtualizer had mounted. Select All now selects the console only (also in its context menu), and copying after it — keyboard, context menu or native copy — takes the whole buffer. copySelectionOnKeyDown accepts a textOf callback for other views that do not mount all their text. Refs #746 Co-authored-by: ntbang0901 <121003585+ntbang0901@users.noreply.github.com> --- e2e/console-selection.spec.ts | 21 ++++++++++ .../app/__tests__/consoleCopy.test.ts | 27 +++++++++++++ src/features/run/ConsoleView.tsx | 40 +++++++++++++++++-- src/ui/selectableText.ts | 4 +- 4 files changed, 88 insertions(+), 4 deletions(-) diff --git a/e2e/console-selection.spec.ts b/e2e/console-selection.spec.ts index cfec2c291..c0e9c070e 100644 --- a/e2e/console-selection.spec.ts +++ b/e2e/console-selection.spec.ts @@ -82,3 +82,24 @@ test("preserves all lines in a drag selection across virtual windows", async ({ expect(text).toContain("output 250\noutput 251"); expect(text).toContain("output 284"); }); + +test("Select All takes the whole buffer, not just the mounted rows", async ({ page }) => { + const log = await consoleOutput(page); + await page.evaluate(() => { + (window as unknown as { copied: string[] }).copied = []; + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { writeText: async (text: string) => (window as unknown as { copied: string[] }).copied.push(text) }, + }); + }); + await log.focus(); + await page.keyboard.press("Meta+A"); + // Selection stays inside the console: nothing from the workbench behind it is selected. + const selected = await page.evaluate(() => window.getSelection()?.toString() ?? ""); + expect(selected).not.toContain("Project"); + await page.keyboard.press("Meta+C"); + const [copied] = await page.evaluate(() => (window as unknown as { copied: string[] }).copied); + expect(copied?.split("\n")).toHaveLength(500); + expect(copied).toContain("output 0\n"); + expect(copied).toMatch(/output 499$/); +}); diff --git a/src/features/app/__tests__/consoleCopy.test.ts b/src/features/app/__tests__/consoleCopy.test.ts index 3f449702f..b82d12f62 100644 --- a/src/features/app/__tests__/consoleCopy.test.ts +++ b/src/features/app/__tests__/consoleCopy.test.ts @@ -108,6 +108,33 @@ describe("view-local console copy with the Windows keymap (#372)", () => { expect(writeText).toHaveBeenCalledWith("selected application output"); }); + it("Ctrl+A selects only the console and copies the whole buffer, not just the mounted rows (#746)", () => { + const model = new ConsoleModel(); + model.append({ kind: "stdout", text: "first\nsecond\n" }); + vi.spyOn(model, "text").mockReturnValue("whole buffer, including unmounted rows"); + const outside = document.createElement("p"); + outside.textContent = "workbench chrome"; + document.body.append(outside); + render( + createElement(ConsoleView, { + model, + version: 1, + softWrap: false, + follow: false, + onFollowChange: () => {}, + cwd: "/", + }), + ); + const output = screen.getByRole("log"); + const selectAll = copy(output, { key: "a", code: "KeyA", metaKey: true }); + expect(selectAll.defaultPrevented).toBe(true); + const selection = window.getSelection()!; + expect(output.contains(selection.anchorNode) && output.contains(selection.focusNode)).toBe(true); + expect(selection.toString()).not.toContain("workbench chrome"); + copy(output); + expect(writeText).toHaveBeenCalledWith("whole buffer, including unmounted rows"); + }); + it.each([false, true])("copies build output and selected message detail (detail=%s)", (detail) => { useBuildRun.setState({ status: "failed", diff --git a/src/features/run/ConsoleView.tsx b/src/features/run/ConsoleView.tsx index 24cc2287d..8c5aad24b 100644 --- a/src/features/run/ConsoleView.tsx +++ b/src/features/run/ConsoleView.tsx @@ -179,6 +179,7 @@ export function ConsoleView({ ...aria }: ConsoleViewProps) { const scrollRef = useRef(null); + const innerRef = useRef(null); const lines = model.lines; const patterns = useWorkflowSettings((s) => s.console.foldPatterns); const [expanded, setExpanded] = useState>(() => new Set()); @@ -377,9 +378,30 @@ export function ConsoleView({ scrolledMatch.current = key; }, [findOpen, activeMatch, rowOfLine, rows, virtualizer]); + /** Rows are virtualized, so "everything" is a selection of the container, not of the mounted rows. */ + const selectAll = () => { + const inner = innerRef.current; + if (inner) window.getSelection()?.selectAllChildren(inner); + }; + const allSelected = () => { + const inner = innerRef.current; + const s = window.getSelection(); + return ( + !!inner && + !!s && + !s.isCollapsed && + s.anchorNode === inner && + s.focusNode === inner && + s.anchorOffset === 0 && + s.focusOffset === inner.childNodes.length + ); + }; + /** The selected text; after Select All the whole buffer, including rows that are not mounted. */ + const selectedOutput = () => (allSelected() ? model.text() : (window.getSelection()?.toString() ?? "")); + const onContextMenu = (e: MouseEvent) => { e.preventDefault(); - const selection = window.getSelection()?.toString() ?? ""; + const selection = selectedOutput(); showContextMenu(e, [ { label: "Copy", @@ -388,6 +410,7 @@ export function ConsoleView({ disabled: !selection, run: () => void navigator.clipboard?.writeText(selection).catch(() => undefined), }, + { label: "Select All", shortcut: formatBinding(hostBinding("Meta+A")), run: selectAll }, { label: "Copy All", run: () => void navigator.clipboard?.writeText(model.text()).catch(() => undefined) }, { label: "Find…", @@ -410,7 +433,12 @@ export function ConsoleView({ openFind(); return; } - if (copySelectionOnKeyDown(e)) return; + if (hasPrimaryModifier(e) && !e.altKey && !e.shiftKey && e.key.toLowerCase() === "a") { + e.preventDefault(); + selectAll(); + return; + } + if (copySelectionOnKeyDown(e, selectedOutput)) return; if (!input) return; if (e.key === "d" && e.ctrlKey && !e.metaKey && !e.altKey) { e.preventDefault(); @@ -496,10 +524,16 @@ export function ConsoleView({ onScroll={onScroll} onContextMenu={onContextMenu} onKeyDown={onKeyDown} + onCopy={(e) => { + // Native copy (menu, macOS) would take only the mounted rows. + if (!allSelected()) return; + e.preventDefault(); + e.clipboardData.setData("text/plain", model.text()); + }} onPaste={onPaste} data-autofocus="" > -
+
{items.map((item) => { const row = rows[item.index]; if (!row) { diff --git a/src/ui/selectableText.ts b/src/ui/selectableText.ts index d285d3bfd..e8489a48e 100644 --- a/src/ui/selectableText.ts +++ b/src/ui/selectableText.ts @@ -70,6 +70,8 @@ export function copySelectionOnKeyDown( | "preventDefault" | "stopPropagation" >, + /** Text to copy instead of `selection.toString()`, for views that do not mount all their text. */ + textOf: (selection: Selection) => string = (selection) => selection.toString(), ): boolean { if (e.defaultPrevented || e.nativeEvent.isComposing || e.altKey || e.shiftKey) return false; const copy = @@ -87,6 +89,6 @@ export function copySelectionOnKeyDown( return false; e.preventDefault(); e.stopPropagation(); - void copyText(selection.toString()); + void copyText(textOf(selection)); return true; } From eb633f6ca60fcae71033c3fdc3df9a834f583e62 Mon Sep 17 00:00:00 2001 From: arookieofc <2128194521@qq.com> Date: Tue, 3 Nov 2026 00:37:14 +0800 Subject: [PATCH 037/106] feat(run): run npm scripts from package.json gutter icons (#898) Every entry under the top-level `scripts` of a package.json gets a run gutter icon with Run/Debug for that script. It uses the detected npm run configuration (re-detected at click time) and otherwise a temporary configuration built with the same package-manager precedence as backend detection: the `packageManager` field, then the lockfile, inheriting the workspace root's manager for members. Workspace member configuration ids use the full relative directory, as the backend does. The "Show run icons" preference hides these icons too, and the markers are real buttons with translated titles. Fixes #894 Co-authored-by: a-rookie-of-C-language <186315048+a-rookie-of-C-language@users.noreply.github.com> --- src/editor/core/cm/theme.ts | 2 +- src/features/run/index.ts | 2 + src/features/run/npmGutter.test.ts | 311 +++++++++++++++++++++++ src/features/run/npmGutter.ts | 392 +++++++++++++++++++++++++++++ 4 files changed, 706 insertions(+), 1 deletion(-) create mode 100644 src/features/run/npmGutter.test.ts create mode 100644 src/features/run/npmGutter.ts diff --git a/src/editor/core/cm/theme.ts b/src/editor/core/cm/theme.ts index 0390d1f1f..3bfd84274 100644 --- a/src/editor/core/cm/theme.ts +++ b/src/editor/core/cm/theme.ts @@ -105,7 +105,7 @@ export const openideaTheme = EditorView.theme({ boxShadow: "inset 0 1px 0 var(--border-method-separator, var(--border))", }, "&.cm-openidea-hideGutter-run .cm-openidea-gutter-run, &.cm-openidea-hideGutter-bookmarks .cm-openidea-bookmarks": { - display: "none", + display: "none !important", }, // Inheritance markers share one gutter: each group hides its own icons. "&.cm-openidea-hideGutter-inheritors :is(.cm-openidea-lineMarker-implemented, .cm-openidea-lineMarker-overridden), &.cm-openidea-hideGutter-supers :is(.cm-openidea-lineMarker-implementing, .cm-openidea-lineMarker-overriding)": diff --git a/src/features/run/index.ts b/src/features/run/index.ts index a07662b1d..cd2fcd0d3 100644 --- a/src/features/run/index.ts +++ b/src/features/run/index.ts @@ -45,6 +45,7 @@ import { compoundChildren } from "./configs"; import { remoteDebugBlocked } from "./remote"; import { isDebugOnly, registerBuiltinTypes } from "./types"; import { listenForApplicationDeactivation, showApplicationUpdate, updatableSessions } from "./applicationUpdate"; +import { npmGutter } from "./npmGutter"; import { runGutter } from "./runGutter"; import { activateStackTrace, useStackTraces } from "./stackTrace"; import { clearRuntimeCache } from "./runtimes"; @@ -283,6 +284,7 @@ export function activate(): () => void { listenForApplicationDeactivation(), useDebugRunSessions.subscribe(() => setContext("run.updatableRunning", updatableSessions().length > 0)), registerEditorContribution({ id: "run.targets", order: 55, extensions: runGutter }), + registerEditorContribution({ id: "run.npmScripts", order: 58, extensions: npmGutter }), registerMenuGroup({ id: "run.context", menus: ["editor", "projectView", "editorTab"], diff --git a/src/features/run/npmGutter.test.ts b/src/features/run/npmGutter.test.ts new file mode 100644 index 000000000..cff1da5c4 --- /dev/null +++ b/src/features/run/npmGutter.test.ts @@ -0,0 +1,311 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { EditorView } from "@codemirror/view"; +import { commands, type RunConfig } from "@/platform/ipc"; +import { useProject } from "@/platform/project"; +import { + npmGutter, + scriptConfigFor, + scriptConfigIds, + scriptEntries, + scriptMenuItems, + scriptPackageManager, +} from "./npmGutter"; +import * as ui from "@/ui"; +import * as store from "./store"; + +function cfg(over: Partial): RunConfig { + return { + id: "x", + name: "x", + kind: "npm", + command: "npm", + args: [], + cwd: null, + env: {}, + detected: true, + isTest: false, + debugAdapter: null, + ...over, + }; +} + +describe("scriptEntries", () => { + it("lists scripts with their lines in document order", () => { + const text = [ + "{", + ' "name": "app",', + ' "scripts": {', + ' "dev": "vite",', + ' "build": "tsc && vite build",', + ' "test": "vitest run"', + " }", + "}", + ].join("\n"); + expect(scriptEntries(text)).toEqual([ + { name: "dev", line: 3 }, + { name: "build", line: 4 }, + { name: "test", line: 5 }, + ]); + }); + + it("parses names with escapes and values containing braces or colons", () => { + const text = [ + "{", + ' "scripts": {', + ' "say \\"hi\\"": "echo \\"{\\" done",', + ' "a\\u0041b": "node -e \\"x:{y}\\""', + " }", + "}", + ].join("\n"); + expect(scriptEntries(text).map((e) => e.name)).toEqual(['say "hi"', "aAb"]); + expect(scriptEntries(text).map((e) => e.line)).toEqual([2, 3]); + }); + + it("ignores scripts nested in other objects", () => { + const text = [ + "{", + ' "a": {', + ' "scripts": {', + ' "inner": "x"', + " }", + " },", + ' "scripts": {', + ' "outer": "y"', + " }", + "}", + ].join("\n"); + expect(scriptEntries(text)).toEqual([{ name: "outer", line: 7 }]); + }); + + it("tracks lines through object, array and scalar values", () => { + const text = [ + "{", + ' "scripts": {', + ' "a": 1,', + ' "b": true,', + ' "c": null,', + ' "d": [1, 2],', + ' "e": { "x": 1 },', + ' "f": "ok"', + " }", + "}", + ].join("\n"); + expect(scriptEntries(text).map((e) => e.name)).toEqual(["a", "b", "c", "d", "e", "f"]); + expect(scriptEntries(text).map((e) => e.line)).toEqual([2, 3, 4, 5, 6, 7]); + }); + + it("returns nothing without a scripts key", () => { + expect(scriptEntries('{\n "name": "x"\n}\n')).toEqual([]); + }); + + it("returns an empty scripts object as no entries", () => { + expect(scriptEntries('{\n "scripts": {}\n}\n')).toEqual([]); + }); + + it("ignores a scripts value that is not an object", () => { + expect(scriptEntries('{\n "scripts": "dev"\n}\n')).toEqual([]); + }); + + it("keeps entries parsed before malformed text", () => { + const text = ["{", ' "scripts": {', ' "a": "x",', ' "b": '].join("\n"); + expect(scriptEntries(text)).toEqual([{ name: "a", line: 2 }]); + }); + + it("tolerates a trailing comma", () => { + expect(scriptEntries('{\n "scripts": {\n "a": "x",\n },\n}\n')).toEqual([{ name: "a", line: 2 }]); + }); + + it("skips a BOM", () => { + expect(scriptEntries('{\n "scripts": {\n "dev": "vite"\n }\n}\n')).toEqual([{ name: "dev", line: 2 }]); + }); + + it("tracks CRLF lines after non-ASCII and astral characters", () => { + const text = [ + "{", + ' "description": "中文 🚀 café",', + ' "scripts": {', + ' "构建🚀": "echo 完成",', + ' "test": "echo ok"', + " }", + "}", + ].join("\r\n"); + expect(scriptEntries(text)).toEqual([ + { name: "构建🚀", line: 3 }, + { name: "test", line: 4 }, + ]); + }); + + it("decodes escaped unicode and backslashes in keys and values", () => { + const text = String.raw`{"scripts":{"\u6784\u5efa\ud83d\ude80":"echo \"ok\" \\path","say\\hi":"echo ok"}}`; + expect(scriptEntries(text)).toEqual([ + { name: "构建🚀", line: 0 }, + { name: "say\\hi", line: 0 }, + ]); + }); + + it("produces no entries for scripts nested in an object or array", () => { + expect(scriptEntries('{"config":{"scripts":{"nested":"echo no"}}}')).toEqual([]); + expect(scriptEntries('{"config":[{"scripts":{"nested":"echo no"}}]}')).toEqual([]); + }); + + it("skips long numeric metadata before scripts", () => { + expect(scriptEntries('{"metadata":1234567890123456,"scripts":{"dev":"echo ok"}}')).toEqual([ + { name: "dev", line: 0 }, + ]); + }); +}); + +describe("scriptPackageManager", () => { + it.each(["npm", "pnpm", "yarn", "bun"])("prefers the declared %s over lockfiles", (pm) => { + expect(scriptPackageManager({ packageManager: `${pm}@1.0.0` }, ["pnpm-lock.yaml", "yarn.lock", "bun.lock"])).toBe( + pm, + ); + }); + + it.each([ + ["pnpm-lock.yaml", "pnpm"], + ["yarn.lock", "yarn"], + ["bun.lock", "bun"], + ["bun.lockb", "bun"], + ["package-lock.json", "npm"], + ])("uses %s when no manager is declared", (file, pm) => { + expect(scriptPackageManager({}, [file])).toBe(pm); + expect(scriptPackageManager({ packageManager: "unknown@1" }, [file])).toBe(pm); + }); + + it("uses backend lockfile precedence and defaults to npm", () => { + expect(scriptPackageManager(null, ["bun.lock", "yarn.lock", "pnpm-lock.yaml"])).toBe("pnpm"); + expect(scriptPackageManager(null, ["bun.lock", "yarn.lock"])).toBe("yarn"); + expect(scriptPackageManager(null, [])).toBe("npm"); + }); +}); + +describe("scriptMenuItems", () => { + beforeEach(() => { + vi.spyOn(store, "loadConfigs").mockResolvedValue(); + useProject.setState({ project: { root: "/project" } as never }); + store.useRun.setState({ configs: [] }); + vi.spyOn(commands, "fsReadDir").mockResolvedValue({ + status: "ok", + data: [{ name: "pnpm-lock.yaml", path: "/project/pnpm-lock.yaml", kind: "file" } as never], + }); + vi.spyOn(commands, "fsReadText").mockRejectedValue(new Error("not found")); + }); + afterEach(() => { + vi.restoreAllMocks(); + useProject.setState({ project: null }); + store.useRun.setState({ configs: [] }); + }); + + it("uses the package lockfile for an undetected script", async () => { + const items = await scriptMenuItems({ name: "new", line: 0 }, "/project/package.json", "{}"); + expect(items).toMatchObject([{ label: "Run 'pnpm run new'" }, { label: "Debug 'pnpm run new'" }]); + expect(commands.fsReadDir).toHaveBeenCalledWith("/project/"); + }); + + it("inherits the backend's workspace manager from a sibling script", async () => { + store.useRun.setState({ configs: [cfg({ command: "yarn", cwd: "packages/web" })] }); + const items = await scriptMenuItems( + { name: "new", line: 0 }, + "/project/packages/web/package.json", + '{"packageManager":"pnpm@9"}', + ); + expect(items).toMatchObject([{ label: "Run 'yarn run new'" }, { label: "Debug 'yarn run new'" }]); + expect(commands.fsReadDir).not.toHaveBeenCalled(); + }); + + it("does not inherit an unrelated package's manager or a custom executable", async () => { + store.useRun.setState({ + configs: [cfg({ command: "yarn", cwd: "other" }), cfg({ command: "echo", cwd: null })], + }); + const items = await scriptMenuItems({ name: "new", line: 0 }, "/project/package.json", "{}"); + expect(items).toMatchObject([{ label: "Run 'pnpm run new'" }, { label: "Debug 'pnpm run new'" }]); + }); + + it("uses root detection for a workspace member's first script", async () => { + vi.mocked(commands.fsReadText).mockResolvedValue({ status: "ok", data: '{"packageManager":"yarn@4"}' }); + const items = await scriptMenuItems({ name: "new", line: 0 }, "/project/packages/web/package.json", "{}"); + expect(items).toMatchObject([{ label: "Run 'yarn run new'" }, { label: "Debug 'yarn run new'" }]); + expect(commands.fsReadDir).toHaveBeenCalledWith("/project/"); + }); + + it("uses local detection for a polyglot package without a root manifest", async () => { + const items = await scriptMenuItems({ name: "new", line: 0 }, "/project/web/package.json", "{}"); + expect(items).toMatchObject([{ label: "Run 'pnpm run new'" }, { label: "Debug 'pnpm run new'" }]); + expect(commands.fsReadDir).toHaveBeenCalledWith("/project/web/"); + }); + + it("preserves a matching user configuration", async () => { + store.useRun.setState({ configs: [cfg({ id: "npm:new", name: "Custom new", command: "custom" })] }); + const items = await scriptMenuItems({ name: "new", line: 0 }, "/project/package.json", "{}"); + expect(items).toMatchObject([{ label: "Run 'Custom new'" }, { label: "Debug 'Custom new'" }]); + expect(commands.fsReadDir).not.toHaveBeenCalled(); + }); + + it("renders a native button and opens its menu on keyboard-style activation after Unicode text", async () => { + const show = vi.spyOn(ui, "showContextMenu").mockImplementation(() => {}); + const text = '{\r\n "description": "中文 🚀",\r\n "scripts": {\r\n "dev": "echo ok"\r\n }\r\n}'; + const view = new EditorView({ + doc: text, + parent: document.body, + extensions: npmGutter({ doc: 1, path: "/project/package.json", language: "json", uiMode: null, readOnly: false }), + }); + try { + await vi.waitFor(() => expect(view.dom.querySelector("button[aria-label=\"Run 'dev'\"]")).not.toBeNull()); + expect(view.state.doc.line(4).from).toBe(text.replaceAll("\r\n", "\n").indexOf(' "dev"')); + view.dom.querySelector("button[aria-label=\"Run 'dev'\"]")!.click(); + await vi.waitFor(() => + expect(show).toHaveBeenCalledWith(expect.anything(), [ + expect.objectContaining({ label: "Run 'pnpm run dev'" }), + expect.objectContaining({ label: "Debug 'pnpm run dev'" }), + ]), + ); + } finally { + view.destroy(); + } + }); +}); + +describe("scriptConfigFor", () => { + const root = cfg({ id: "npm:dev", name: "pnpm run dev", command: "pnpm", args: ["run", "dev"], cwd: null }); + + it("matches the root package's detected id", () => { + expect(scriptConfigFor([root], "dev", "", null)).toBe(root); + }); + + it("matches a workspace member by its package name or directory", () => { + const web = cfg({ + id: "npm:web:dev", + name: "web: pnpm run dev", + command: "pnpm", + args: ["run", "dev"], + cwd: "web", + }); + expect(scriptConfigFor([web], "dev", "web", "web")).toBe(web); + expect(scriptConfigFor([web], "dev", "web", null)).toBe(web); + }); + + it("falls back to npm run args in the same directory", () => { + const custom = cfg({ id: "user-cfg", name: "my dev", args: ["run", "dev"], cwd: "./web/" }); + expect(scriptConfigFor([custom], "dev", "web", null)).toBe(custom); + }); + + it("matches an unnamed nested member's overridden config by its full relative directory", () => { + const custom = cfg({ id: "npm:packages/web:dev", command: "custom", cwd: "packages/web" }); + expect(scriptConfigFor([custom], "dev", "packages/web", null)).toBe(custom); + }); + + it("prefers an id match over an args match", () => { + const byArgs = cfg({ id: "user-cfg", args: ["run", "dev"], cwd: null }); + expect(scriptConfigFor([byArgs, root], "dev", "", null)).toBe(root); + }); + + it("finds nothing in another directory", () => { + expect(scriptConfigFor([root], "dev", "web", null)).toBeUndefined(); + }); + + it("derives the backend configuration ids", () => { + expect(scriptConfigIds("dev", null, "")).toEqual(["npm:dev"]); + expect(scriptConfigIds("dev", "web", "packages/web")).toEqual(["npm:dev", "npm:web:dev", "npm:packages/web:dev"]); + }); +}); diff --git a/src/features/run/npmGutter.ts b/src/features/run/npmGutter.ts new file mode 100644 index 000000000..cf75cbb29 --- /dev/null +++ b/src/features/run/npmGutter.ts @@ -0,0 +1,392 @@ +/** + * Gutter ▶ icons on `package.json` `scripts` entries (like IDEA's package.json + * gutter): Run / Debug launch the npm run configuration the project detected + * for the script, or a temporary one when it has none. + */ +import { RangeSet, StateEffect, StateField, type Extension } from "@codemirror/state"; +import { EditorView, GutterMarker, ViewPlugin, gutter, type ViewUpdate } from "@codemirror/view"; +import type { EditorDocContext } from "@/editor/api"; +import { executeAction } from "@/platform/actions"; +import { tx } from "@/platform/i18n"; +import { call, commands, type RunConfig } from "@/platform/ipc"; +import { shortcutLabel } from "@/platform/keymap"; +import { useProject } from "@/platform/project"; +import { gutterIconMarkup, showContextMenu, type MenuItem } from "@/ui"; +import { loadConfigs, useRun } from "./store"; +import styles from "./Run.module.css"; + +/** One `scripts` entry: the script name and its 0-based line. */ +export interface ScriptEntry { + name: string; + line: number; +} + +/** + * The top-level `scripts` entries of `package.json` text, in document order + * (a small JSON scanner: script values may contain braces, colons, escapes). + * Entries parsed so far survive malformed text, so a trailing comma keeps + * earlier scripts visible. + */ +export function scriptEntries(text: string): ScriptEntry[] { + const entries: ScriptEntry[] = []; + let i = 0; + let line = 0; + const BROKEN = new Error("broken"); + + const ws = () => { + while (i < text.length) { + const c = text[i]!; + if (c === "\n") { + line++; + i++; + } else if (c === " " || c === "\t" || c === "\r" || c === "") { + i++; + } else { + break; + } + } + }; + /** The JSON string at `i`, with escapes; advances past it. */ + const str = (): string => { + if (text[i] !== '"') throw BROKEN; + i++; + let out = ""; + for (;;) { + const c = text[i]; + if (c === undefined) throw BROKEN; + if (c === '"') { + i++; + return out; + } + if (c === "\\") { + const e = text[i + 1]; + if (e === undefined) throw BROKEN; + if (e === "\n") line++; + if (e === "u") { + const hex = text.slice(i + 2, i + 6); + if (!/^[0-9a-fA-F]{4}$/.test(hex)) throw BROKEN; + out += String.fromCodePoint(Number.parseInt(hex, 16)); + i += 6; + } else { + out += { n: "\n", t: "\t", r: "\r", b: "\b", f: "\f" }[e] ?? e; + i += 2; + } + continue; + } + if (c === "\n") line++; + out += c; + i++; + } + }; + /** Skips one JSON value (objects and arrays recursively), tracking lines. */ + const value = () => { + ws(); + const c = text[i]; + if (c === '"') { + str(); + return; + } + if (c === "{" || c === "[") { + const close = c === "{" ? "}" : "]"; + i++; + ws(); + if (text[i] === close) { + i++; + return; + } + for (;;) { + if (c === "{") { + ws(); + str(); + ws(); + if (text[i] !== ":") throw BROKEN; + i++; + } + value(); + ws(); + if (text[i] === ",") { + i++; + continue; + } + if (text[i] === close) { + i++; + return; + } + throw BROKEN; + } + } + const m = /^(?:-?\d+(?:\.\d+)?(?:[eE][-+]?\d+)?|true|false|null)/.exec(text.slice(i)); + if (!m) throw BROKEN; + i += m[0].length; + }; + try { + ws(); + if (text[i] !== "{") return entries; + i++; + ws(); + if (text[i] !== "}") { + for (;;) { + ws(); + const key = str(); + ws(); + if (text[i] !== ":") break; + i++; + ws(); + if (key === "scripts" && text[i] === "{") { + i++; + ws(); + if (text[i] !== "}") { + for (;;) { + ws(); + const scriptLine = line; + const name = str(); + ws(); + if (text[i] !== ":") throw BROKEN; + i++; + value(); + entries.push({ name, line: scriptLine }); + ws(); + if (text[i] === ",") { + i++; + continue; + } + break; + } + } + i++; // the closing `}` of `scripts` + } else { + value(); + } + ws(); + if (text[i] === ",") { + i++; + continue; + } + break; + } + } + } catch (e) { + if (e !== BROKEN) throw e; + } + return entries; +} + +type PackageJson = { name?: unknown; packageManager?: unknown }; + +function safeParse(text: string): PackageJson | null { + try { + return JSON.parse(text) as PackageJson; + } catch { + return null; + } +} + +/** Same precedence as backend detection for a package: declaration, then lockfile. */ +export function scriptPackageManager(pkg: PackageJson | null, files: readonly string[]): string { + const pm = typeof pkg?.packageManager === "string" ? pkg.packageManager.split("@")[0] : ""; + if (pm === "pnpm" || pm === "yarn" || pm === "bun" || pm === "npm") return pm; + if (files.includes("pnpm-lock.yaml")) return "pnpm"; + if (files.includes("yarn.lock")) return "yarn"; + if (files.includes("bun.lock") || files.includes("bun.lockb")) return "bun"; + return "npm"; +} + +const normCwd = (cwd: string | null) => (cwd ?? "").replace(/^\.\/?/, "").replace(/\/+$/, ""); + +/** Backend ids for a script: the root package's, the member's by its `name` or its directory. */ +export function scriptConfigIds(name: string, pkgName: string | null, dir: string): string[] { + const ids = new Set([`npm:${name}`]); + if (pkgName) ids.add(`npm:${pkgName}:${name}`); + if (dir) ids.add(`npm:${dir}:${name}`); + return [...ids]; +} + +/** + * The project's configuration for the script in the package at `dir` (relative + * to the project root): the detected id first (a user override keeps it), then + * `npm run ` args in the same working directory. + */ +export function scriptConfigFor( + configs: readonly RunConfig[], + name: string, + dir: string, + pkgName: string | null, +): RunConfig | undefined { + const inDir = (c: RunConfig) => normCwd(c.cwd) === dir; + const ids = new Set(scriptConfigIds(name, pkgName, dir)); + return ( + configs.find((c) => inDir(c) && ids.has(c.id)) ?? + configs.find((c) => inDir(c) && c.kind === "npm" && c.args[0] === "run" && c.args[1] === name) + ); +} + +/** What the backend would detect (`detect.rs`): test scripts render results in the test tree. */ +const isTestScript = (name: string) => + name === "test" || name === "vitest" || name === "jest" || name.startsWith("test:"); + +export async function scriptMenuItems(entry: ScriptEntry, path: string, content: string): Promise { + const root = useProject.getState().project?.root ?? null; + const rel = root && path.startsWith(`${root}/`) ? path.slice(root.length + 1) : path; + const dir = rel.endsWith("/package.json") ? rel.slice(0, -"/package.json".length) : ""; + try { + // Detection reads the disk: a script typed moments ago is already there. + await loadConfigs(); + } catch { + // Fall back to a temporary configuration below. + } + const pkg = safeParse(content.slice(0, 400_000)); + const matched = scriptConfigFor( + useRun.getState().configs, + entry.name, + dir, + typeof pkg?.name === "string" ? pkg.name : null, + ); + // Reuse detection for sibling scripts, including a workspace's inherited manager. + const sibling = useRun + .getState() + .configs.find( + (c) => + c.kind === "npm" && c.detected && normCwd(c.cwd) === dir && ["npm", "pnpm", "yarn", "bun"].includes(c.command), + ); + let pm = matched?.command ?? sibling?.command; + if (!pm) { + // Workspace detection uses the root package's manager, even for a member's first script. + const rootPkg = + root && dir + ? await call(commands.fsReadText(`${root}/package.json`)) + .then(safeParse) + .catch(() => null) + : null; + const packageDir = rootPkg ? `${root}/` : path.slice(0, -"package.json".length); + const files = await call(commands.fsReadDir(packageDir)).catch(() => []); + pm = scriptPackageManager( + rootPkg ?? pkg, + files.filter((f) => f.kind === "file" || f.targetKind === "file").map((f) => f.name), + ); + } + const label = matched?.name ?? `${pm} run ${entry.name}`; + const config = matched ?? { + id: `context:npm:${dir || "root"}:${entry.name}`, + name: label, + kind: "npm", + command: pm, + args: ["run", entry.name], + cwd: dir || null, + env: {}, + detected: true, + isTest: isTestScript(entry.name), + debugAdapter: "js-debug", + }; + return [ + { + label: tx`Run '${label}'`, + icon: "run", + shortcut: shortcutLabel("run.runContext"), + run: () => + matched + ? void executeAction("run.runConfig", matched.id) + : void executeAction("run.runConfig", { config, temporary: true }), + }, + { + label: tx`Debug '${label}'`, + icon: "debug", + shortcut: shortcutLabel("run.debugContext"), + run: () => void executeAction("run.debugConfig", config), + }, + ]; +} + +interface Placed extends ScriptEntry { + pos: number; +} + +const setEntries = StateEffect.define(); + +const entriesField = StateField.define({ + create: () => [], + update(value, tr) { + for (const e of tr.effects) if (e.is(setEntries)) return e.value; + if (!tr.docChanged) return value; + return value.map((p) => ({ ...p, pos: tr.state.doc.lineAt(tr.changes.mapPos(p.pos, -1)).from })); + }, +}); + +class ScriptMarker extends GutterMarker { + constructor( + readonly entry: ScriptEntry, + readonly path: string, + ) { + super(); + } + override eq(other: GutterMarker): boolean { + return other instanceof ScriptMarker && other.entry.name === this.entry.name; + } + override toDOM(view: EditorView): Node { + const el = document.createElement("button"); + el.type = "button"; + el.className = styles.runMarker ?? ""; + el.title = tx`Run '${this.entry.name}'`; + el.setAttribute("aria-label", el.title); + el.innerHTML = gutterIconMarkup("gutter-run") ?? ""; + el.addEventListener("click", () => { + const rect = el.getBoundingClientRect(); + void scriptMenuItems(this.entry, this.path, view.state.doc.toString()).then((items) => + showContextMenu({ x: rect.left, y: rect.bottom + 2 }, items), + ); + }); + return el; + } +} + +const DEBOUNCE = 300; + +export function npmGutter(ctx: EditorDocContext): Extension { + const path = ctx.path; + if (ctx.virtual || !/(^|\/)package\.json$/.test(path) || !useProject.getState().project?.root) return []; + const detector = ViewPlugin.fromClass( + class { + timer: ReturnType | undefined; + constructor(readonly view: EditorView) { + this.schedule(0); + } + update(u: ViewUpdate) { + if (u.docChanged) this.schedule(DEBOUNCE); + } + schedule(delay: number) { + clearTimeout(this.timer); + this.timer = setTimeout(() => { + const view = this.view; + const entries = scriptEntries(view.state.doc.toString()) + .filter((e) => e.line < view.state.doc.lines) + .map((e) => ({ ...e, pos: view.state.doc.line(e.line + 1).from })); + view.dispatch({ effects: setEntries.of(entries) }); + }, delay); + } + destroy() { + clearTimeout(this.timer); + } + }, + ); + return [ + entriesField, + detector, + gutter({ + // Shares the run gutter's class so "Show run icons" hides these too. + class: `${styles.runGutter} cm-openidea-gutter-run`, + markers: (view) => + RangeSet.of( + view.state.field(entriesField).map((e) => new ScriptMarker(e, path).range(e.pos)), + true, + ), + initialSpacer: () => new ScriptMarker({ name: "", line: 0 }, path), + domEventHandlers: { + mousedown: (view, block, event) => { + const entry = view.state.field(entriesField).find((e) => e.pos === block.from); + if (!entry || (event as MouseEvent).button !== 0) return false; + event.preventDefault(); + return true; + }, + }, + }), + ]; +} From db5251efe645b8e4136ac35554d8cf760c356aac Mon Sep 17 00:00:00 2001 From: Florent V Date: Mon, 2 Nov 2026 18:44:28 +0100 Subject: [PATCH 038/106] fix(plugins): install plugin packages from a .zip (#896) Root cause: Install Plugin from Disk only opened a folder picker and the host could only copy folders, so a zipped plugin could not be installed. The picker now accepts a .zip or a package's plugin.toml, and the host's install_from_path unpacks a zip to a temporary folder and runs it through the existing install_from_dir validation. The package may sit at the archive root or in its only folder (archiver metadata such as __MACOSX is ignored). Archives with unsafe paths or symbolic links, more than 10,000 entries or over 1 GB unpacked are refused, and a zip without plugin.toml (e.g. an IntelliJ plugin) gets a clear error. Fixes #892 Co-authored-by: florentvdry <16673235+florentvdry@users.noreply.github.com> --- Cargo.lock | 1 + crates/ide-plugin/Cargo.toml | 5 +- crates/ide-plugin/src/lib.rs | 15 +++++ crates/ide-plugin/src/package.rs | 74 +++++++++++++++++++++++++ crates/ide-plugin/src/types.rs | 2 +- crates/ide-plugin/tests/host.rs | 91 +++++++++++++++++++++++++++++++ docs/plugins/themes.md | 4 +- src-tauri/src/commands/plugin.rs | 4 +- src/bindings.ts | 4 +- src/features/plugins/backend.ts | 14 +++-- src/features/plugins/store.ts | 12 ++-- src/platform/locales/text/zh.json | 3 +- 12 files changed, 208 insertions(+), 21 deletions(-) create mode 100644 crates/ide-plugin/src/package.rs diff --git a/Cargo.lock b/Cargo.lock index 56a55e9bd..a55ceac55 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4428,6 +4428,7 @@ dependencies = [ "tracing", "wasmtime", "wasmtime-wasi", + "zip", ] [[package]] diff --git a/crates/ide-plugin/Cargo.toml b/crates/ide-plugin/Cargo.toml index 06b54ab56..d6bd3055b 100644 --- a/crates/ide-plugin/Cargo.toml +++ b/crates/ide-plugin/Cargo.toml @@ -15,8 +15,7 @@ toml.workspace = true thiserror.workspace = true tracing.workspace = true parking_lot.workspace = true +tempfile.workspace = true +zip.workspace = true wasmtime = { version = "49.0.4", default-features = false, features = ["runtime", "cranelift", "component-model", "std", "parallel-compilation"] } wasmtime-wasi = { version = "49.0.4", default-features = false, features = ["p2"] } - -[dev-dependencies] -tempfile.workspace = true diff --git a/crates/ide-plugin/src/lib.rs b/crates/ide-plugin/src/lib.rs index 724465769..dc6a3ed4e 100644 --- a/crates/ide-plugin/src/lib.rs +++ b/crates/ide-plugin/src/lib.rs @@ -15,6 +15,7 @@ //! (dev plugins, loaded by [`PluginHost::sync_project`]). mod manifest; +mod package; mod runtime; mod sandbox; mod theme; @@ -263,6 +264,20 @@ impl PluginHost { self.info_of(&new_id) } + /// Installs the package at `path`: a folder, its `plugin.toml`, or a `.zip` of the folder. + pub fn install_from_path(&self, path: &Path) -> IdeResult { + if package::is_zip(path) && path.is_file() { + // Outside the plugins directory: a crash must not leave a package that loads. + let unpacked = tempfile::Builder::new().prefix("openidea-plugin-").tempdir()?; + let root = package::unpack_zip(path, unpacked.path())?; + return self.install_from_dir(&root); + } + if path.file_name().is_some_and(|name| name == MANIFEST_FILE) && path.is_file() { + return self.install_from_dir(path.parent().unwrap_or(path)); + } + self.install_from_dir(path) + } + /// Copies a plugin package (manifest + referenced files) into the plugins /// directory, replacing an installed version with the same id. pub fn install_from_dir(&self, src: &Path) -> IdeResult { diff --git a/crates/ide-plugin/src/package.rs b/crates/ide-plugin/src/package.rs new file mode 100644 index 000000000..cefb3fa90 --- /dev/null +++ b/crates/ide-plugin/src/package.rs @@ -0,0 +1,74 @@ +//! Plugin packages on disk: a folder, its `plugin.toml`, or a `.zip` of the folder. + +use crate::MANIFEST_FILE; +use ide_core::{IdeError, IdeResult}; +use std::fs::{self, File}; +use std::io::{self, BufReader, Read}; +use std::path::{Path, PathBuf}; + +/// Far above any real package; stops a zip bomb before it fills the disk. +const MAX_ENTRIES: usize = 10_000; +const MAX_UNPACKED_BYTES: u64 = 1024 * 1024 * 1024; + +pub(crate) fn is_zip(path: &Path) -> bool { + path.extension().is_some_and(|e| e.eq_ignore_ascii_case("zip")) +} + +fn invalid_zip(zip: &Path, reason: impl std::fmt::Display) -> IdeError { + IdeError::invalid(format!("{} is not a valid plugin archive: {reason}", zip.display())) +} + +/// Unpacks `zip` into `dest` and returns the package folder inside it. Refuses the +/// whole archive when an entry would leave `dest` or is a symbolic link. +pub(crate) fn unpack_zip(zip: &Path, dest: &Path) -> IdeResult { + let mut archive = zip::ZipArchive::new(BufReader::new(File::open(zip)?)).map_err(|e| invalid_zip(zip, e))?; + if archive.len() > MAX_ENTRIES { + return Err(invalid_zip(zip, format!("more than {MAX_ENTRIES} entries"))); + } + let mut budget = MAX_UNPACKED_BYTES; + for i in 0..archive.len() { + let mut entry = archive.by_index(i).map_err(|e| invalid_zip(zip, e))?; + let name = entry.name().to_owned(); + let Some(rel) = entry.enclosed_name() else { + return Err(invalid_zip(zip, format!("unsafe path {name:?}"))); + }; + if entry.is_symlink() { + return Err(invalid_zip(zip, format!("symbolic link {name:?}"))); + } + let out = dest.join(rel); + if entry.is_dir() { + fs::create_dir_all(&out)?; + continue; + } + if let Some(parent) = out.parent() { + fs::create_dir_all(parent)?; + } + // One byte over the budget tells a full budget from a file that exactly fits. + let written = + io::copy(&mut (&mut entry).take(budget + 1), &mut File::create(&out)?).map_err(|e| invalid_zip(zip, e))?; + if written > budget { + return Err(invalid_zip(zip, "it unpacks to more than 1 GB")); + } + budget -= written; + } + package_root(dest).ok_or_else(|| { + IdeError::invalid(format!( + "{} is not an OpenIDEA plugin: no {MANIFEST_FILE} at its root or in its only folder", + zip.display() + )) + }) +} + +/// `dir` itself, or its only folder as when a zip holds the package folder. +/// Archiver metadata (`__MACOSX`, dot files) does not count as a second folder. +fn package_root(dir: &Path) -> Option { + if dir.join(MANIFEST_FILE).is_file() { + return Some(dir.to_path_buf()); + } + let mut folders = fs::read_dir(dir).ok()?.flatten().map(|e| e.path()).filter(|p| { + let name = p.file_name().unwrap_or_default().to_string_lossy(); + p.is_dir() && !name.starts_with('.') && name != "__MACOSX" + }); + let only = folders.next().filter(|_| folders.next().is_none())?; + only.join(MANIFEST_FILE).is_file().then_some(only) +} diff --git a/crates/ide-plugin/src/types.rs b/crates/ide-plugin/src/types.rs index be25b2413..47890f3de 100644 --- a/crates/ide-plugin/src/types.rs +++ b/crates/ide-plugin/src/types.rs @@ -89,7 +89,7 @@ pub struct CatalogPlugin { pub authors: Vec, pub homepage: Option, pub change_notes: Option, - /// Package directory; install with `install_from_dir(path)`. + /// Package directory; install with `install_from_path(path)`. pub path: ide_core::IpcPath, pub has_code: bool, pub permissions: Vec, diff --git a/crates/ide-plugin/tests/host.rs b/crates/ide-plugin/tests/host.rs index a644cca9e..2c2572c03 100644 --- a/crates/ide-plugin/tests/host.rs +++ b/crates/ide-plugin/tests/host.rs @@ -8,6 +8,7 @@ use ide_plugin::*; use parking_lot::Mutex; +use std::io::Write; use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::Duration; @@ -510,3 +511,93 @@ fn restricted_dev_plugin_has_no_activation_registration() { f.host.sync_project(&f.project); assert!(f.host.list(Some(&f.project)).plugins.is_empty()); } + +/// Zips the files of `dir` under `prefix` (recursively). +fn add_dir(zip: &mut zip::ZipWriter, dir: &Path, prefix: &str) { + for entry in std::fs::read_dir(dir).unwrap().flatten() { + let name = format!("{prefix}{}", entry.file_name().to_string_lossy()); + if entry.path().is_dir() { + add_dir(zip, &entry.path(), &format!("{name}/")); + } else { + zip.start_file(name, zip::write::SimpleFileOptions::default()).unwrap(); + zip.write_all(&std::fs::read(entry.path()).unwrap()).unwrap(); + } + } +} + +fn zip_of(dir: &Path, prefix: &str, out: &Path) -> PathBuf { + let mut zip = zip::ZipWriter::new(std::fs::File::create(out).unwrap()); + add_dir(&mut zip, dir, prefix); + zip.finish().unwrap(); + out.to_path_buf() +} + +#[test] +fn installs_zips_with_the_package_at_the_root_or_in_one_folder() { + let f = fixture(Limits::default()); + let tmp = tempfile::tempdir().unwrap(); + for (name, prefix) in [("root.ZIP", ""), ("folder.zip", "hello-1.0/")] { + let zip = zip_of(&sample("hello"), prefix, &tmp.path().join(name)); + let info = f.host.install_from_path(&zip).unwrap(); + assert_eq!((info.id.as_str(), info.status), ("hello", PluginStatus::Active), "{:?}", info.error); + assert!(f.plugins_dir.join("hello/dist/plugin.wasm").is_file()); + } + // Still runs from the installed copy, not the unpacked archive. + assert!(f.run("hello", "insert-date", None).unwrap().is_some()); +} + +#[test] +fn installs_from_a_manifest_or_a_folder() { + let f = fixture(Limits::default()); + assert_eq!(f.host.install_from_path(&sample("hello").join("plugin.toml")).unwrap().id, "hello"); + assert_eq!(f.host.install_from_path(&sample("hello")).unwrap().id, "hello"); +} + +#[test] +fn ignores_archiver_metadata_next_to_the_package_folder() { + let f = fixture(Limits::default()); + let tmp = tempfile::tempdir().unwrap(); + let out = tmp.path().join("mac.zip"); + let mut zip = zip::ZipWriter::new(std::fs::File::create(&out).unwrap()); + add_dir(&mut zip, &sample("hello"), "hello/"); + zip.start_file("__MACOSX/hello/._plugin.toml", zip::write::SimpleFileOptions::default()).unwrap(); + zip.write_all(b"metadata").unwrap(); + zip.finish().unwrap(); + assert_eq!(f.host.install_from_path(&out).unwrap().id, "hello"); +} + +#[test] +fn refuses_zips_that_are_not_safe_plugin_packages() { + let f = fixture(Limits::default()); + let tmp = tempfile::tempdir().unwrap(); + let options = zip::write::SimpleFileOptions::default; + + let no_manifest = tmp.path().join("idea-plugin.zip"); + let mut zip = zip::ZipWriter::new(std::fs::File::create(&no_manifest).unwrap()); + zip.start_file("idea-plugin/lib/plugin.jar", options()).unwrap(); + zip.finish().unwrap(); + let error = f.host.install_from_path(&no_manifest).unwrap_err(); + assert!(error.message.contains("no plugin.toml"), "{}", error.message); + + let escape = tmp.path().join("escape.zip"); + let mut zip = zip::ZipWriter::new(std::fs::File::create(&escape).unwrap()); + zip.start_file("../../evil.txt", options()).unwrap(); + zip.write_all(b"evil").unwrap(); + zip.finish().unwrap(); + let error = f.host.install_from_path(&escape).unwrap_err(); + assert!(error.message.contains("unsafe path"), "{}", error.message); + + let link = tmp.path().join("link.zip"); + let mut zip = zip::ZipWriter::new(std::fs::File::create(&link).unwrap()); + zip.add_symlink("plugin.toml", "/etc/passwd", options()).unwrap(); + zip.finish().unwrap(); + let error = f.host.install_from_path(&link).unwrap_err(); + assert!(error.message.contains("symbolic link"), "{}", error.message); + + let garbage = tmp.path().join("garbage.zip"); + std::fs::write(&garbage, "not a zip").unwrap(); + let error = f.host.install_from_path(&garbage).unwrap_err(); + assert!(error.message.contains("not a valid plugin archive"), "{}", error.message); + + assert!(!f.plugins_dir.join("hello").exists()); +} diff --git a/docs/plugins/themes.md b/docs/plugins/themes.md index 6b038fc5c..013041bf5 100644 --- a/docs/plugins/themes.md +++ b/docs/plugins/themes.md @@ -109,8 +109,8 @@ There is no build step. The package *is* the folder. 1. Copy `templates/theme-plugin` and rename `id`, `name` and the theme files. 2. Edit the colours. Keep `kind` truthful (`light` for light backgrounds). 3. **Install:** *Settings › Plugins › ⚙ › Install Plugin from Disk…* (or the - *Install Plugin from Disk…* action) and pick the package folder. The - manifest and the files it references are copied to + *Install Plugin from Disk…* action) and pick the package's `plugin.toml`, or + a `.zip` of the folder. The manifest and the files it references are copied to `/plugins//`. 4. **Apply:** in *Settings › Plugins*, select the plugin and press *Apply* next to the theme. *Use Default Theme* switches back. diff --git a/src-tauri/src/commands/plugin.rs b/src-tauri/src/commands/plugin.rs index 0f94dc2ac..6c91ad079 100644 --- a/src-tauri/src/commands/plugin.rs +++ b/src-tauri/src/commands/plugin.rs @@ -91,12 +91,12 @@ pub async fn plugin_set_enabled(state: State<'_, AppState>, id: String, enabled: blocking(move || host.set_enabled(&id, enabled)).await } -/// Installs the plugin package in `path` (a folder with `plugin.toml`). +/// Installs the plugin package at `path`: a folder, its `plugin.toml`, or a `.zip` of the folder. #[tauri::command] #[specta::specta] pub async fn plugin_install_dir(state: State<'_, AppState>, path: PathBuf) -> IdeResult { let host = state.plugins.clone(); - blocking(move || host.install_from_dir(&path)).await + blocking(move || host.install_from_path(&path)).await } /// Plugin packages available locally for installation: `/plugins` diff --git a/src/bindings.ts b/src/bindings.ts index 4f1913248..6d71d1a38 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -853,7 +853,7 @@ export const commands = { */ pluginList: () => typedError(__TAURI_INVOKE("plugin_list")), pluginSetEnabled: (id: string, enabled: boolean) => typedError(__TAURI_INVOKE("plugin_set_enabled", { id, enabled })), - /** Installs the plugin package in `path` (a folder with `plugin.toml`). */ + /** Installs the plugin package at `path`: a folder, its `plugin.toml`, or a `.zip` of the folder. */ pluginInstallDir: (path: string) => typedError(__TAURI_INVOKE("plugin_install_dir", { path })), /** * Plugin packages available locally for installation: `/plugins` @@ -1688,7 +1688,7 @@ export type CatalogPlugin = { authors: string[], homepage: string | null, changeNotes: string | null, - /** Package directory; install with `install_from_dir(path)`. */ + /** Package directory; install with `install_from_path(path)`. */ path: IpcPath, hasCode: boolean, permissions: Permission[], diff --git a/src/features/plugins/backend.ts b/src/features/plugins/backend.ts index d3a4b131f..4030550d3 100644 --- a/src/features/plugins/backend.ts +++ b/src/features/plugins/backend.ts @@ -5,6 +5,7 @@ */ import { Channel } from "@tauri-apps/api/core"; import type { ActiveTheme, CatalogPlugin, PluginEvent, PluginInfo, PluginsSnapshot } from "@/bindings"; +import { tx } from "@/platform/i18n"; import { call, commands, IpcError, isTauri } from "@/platform/ipc"; export interface PluginsBackend { @@ -21,7 +22,8 @@ export interface PluginsBackend { /** The selected theme's resolved frame colour (`#rrggbb[aa]`), which new windows open in. */ setThemeFrame(color: string | null): Promise; /** Folder picker; `null` when cancelled. */ - pickFolder(): Promise; + /** A package to install: a `.zip`, or the `plugin.toml` of a folder. */ + pickPackage(): Promise; /** Subscribes to backend plugin events (this window only). */ onEvent(handler: (e: PluginEvent) => void): () => void; } @@ -45,9 +47,13 @@ export const tauriPluginsBackend: PluginsBackend = { setThemeFrame: async (color) => { await call(commands.pluginSetThemeFrame(color)); }, - pickFolder: async () => { + pickPackage: async () => { const { open } = await import("@tauri-apps/plugin-dialog"); - const picked = await open({ directory: true, multiple: false, title: "Install Plugin from Disk" }); + const picked = await open({ + multiple: false, + title: tx("Install Plugin from Disk"), + filters: [{ name: tx("Plugin package"), extensions: ["zip", "toml"] }], + }); return (Array.isArray(picked) ? picked[0] : picked) ?? null; }, onEvent: (handler) => { @@ -247,7 +253,7 @@ export function createMemoryPluginsBackend( return t; }, setThemeFrame: async () => {}, - pickFolder: async () => null, + pickPackage: async () => null, onEvent: (handler) => { listeners.add(handler); return () => void listeners.delete(handler); diff --git a/src/features/plugins/store.ts b/src/features/plugins/store.ts index 019133651..08ec18a63 100644 --- a/src/features/plugins/store.ts +++ b/src/features/plugins/store.ts @@ -124,25 +124,25 @@ export function compareVersions(a: string, b: string): number { return 0; } -/** Asks for a plugin folder (containing plugin.toml) and installs it. Returns the new plugin id. */ +/** Asks for a plugin package (a `.zip` or a `plugin.toml`) and installs it. Returns the new plugin id. */ export async function installFromDisk(path?: string): Promise { const backend = pluginsBackend(); - let dir: string | null = path ?? null; + let source: string | null = path ?? null; try { - dir ??= await backend.pickFolder(); + source ??= await backend.pickPackage(); } catch (e) { notifications().notify({ group: "plugins", level: "error", - title: "Could not open the folder picker", + title: "Could not open the file picker", body: errorText(e), }); return null; } - if (!dir) return null; + if (!source) return null; usePlugins.setState({ installing: true }); try { - const info = await backend.installDir(dir); + const info = await backend.installDir(source); notifications().notify({ group: "plugins", level: info.status === "failed" ? "warning" : "info", diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index ed9b91318..048353eba 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -523,7 +523,8 @@ "Could not install plugin": "无法安装插件", "Could not invalidate caches": "无法清除缓存", "Could not open project": "无法打开项目", - "Could not open the folder picker": "无法打开文件夹选择器", + "Could not open the file picker": "无法打开文件选择器", + "Plugin package": "插件包", "Could not reach GitHub": "无法连接到 GitHub", "Could not save": "无法保存", "Coverage": "覆盖率", From 7af2b098cc3910d349c22180b96769abe53decda Mon Sep 17 00:00:00 2001 From: kkmihai Date: Mon, 2 Nov 2026 20:09:17 +0200 Subject: [PATCH 039/106] feat(terminal): paste clipboard media as file paths (#880) Pasting an image, audio or video from the clipboard into a local terminal saves the bytes to the per-user terminal cache (20 MB limit, files older than 24 h cleaned up on the next paste, private permissions) and inserts the shell-quoted path without running anything. Copied files paste their original paths, plain text pastes unchanged, and SSH sessions keep the existing remote image-paste route. Paths are quoted per shell (zsh/bash, fish, PowerShell, cmd.exe); fish quoting now escapes backslashes and single quotes. Fixes #838 Co-authored-by: kkMihai <59262001+kkMihai@users.noreply.github.com> --- crates/ide-host/src/ops/term/images.rs | 367 +++++++++++++++++++++-- crates/ide-host/tests/terminal_images.rs | 15 +- e2e/terminal-clipboard.spec.ts | 47 +++ src/features/terminal/paste.test.ts | 173 ++++++++++- src/features/terminal/paste.ts | 114 +++++-- src/features/terminal/session.ts | 14 +- src/platform/clipboard.ts | 13 +- src/platform/locales/text/zh.json | 6 +- 8 files changed, 670 insertions(+), 79 deletions(-) create mode 100644 e2e/terminal-clipboard.spec.ts diff --git a/crates/ide-host/src/ops/term/images.rs b/crates/ide-host/src/ops/term/images.rs index 6a45926f5..e0c3261d5 100644 --- a/crates/ide-host/src/ops/term/images.rs +++ b/crates/ide-host/src/ops/term/images.rs @@ -1,6 +1,61 @@ use ide_core::{IdeError, IdeResult}; use std::path::Path; +const LIFETIME: std::time::Duration = std::time::Duration::from_secs(24 * 60 * 60); +const DIRECTORY: &str = "terminal-images"; + +fn extension(mime: &str) -> IdeResult<&str> { + match mime { + "image/png" => Ok("png"), + "image/jpeg" => Ok("jpg"), + "image/gif" => Ok("gif"), + "image/webp" => Ok("webp"), + "image/bmp" => Ok("bmp"), + "image/svg+xml" => Ok("svg"), + "image/tiff" => Ok("tiff"), + "image/x-icon" => Ok("ico"), + "audio/wav" | "audio/x-wav" => Ok("wav"), + "audio/mpeg" => Ok("mp3"), + "audio/mp4" => Ok("m4a"), + "audio/ogg" => Ok("ogg"), + "audio/webm" | "video/webm" => Ok("webm"), + "audio/flac" => Ok("flac"), + "audio/aac" => Ok("aac"), + "video/mp4" => Ok("mp4"), + "video/ogg" => Ok("ogv"), + "video/quicktime" => Ok("mov"), + _ => Err(IdeError::invalid("unsupported clipboard media type")), + } +} + +fn owned_name(name: &str) -> bool { + let name = name.strip_prefix(".partial-").unwrap_or(name); + let Some((id, ext)) = name.strip_prefix("image-").and_then(|s| s.split_once('.')) else { return false }; + id.len() == 32 + && id.bytes().all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase()) + && matches!( + ext, + "png" + | "jpg" + | "gif" + | "webp" + | "bmp" + | "svg" + | "tiff" + | "ico" + | "wav" + | "mp3" + | "m4a" + | "ogg" + | "webm" + | "flac" + | "aac" + | "mp4" + | "ogv" + | "mov" + ) +} + #[cfg(unix)] mod unix { use super::*; @@ -8,24 +63,7 @@ mod unix { use std::fs::File; use std::io::{self, Write}; use std::os::unix::fs::MetadataExt; - use std::time::{Duration, SystemTime}; - - const LIFETIME: Duration = Duration::from_secs(24 * 60 * 60); - const DIRECTORY: &str = "terminal-images"; - - fn extension(mime: &str) -> IdeResult<&str> { - match mime { - "image/png" => Ok("png"), - "image/jpeg" => Ok("jpg"), - "image/gif" => Ok("gif"), - "image/webp" => Ok("webp"), - "image/bmp" => Ok("bmp"), - "image/svg+xml" => Ok("svg"), - "image/tiff" => Ok("tiff"), - "image/x-icon" => Ok("ico"), - _ => Err(IdeError::invalid("unsupported image type")), - } - } + use std::time::SystemTime; fn open_directory(data_dir: &Path) -> io::Result { if !data_dir.is_absolute() { @@ -55,14 +93,6 @@ mod unix { Ok(dir) } - fn owned_name(name: &str) -> bool { - let name = name.strip_prefix(".partial-").unwrap_or(name); - let Some((id, ext)) = name.strip_prefix("image-").and_then(|s| s.split_once('.')) else { return false }; - id.len() == 32 - && id.bytes().all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase()) - && matches!(ext, "png" | "jpg" | "gif" | "webp" | "bmp" | "svg" | "tiff" | "ico") - } - fn cleanup(dir: &File, now: SystemTime) -> io::Result<()> { for entry in Dir::read_from(dir)? { let entry = entry?; @@ -140,6 +170,7 @@ mod unix { use super::*; use std::fs; use std::os::unix::fs::{PermissionsExt, symlink}; + use std::time::Duration; #[test] fn stores_bytes_with_private_modes_and_unpredictable_names() { @@ -211,10 +242,292 @@ mod unix { } } +#[cfg(windows)] +mod windows { + use super::*; + use std::fs::{self, File, OpenOptions}; + use std::io::{self, Write}; + use std::os::windows::fs::{MetadataExt, OpenOptionsExt}; + use std::time::SystemTime; + use windows_sys::Win32::Storage::FileSystem::{ + FILE_ATTRIBUTE_REPARSE_POINT, FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, FILE_SHARE_READ, + FILE_SHARE_WRITE, + }; + + fn open_directory(path: &Path) -> io::Result { + if !path.is_absolute() { + return Err(io::Error::new(io::ErrorKind::InvalidInput, "data directory must be absolute")); + } + let dir = OpenOptions::new() + .read(true) + .share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE) + .custom_flags(FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT) + .open(path)?; + let meta = dir.metadata()?; + if !meta.is_dir() || meta.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "terminal media directory must not be a reparse point", + )); + } + Ok(dir) + } + + fn cleanup(path: &Path, now: SystemTime) -> io::Result<()> { + for entry in fs::read_dir(path)? { + let entry = entry?; + if !entry.file_name().to_str().is_some_and(owned_name) { + continue; + } + let file = match OpenOptions::new().read(true).custom_flags(FILE_FLAG_OPEN_REPARSE_POINT).open(entry.path()) + { + Ok(file) => file, + Err(_) => continue, + }; + let meta = file.metadata()?; + if meta.is_file() + && meta.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT == 0 + && meta + .modified() + .ok() + .and_then(|time| now.duration_since(time).ok()) + .is_some_and(|age| age >= LIFETIME) + { + match fs::remove_file(entry.path()) { + Ok(()) => (), + Err(error) if error.kind() == io::ErrorKind::NotFound => (), + Err(error) => return Err(error), + } + } + } + Ok(()) + } + + fn store_with( + data_dir: &Path, + data: &[u8], + mime: &str, + now: SystemTime, + write: impl FnOnce(&mut File, &[u8]) -> io::Result<()>, + ) -> IdeResult { + let ext = extension(mime)?; + let _parent = open_directory(data_dir)?; + let path = data_dir.join(DIRECTORY); + match fs::create_dir(&path) { + Ok(()) => (), + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => (), + Err(error) => return Err(error.into()), + } + let _dir = open_directory(&path)?; + cleanup(&path, now)?; + let name = format!("image-{}.{}", uuid::Uuid::new_v4().simple(), ext); + let target = path.join(&name); + let temporary = path.join(format!(".partial-{name}")); + let mut file = OpenOptions::new().write(true).create_new(true).open(&temporary)?; + let result = write(&mut file, data); + drop(file); + if let Err(error) = result { + let _ = fs::remove_file(&temporary); + return Err(error.into()); + } + if let Err(error) = fs::hard_link(&temporary, &target) { + let _ = fs::remove_file(&temporary); + return Err(error.into()); + } + let _ = fs::remove_file(&temporary); + Ok(target.to_string_lossy().into_owned()) + } + + pub(in crate::ops::term) fn store(data_dir: &Path, data: &[u8], mime: &str) -> IdeResult { + store_with(data_dir, data, mime, SystemTime::now(), |file, bytes| file.write_all(bytes)) + } + + #[cfg(test)] + mod tests { + use super::*; + use std::time::Duration; + + fn junction(path: &Path, target: &Path) { + let output = std::process::Command::new("powershell.exe") + .args([ + "-NoProfile", + "-NonInteractive", + "-Command", + "New-Item -ItemType Junction -Path $env:OPENIDEA_TEST_JUNCTION -Target $env:OPENIDEA_TEST_TARGET -ErrorAction Stop | Out-Null", + ]) + .env("OPENIDEA_TEST_JUNCTION", path) + .env("OPENIDEA_TEST_TARGET", target) + .output() + .unwrap(); + assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr)); + } + + #[test] + fn rejects_reparse_data_and_storage_directories() { + let root = tempfile::tempdir().unwrap(); + let outside = tempfile::tempdir().unwrap(); + let link = root.path().join("data"); + junction(&link, outside.path()); + assert!(store(&link, b"x", "image/png").is_err()); + assert_eq!(fs::read_dir(outside.path()).unwrap().count(), 0); + fs::remove_dir(&link).unwrap(); + let link = root.path().join(DIRECTORY); + junction(&link, outside.path()); + assert!(store(root.path(), b"x", "image/png").is_err()); + assert_eq!(fs::read_dir(outside.path()).unwrap().count(), 0); + fs::remove_dir(&link).unwrap(); + } + + #[test] + fn expiry_only_removes_owned_regular_media_at_boundary() { + let root = tempfile::tempdir().unwrap(); + let outside = tempfile::tempdir().unwrap(); + let path = root.path().join(DIRECTORY); + fs::create_dir(&path).unwrap(); + let _dir = open_directory(&path).unwrap(); + let now = SystemTime::now(); + let names = [ + "image-00000000000000000000000000000001.png", + "image-00000000000000000000000000000002.mp3", + ".partial-image-00000000000000000000000000000003.mp4", + "image-00000000000000000000000000000004.webm", + "image-00000000000000000000000000000005.wav", + "unrelated.png", + "image-00000000000000000000000000000006.exe", + ]; + for (name, time) in names.iter().zip([ + now - LIFETIME, + now - LIFETIME, + now - LIFETIME, + now - LIFETIME + Duration::from_secs(1), + now + Duration::from_secs(1), + now - LIFETIME, + now - LIFETIME, + ]) { + let file = File::create(path.join(name)).unwrap(); + file.set_times(fs::FileTimes::new().set_modified(time)).unwrap(); + } + let link = path.join("image-00000000000000000000000000000007.png"); + junction(&link, outside.path()); + let directory = path.join("image-00000000000000000000000000000008.png"); + fs::create_dir(&directory).unwrap(); + cleanup(&path, now).unwrap(); + for name in &names[..3] { + assert!(!path.join(name).exists()); + } + for name in &names[3..] { + assert!(path.join(name).exists()); + } + assert!(directory.is_dir()); + assert!(fs::symlink_metadata(&link).unwrap().file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0); + fs::remove_dir(link).unwrap(); + } + + #[test] + fn failed_write_removes_partial_file() { + let root = tempfile::tempdir().unwrap(); + let result = store_with(root.path(), b"partial", "audio/ogg", SystemTime::now(), |file, bytes| { + file.write_all(bytes)?; + Err(io::Error::other("simulated failure")) + }); + assert!(result.is_err()); + assert_eq!(fs::read_dir(root.path().join(DIRECTORY)).unwrap().count(), 0); + } + + #[test] + fn publication_preserves_existing_target() { + let root = tempfile::tempdir().unwrap(); + let path = root.path().join(DIRECTORY); + let result = store_with(root.path(), b"new", "video/mp4", SystemTime::now(), |file, bytes| { + file.write_all(bytes)?; + let temporary = fs::read_dir(&path)?.next().unwrap()?.file_name(); + let target = temporary.to_str().unwrap().strip_prefix(".partial-").unwrap(); + fs::write(path.join(target), b"existing") + }); + assert!(result.is_err()); + let files: Vec<_> = fs::read_dir(&path).unwrap().map(|entry| entry.unwrap().path()).collect(); + assert_eq!(files.len(), 1); + assert_eq!(fs::read(&files[0]).unwrap(), b"existing"); + assert!(!files[0].file_name().unwrap().to_str().unwrap().starts_with(".partial-")); + } + + #[test] + fn storage_handles_prevent_directory_replacement() { + let root = tempfile::tempdir().unwrap(); + let path = root.path().join(DIRECTORY); + let result = store_with(root.path(), b"x", "image/png", SystemTime::now(), |file, bytes| { + assert!(fs::rename(&path, root.path().join("replaced")).is_err()); + file.write_all(bytes) + }); + assert_eq!(fs::read(result.unwrap()).unwrap(), b"x"); + } + } +} + #[cfg(unix)] pub(super) use unix::store; -#[cfg(not(unix))] +#[cfg(windows)] +pub(super) use windows::store; + +#[cfg(not(any(unix, windows)))] pub(super) fn store(_data_dir: &Path, _data: &[u8], _mime: &str) -> IdeResult { Err(IdeError::internal("private terminal image storage is unsupported on this platform")) } + +#[cfg(all(test, any(unix, windows)))] +mod tests { + use super::*; + + #[test] + fn stores_supported_media_with_expected_extensions_and_unique_names() { + let root = tempfile::tempdir().unwrap(); + let types = [ + ("image/png", "png"), + ("image/jpeg", "jpg"), + ("image/gif", "gif"), + ("image/webp", "webp"), + ("image/bmp", "bmp"), + ("image/svg+xml", "svg"), + ("image/tiff", "tiff"), + ("image/x-icon", "ico"), + ("audio/wav", "wav"), + ("audio/x-wav", "wav"), + ("audio/mpeg", "mp3"), + ("audio/mp4", "m4a"), + ("audio/ogg", "ogg"), + ("audio/webm", "webm"), + ("audio/flac", "flac"), + ("audio/aac", "aac"), + ("video/mp4", "mp4"), + ("video/webm", "webm"), + ("video/ogg", "ogv"), + ("video/quicktime", "mov"), + ]; + let mut paths = std::collections::HashSet::new(); + for (mime, ext) in types { + let path = store(root.path(), mime.as_bytes(), mime).unwrap(); + assert_eq!(std::fs::read(&path).unwrap(), mime.as_bytes()); + assert_eq!(Path::new(&path).extension().unwrap(), ext); + assert!(owned_name(Path::new(&path).file_name().unwrap().to_str().unwrap())); + assert!(paths.insert(path)); + } + assert_eq!(std::fs::read_dir(root.path().join(DIRECTORY)).unwrap().count(), types.len()); + } + + #[test] + fn rejects_unsupported_mime_before_creating_storage() { + let root = tempfile::tempdir().unwrap(); + for mime in ["text/plain", "application/octet-stream", "image/unknown", "audio/unknown", "video/unknown", ""] { + let error = store(root.path(), b"x", mime).unwrap_err(); + assert_eq!(error.kind, ide_core::error::ErrorKind::InvalidInput); + assert_eq!(error.message, "unsupported clipboard media type"); + } + assert!(!root.path().join(DIRECTORY).exists()); + } + + #[test] + fn rejects_relative_data_directory() { + assert!(store(Path::new("relative-data"), b"x", "image/png").is_err()); + } +} diff --git a/crates/ide-host/tests/terminal_images.rs b/crates/ide-host/tests/terminal_images.rs index 8bb4a3e11..014d1430c 100644 --- a/crates/ide-host/tests/terminal_images.rs +++ b/crates/ide-host/tests/terminal_images.rs @@ -1,4 +1,4 @@ -#![cfg(unix)] +#![cfg(any(unix, windows))] use ide_core::{NullSink, TerminalId}; use ide_host::{Host, HostConfig, ops}; @@ -28,8 +28,8 @@ async fn storage_requires_a_registered_terminal() { .spawn( TerminalOptions { cwd: root.path().into(), - shell: Some("/bin/sh".into()), - args: vec![], + shell: Some(if cfg!(windows) { "powershell.exe" } else { "/bin/sh" }.into()), + args: if cfg!(windows) { vec!["-NoLogo".into(), "-NoProfile".into()] } else { vec![] }, env: Default::default(), cols: 80, rows: 24, @@ -40,9 +40,12 @@ async fn storage_requires_a_registered_terminal() { Box::new(|_| {}), ) .unwrap(); - let path = ops::term::store_image(&host, terminal.id, vec![1, 2, 3], "image/png".into()).await.unwrap(); - assert_eq!(std::fs::read(path).unwrap(), [1, 2, 3]); + for mime in ["image/png", "audio/mpeg", "video/mp4"] { + let path = ops::term::store_image(&host, terminal.id, vec![1, 2, 3], mime.into()).await.unwrap(); + assert_eq!(std::fs::read(path).unwrap(), [1, 2, 3]); + } + assert!(ops::term::store_image(&host, terminal.id, vec![1], "application/octet-stream".into()).await.is_err()); host.terminals.kill(terminal.id).unwrap(); assert!(ops::term::store_image(&host, terminal.id, vec![4], "image/png".into()).await.is_err()); - assert_eq!(std::fs::read_dir(data_dir.join("terminal-images")).unwrap().count(), 1); + assert_eq!(std::fs::read_dir(data_dir.join("terminal-images")).unwrap().count(), 3); } diff --git a/e2e/terminal-clipboard.spec.ts b/e2e/terminal-clipboard.spec.ts new file mode 100644 index 000000000..1127762f5 --- /dev/null +++ b/e2e/terminal-clipboard.spec.ts @@ -0,0 +1,47 @@ +import { expect, test } from "@playwright/test"; + +test("local terminal stores clipboard media and pastes one shell-quoted argument", async ({ page }) => { + await page.goto("/"); + const observed = await page.evaluate(async () => { + const ipcPath = "/src/platform/ipc.ts"; + const sessionPath = "/src/features/terminal/session.ts"; + const { commands } = await import(ipcPath); + const { TerminalSession } = await import(sessionPath); + Object.defineProperty(window, "__TAURI_INTERNALS__", { + configurable: true, + value: { transformCallback: () => 1 }, + }); + const stored: { id: number; data: number[]; mime: string }[] = []; + const writes: string[] = []; + commands.termSpawn = async () => ({ status: "ok", data: { id: 838, title: "pwsh", pid: 1 } }); + commands.clipboardFileList = async () => ({ status: "ok", data: [] }); + commands.termStoreImage = async (id: number, data: number[], mime: string) => { + stored.push({ id, data, mime }); + return { status: "ok", data: "C:\\Temp\\a'b c\\image.png" }; + }; + commands.termWrite = async (_id: number, data: string) => writes.push(data); + commands.termResize = async () => undefined; + commands.termKill = async () => undefined; + const container = document.createElement("div"); + container.style.cssText = "position:fixed;inset:0;z-index:99999"; + document.body.append(container); + const session = new TerminalSession({ key: "clipboard", cwd: "C:/Temp", onChange() {}, onFocus() {} }); + try { + session.attach(container); + await session.start(); + const transfer = new DataTransfer(); + transfer.items.add(new File([new Uint8Array([137, 80, 78, 71])], "image.png", { type: "image/png" })); + const event = new ClipboardEvent("paste", { bubbles: true, cancelable: true, clipboardData: transfer }); + container.querySelector("textarea")!.dispatchEvent(event); + const deadline = Date.now() + 5000; + while (!writes.length && Date.now() < deadline) await new Promise((resolve) => requestAnimationFrame(resolve)); + return { stored, writes, claimed: event.defaultPrevented }; + } finally { + session.dispose(); + container.remove(); + } + }); + expect(observed.claimed).toBe(true); + expect(observed.stored).toEqual([{ id: 838, data: [137, 80, 78, 71], mime: "image/png" }]); + expect(observed.writes).toEqual(["'C:\\Temp\\a''b c\\image.png'"]); +}); diff --git a/src/features/terminal/paste.test.ts b/src/features/terminal/paste.test.ts index 726f1c8bc..bb01772bf 100644 --- a/src/features/terminal/paste.test.ts +++ b/src/features/terminal/paste.test.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, expect, it, vi } from "vitest"; import { Terminal } from "@xterm/xterm"; import { commands } from "@/platform/ipc"; -import { installTerminalPaste, pasteTerminalClipboard, type RemotePasteTarget } from "./paste"; +import { installTerminalPaste, pasteTerminalClipboard, type TerminalPasteTarget } from "./paste"; const notify = vi.hoisted(() => vi.fn()); vi.mock("@/workbench/contributions", () => ({ notifications: () => ({ notify }) })); @@ -98,8 +98,8 @@ function imageEvent(blob: Blob, text = "") { return event; } -function remoteTarget(current = () => true): RemotePasteTarget { - return { id: 42 as RemotePasteTarget["id"], supported: true, current }; +function remoteTarget(current = () => true): TerminalPasteTarget { + return { id: 42 as TerminalPasteTarget["id"], supported: true, remote: true, shell: "sh", current }; } it.each(["event", "menu"])("rejects oversized %s images before reading or uploading", async (route) => { @@ -123,8 +123,8 @@ it.each(["event", "menu"])("rejects oversized %s images before reading or upload await vi.waitFor(() => expect(notify).toHaveBeenCalledWith({ level: "error", - title: "Cannot paste image", - body: "Image is too large to paste (limit 20 MB).", + title: "Cannot paste clipboard media", + body: "Clipboard media is too large to paste (limit 20 MB).", }), ); expect(read).not.toHaveBeenCalled(); @@ -362,3 +362,166 @@ it("leaves local image and remote text events to existing paste handlers", () => expect(store).not.toHaveBeenCalled(); local(); }); + +function localTarget(shell = "sh", current = () => true): TerminalPasteTarget { + return { ...remoteTarget(current), remote: false, shell }; +} + +it.each(["event", "menu"])("stores raw local clipboard media through %s paste", async (route) => { + const store = vi.spyOn(commands, "termStoreImage").mockResolvedValue({ status: "ok", data: "/tmp/a'b c.png" }); + const host = document.createElement("div"); + const paste = vi.fn(); + const remove = installTerminalPaste(host, paste, () => localTarget()); + if (route === "event") { + host.addEventListener("paste", () => paste("xterm")); + const event = imageEvent(image()); + host.dispatchEvent(event); + expect(event.defaultPrevented).toBe(true); + } else { + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { readText: async () => "", read: async () => [{ types: ["image/png"], getType: async () => image() }] }, + }); + await pasteTerminalClipboard(paste, () => localTarget()); + } + await vi.waitFor(() => expect(paste).toHaveBeenCalledExactlyOnceWith("'/tmp/a'\\''b c.png'")); + expect(store).toHaveBeenCalledWith(42, [1, 2], "image/png"); + expect(notify).not.toHaveBeenCalled(); + remove(); +}); + +it.each(["event", "menu"])( + "keeps existing copied files on their original quoted paths during %s paste", + async (route) => { + const paths = ["/a b/photo.png", "/it's here/clip.mp4"]; + vi.spyOn(commands, "clipboardFileList").mockResolvedValue({ status: "ok", data: paths }); + const store = vi.spyOn(commands, "termStoreImage"); + const host = document.createElement("div"); + const paste = vi.fn(); + const remove = installTerminalPaste(host, paste, () => localTarget()); + if (route === "event") host.dispatchEvent(imageEvent(image(), paths.join("\n"))); + else { + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { + readText: async () => paths.join("\r\n"), + read: async () => [{ types: ["image/png"], getType: async () => image() }], + }, + }); + await pasteTerminalClipboard(paste, () => localTarget()); + } + await vi.waitFor(() => expect(paste).toHaveBeenCalledExactlyOnceWith("'/a b/photo.png' '/it'\\''s here/clip.mp4'")); + expect(store).not.toHaveBeenCalled(); + remove(); + }, +); + +it("reads file URI lists without relying on a native file-list backend", async () => { + const store = vi.spyOn(commands, "termStoreImage"); + const host = document.createElement("div"); + const paste = vi.fn(); + const remove = installTerminalPaste(host, paste, () => localTarget()); + const event = new Event("paste", { bubbles: true, cancelable: true }); + Object.defineProperty(event, "clipboardData", { + value: { + files: [], + types: ["text/uri-list"], + getData: (type: string) => + type === "text/uri-list" + ? "# files\r\nfile:///tmp/a%20b.png\r\nhttps://example.com\r\nfile:///tmp/%E4%B8%AD%E6%96%87.mp4" + : "", + }, + }); + host.dispatchEvent(event); + await vi.waitFor(() => expect(paste).toHaveBeenCalledExactlyOnceWith("'/tmp/a b.png' '/tmp/中文.mp4'")); + expect(store).not.toHaveBeenCalled(); + remove(); +}); + +it.each([ + { shell: "pwsh", path: "C:\\Users\\a'b $c\\image.png", expected: "'C:\\Users\\a''b $c\\image.png'" }, + { shell: "powershell", path: "C:\\a b\\image.png", expected: "'C:\\a b\\image.png'" }, + { shell: "zsh", path: "/tmp/a'b $中文.png", expected: "'/tmp/a'\\''b $中文.png'" }, + { shell: "bash", path: "/tmp/a'b $中文.png", expected: "'/tmp/a'\\''b $中文.png'" }, + { shell: "fish", path: "/tmp/a'b $中文.png", expected: "'/tmp/a\\'b $中文.png'" }, + { shell: "fish", path: "/tmp/a\\\\b\\'中文.png", expected: "'/tmp/a\\\\\\\\b\\\\\\'中文.png'" }, + { shell: "cmd", path: "C:\\a&b\\image.png", expected: '"C:\\a&b\\image.png"' }, + { shell: "cmd.exe", path: "C:\\a b $中文\\image.png", expected: '"C:\\a b $中文\\image.png"' }, +])("quotes a stored path for $shell", async ({ shell, path, expected }) => { + vi.spyOn(commands, "termStoreImage").mockResolvedValue({ status: "ok", data: path }); + const host = document.createElement("div"); + const paste = vi.fn(); + const remove = installTerminalPaste(host, paste, () => localTarget(shell)); + host.dispatchEvent(imageEvent(image())); + await vi.waitFor(() => expect(paste).toHaveBeenCalledExactlyOnceWith(expected)); + remove(); +}); + +it.each(["audio/wav", "video/mp4"])("stores raw %s clipboard data", async (type) => { + const store = vi.spyOn(commands, "termStoreImage").mockResolvedValue({ status: "ok", data: "/tmp/media" }); + const host = document.createElement("div"); + const paste = vi.fn(); + const remove = installTerminalPaste(host, paste, () => localTarget()); + const event = imageEvent({ ...image(), type } as Blob); + host.dispatchEvent(event); + await vi.waitFor(() => expect(paste).toHaveBeenCalledExactlyOnceWith("'/tmp/media'")); + expect(store).toHaveBeenCalledWith(42, [1, 2], type); + remove(); +}); + +it("ignores local media when its terminal is replaced during file recovery", async () => { + let current = true; + let finish!: (value: { status: "ok"; data: string[] }) => void; + vi.spyOn(commands, "clipboardFileList").mockImplementation(() => new Promise((resolve) => (finish = resolve))); + const store = vi.spyOn(commands, "termStoreImage"); + const host = document.createElement("div"); + const paste = vi.fn(); + const remove = installTerminalPaste(host, paste, () => localTarget("sh", () => current)); + host.dispatchEvent(imageEvent(image())); + await vi.waitFor(() => expect(finish).toBeDefined()); + current = false; + finish({ status: "ok", data: [] }); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(store).not.toHaveBeenCalled(); + expect(paste).not.toHaveBeenCalled(); + remove(); +}); + +it.each(["event", "menu"])("rejects cmd expansion in copied file paths through %s paste", async (route) => { + vi.spyOn(commands, "clipboardFileList").mockResolvedValue({ status: "ok", data: ["C:\\clips\\%OS%!clip!.png"] }); + const store = vi.spyOn(commands, "termStoreImage"); + const host = document.createElement("div"); + const paste = vi.fn(); + const remove = installTerminalPaste(host, paste, () => localTarget("cmd")); + if (route === "event") host.dispatchEvent(imageEvent(image())); + else { + Object.defineProperty(navigator, "clipboard", { configurable: true, value: { readText: async () => "" } }); + await pasteTerminalClipboard(paste, () => localTarget("cmd")); + } + await vi.waitFor(() => + expect(notify).toHaveBeenCalledWith({ + level: "error", + title: "Cannot paste clipboard media", + body: "Cannot paste this path safely in cmd.exe. Use PowerShell or a POSIX shell.", + }), + ); + expect(paste).not.toHaveBeenCalled(); + expect(store).not.toHaveBeenCalled(); + remove(); +}); + +it("recovers quoted local file paths when clipboard text permission is denied", async () => { + vi.spyOn(commands, "clipboardFileList").mockResolvedValue({ status: "ok", data: ["C:\\a b\\image.png"] }); + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { + readText: async () => { + throw new Error("denied"); + }, + }, + }); + const paste = vi.fn(); + await pasteTerminalClipboard(paste, () => localTarget("pwsh")); + expect(paste).toHaveBeenCalledExactlyOnceWith("'C:\\a b\\image.png'"); + expect(notify).not.toHaveBeenCalled(); +}); diff --git a/src/features/terminal/paste.ts b/src/features/terminal/paste.ts index c0dd74e24..5029cd488 100644 --- a/src/features/terminal/paste.ts +++ b/src/features/terminal/paste.ts @@ -3,31 +3,75 @@ import { call, commands, type TerminalId } from "@/platform/ipc"; import { tx } from "@/platform/i18n"; import { notifications } from "@/workbench/contributions"; -export interface RemotePasteTarget { +export interface TerminalPasteTarget { id: TerminalId; supported: boolean; + remote: boolean; + shell: string; current: () => boolean; } -type Target = () => RemotePasteTarget | null; +type Target = () => TerminalPasteTarget | null; -const MAX_IMAGE_BYTES = 20 * 1024 * 1024; +const MAX_MEDIA_BYTES = 20 * 1024 * 1024; -async function pasteImage(image: Blob, target: RemotePasteTarget, paste: (text: string) => void) { +function pathsText(paths: readonly string[], target: TerminalPasteTarget): string { + const shell = target.shell.toLowerCase().replace(/\.exe$/, ""); + return paths + .map((path) => { + if (shell === "cmd") { + if (/[%!"\r\n]/.test(path)) + throw new Error(tx("Cannot paste this path safely in cmd.exe. Use PowerShell or a POSIX shell.")); + return `"${path}"`; + } + if (shell === "powershell" || shell === "pwsh") return `'${path.replaceAll("'", "''")}'`; + if (shell === "fish") return `'${path.replaceAll("\\", "\\\\").replaceAll("'", "\\'")}'`; + return `'${path.replaceAll("'", "'\\''")}'`; + }) + .join(" "); +} + +function isMedia(type: string): boolean { + return /^(image|audio|video)\//.test(type); +} + +function pasteError(error: unknown, target: TerminalPasteTarget | null | undefined) { + if (target?.current()) + notifications().notify({ + level: "error", + title: tx("Cannot paste clipboard media"), + body: error instanceof Error ? error.message : String(error), + }); +} + +function filePaths(transfer: DataTransfer): string[] { + const paths: string[] = []; + for (const line of transfer.getData("text/uri-list").split(/\r?\n/)) { + if (!line || line.startsWith("#")) continue; + try { + const url = new URL(line); + if (url.protocol !== "file:") continue; + let path = decodeURIComponent(url.pathname); + if (url.hostname && url.hostname !== "localhost") path = `//${url.hostname}${path}`; + else if (/^\/[A-Za-z]:\//.test(path)) path = path.slice(1); + paths.push(path); + } catch { + continue; + } + } + return paths; +} + +async function pasteMedia(media: Blob, target: TerminalPasteTarget, paste: (text: string) => void) { try { - if (image.size > MAX_IMAGE_BYTES) throw new Error(tx`Image is too large to paste (limit ${20} MB).`); - const data = Array.from(new Uint8Array(await image.arrayBuffer())); + if (media.size > MAX_MEDIA_BYTES) throw new Error(tx`Clipboard media is too large to paste (limit ${20} MB).`); + const data = Array.from(new Uint8Array(await media.arrayBuffer())); if (!target.current()) return; if (!target.supported) throw new Error(tx("Remote server does not support image paste.")); - const path = await call(commands.termStoreImage(target.id, data, image.type)); - if (target.current()) paste(`'${path.replaceAll("'", "'\\''")}'`); + const path = await call(commands.termStoreImage(target.id, data, media.type)); + if (target.current()) paste(pathsText([path], target)); } catch (error) { - if (target.current()) - notifications().notify({ - level: "error", - title: tx("Cannot paste image"), - body: error instanceof Error ? error.message : String(error), - }); + pasteError(error, target); } } @@ -36,12 +80,29 @@ export function installTerminalPaste(host: HTMLElement, paste: (text: string) => const onPaste = (event: ClipboardEvent) => { const transfer = event.clipboardData; const target = getTarget?.(); - if (!event.defaultPrevented && target && transfer && !transfer.getData("text/plain")) { - const image = Array.from(transfer.files).find((file) => file.type.startsWith("image/")); - if (image) { + if (!event.defaultPrevented && target && transfer) { + const media = Array.from(transfer.files).find((file) => isMedia(file.type)); + const files = + transfer.files.length || + Array.from(transfer.types).some((type) => type === "Files" || type === "text/uri-list"); + if ((!target.remote && files) || (media && !transfer.getData("text/plain"))) { event.preventDefault(); event.stopImmediatePropagation(); - void pasteImage(image, { ...target, current: () => alive && target.current() }, paste); + const captured = { ...target, current: () => alive && target.current() }; + void (async () => { + if (!captured.remote) { + const paths = filePaths(transfer); + const text = paths.length + ? pathsText(paths, captured) + : await readClipboardText(transfer, (paths) => pathsText(paths, captured)); + if (!captured.current()) return; + if (text) { + paste(text); + return; + } + } + if (media) await pasteMedia(media, captured, paste); + })().catch((error) => pasteError(error, captured)); return; } } @@ -68,7 +129,9 @@ export async function pasteTerminalClipboard(paste: (text: string) => void, getT (error: unknown) => ({ error }), ) : null; - const text = target ? ((await navigator.clipboard?.readText().catch(() => "")) ?? "") : await readClipboardText(); + const text = target?.remote + ? ((await navigator.clipboard?.readText().catch(() => "")) ?? "") + : await readClipboardText(undefined, target ? (paths) => pathsText(paths, target) : undefined); if (target && !target.current()) return; if (text) { paste(text); @@ -80,24 +143,19 @@ export async function pasteTerminalClipboard(paste: (text: string) => void, getT if ("error" in result) throw result.error; if (!target.current()) return; for (const item of result.items) { - const mime = item.types.find((type) => type.startsWith("image/")); + const mime = item.types.find(isMedia); if (mime) { const image = await item.getType(mime); - if (target.current()) await pasteImage(image, target, paste); + if (target.current()) await pasteMedia(image, target, paste); return; } } } - const paths = await readClipboardText(); + const paths = await readClipboardText(undefined, target.remote ? undefined : (paths) => pathsText(paths, target)); if (!target.current()) return; if (paths) paste(paths); else if (!images) throw new Error(tx("Clipboard image reading is unavailable.")); } catch (error) { - if (target?.current()) - notifications().notify({ - level: "error", - title: tx("Cannot paste image"), - body: error instanceof Error ? error.message : String(error), - }); + pasteError(error, target); } } diff --git a/src/features/terminal/session.ts b/src/features/terminal/session.ts index da94bad38..dce99c6cb 100644 --- a/src/features/terminal/session.ts +++ b/src/features/terminal/session.ts @@ -286,7 +286,7 @@ export class TerminalSession { }), ); this.disposables.push( - { dispose: installTerminalPaste(this.host, (text) => this.paste(text), this.remotePasteTarget) }, + { dispose: installTerminalPaste(this.host, (text) => this.paste(text), this.clipboardTarget) }, { dispose: installTerminalDrop(this.host, (text) => this.paste(text)) }, ); this.host.addEventListener("mousedown", this.onMiddleClick); @@ -298,14 +298,16 @@ export class TerminalSession { void this.pasteClipboard(); }; - private remotePasteTarget = () => { + private clipboardTarget = () => { const remote = useRemote.getState().session; const id = this.ptyId; - if (!remote || id === null || this.disposed) return null; + if (id === null || this.disposed) return null; const generation = this.generation; return { id, - supported: remote.commands.includes("term_store_image"), + supported: !remote || remote.commands.includes("term_store_image"), + remote: !!remote, + shell: this.snapshot.title, current: () => !this.disposed && this.ptyId === id && @@ -315,8 +317,8 @@ export class TerminalSession { }; pasteClipboard() { - if (this.remotePasteTarget()) this.focus(); - return pasteTerminalClipboard((text) => this.paste(text), this.remotePasteTarget); + if (this.clipboardTarget()) this.focus(); + return pasteTerminalClipboard((text) => this.paste(text), this.clipboardTarget); } /** Applies Settings › Tools › Terminal changes to a live session. */ applySettings(t: TerminalSettingsValid) { diff --git a/src/platform/clipboard.ts b/src/platform/clipboard.ts index bab48c0a8..f3b25716f 100644 --- a/src/platform/clipboard.ts +++ b/src/platform/clipboard.ts @@ -9,16 +9,19 @@ export async function readClipboardText( transfer?: DataTransfer | null, formatFiles: FormatFiles = rawPaths, ): Promise { - const text = transfer ? transfer.getData("text/plain") : ((await navigator.clipboard?.readText()) ?? ""); + const text = transfer + ? transfer.getData("text/plain") + : ((await navigator.clipboard?.readText().catch(() => "")) ?? ""); if ((text && formatFiles === rawPaths) || (transfer && !hasClipboardFiles(transfer)) || !isTauri()) return text; + let paths: string[]; try { - const paths = await call(commands.clipboardFileList()); - // Windows also supplies the file list as text; unrelated text still wins. - if (text && text.replace(/\r\n/g, "\n").split("\n").map(ipcPath).join("\n") !== paths.join("\n")) return text; - return formatFiles(paths); + paths = await call(commands.clipboardFileList()); } catch { return text; } + // Windows also supplies the file list as text; unrelated text still wins. + if (text && text.replace(/\r\n/g, "\n").split("\n").map(ipcPath).join("\n") !== paths.join("\n")) return text; + return formatFiles(paths); } function hasClipboardFiles(transfer: DataTransfer): boolean { diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 048353eba..be3187ac6 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -5499,10 +5499,12 @@ ".NET launch profile '{profile_name}' is missing or is not a Project profile": ".NET 启动配置“{profile_name}”不存在或不是 Project 类型", ".NET output resolution timed out": ".NET 构建产物路径查询超时", ".NET build output is missing; run dotnet build before debugging": ".NET 构建产物不存在,请在调试前运行 dotnet build", - "Cannot paste image": "无法粘贴图片", + "Cannot paste clipboard media": "无法粘贴剪贴板媒体", + "Cannot paste this path safely in cmd.exe. Use PowerShell or a POSIX shell.": "无法在 cmd.exe 中安全粘贴此路径。请使用 PowerShell 或 POSIX Shell。", "Remote server does not support image paste.": "远程服务器不支持粘贴图片。", "Clipboard image reading is unavailable.": "无法读取剪贴板图片。", - "Image is too large to paste (limit {limit} MB).": "图片过大,无法粘贴(上限 {limit} MB)。", + "Clipboard media is too large to paste (limit {limit} MB).": "剪贴板媒体过大,无法粘贴(上限 {limit} MB)。", + "unsupported clipboard media type": "不支持的剪贴板媒体类型", "Show inline blame for the current line": "为当前行显示内联 Git Blame", "You, now · Uncommitted changes": "你,刚刚 · 未提交的更改", "now": "刚刚", From bbb1be69a2d07c6d09c21609d28be03d04b1806f Mon Sep 17 00:00:00 2001 From: arookieofc <2128194521@qq.com> Date: Tue, 3 Nov 2026 07:05:16 +0800 Subject: [PATCH 040/106] fix(linux): make the frameless settings window edge readable #595 follow-up: the 1px --border-window hairline is nearly invisible on the dark theme (#40434a against a dark desktop), which fuqiuluo confirmed on Fedora 44 GNOME Wayland after the first edge commit. Blend the foreground tone into the ring so the boundary reads on every theme; still CSS-only, since Wayland gives undecorated windows no compositor shadow to paint outside the surface. --- src/features/settings/dialog/SettingsDialog.module.css | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/features/settings/dialog/SettingsDialog.module.css b/src/features/settings/dialog/SettingsDialog.module.css index 0a1105abb..a9ab42928 100644 --- a/src/features/settings/dialog/SettingsDialog.module.css +++ b/src/features/settings/dialog/SettingsDialog.module.css @@ -204,9 +204,10 @@ color: var(--fg); } /* Linux draws no shadow for a frameless window (Wayland/KDE give undecorated - windows no CSD), so mark its edge in CSS instead. */ + windows no CSD), so mark its edge in CSS instead. --border-window alone is + near-invisible on the dark theme, so blend the foreground tone into it. */ .windowEdge { - box-shadow: inset 0 0 0 1px var(--border-window); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--fg) 32%, var(--border-window)); } .windowBody { flex: 1; From fd8161a5a70f2abd322184f2cc18c126b07dd43c Mon Sep 17 00:00:00 2001 From: fuqiuluo Date: Tue, 3 Nov 2026 07:15:24 +0800 Subject: [PATCH 041/106] Revert "fix(linux): make the frameless settings window edge readable" This reverts commit bbb1be69a2d07c6d09c21609d28be03d04b1806f. --- src/features/settings/dialog/SettingsDialog.module.css | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/features/settings/dialog/SettingsDialog.module.css b/src/features/settings/dialog/SettingsDialog.module.css index a9ab42928..0a1105abb 100644 --- a/src/features/settings/dialog/SettingsDialog.module.css +++ b/src/features/settings/dialog/SettingsDialog.module.css @@ -204,10 +204,9 @@ color: var(--fg); } /* Linux draws no shadow for a frameless window (Wayland/KDE give undecorated - windows no CSD), so mark its edge in CSS instead. --border-window alone is - near-invisible on the dark theme, so blend the foreground tone into it. */ + windows no CSD), so mark its edge in CSS instead. */ .windowEdge { - box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--fg) 32%, var(--border-window)); + box-shadow: inset 0 0 0 1px var(--border-window); } .windowBody { flex: 1; From d830d21d466801d3affc10fb423cf4c03d150620 Mon Sep 17 00:00:00 2001 From: fuqiuluo Date: Tue, 3 Nov 2026 07:20:03 +0800 Subject: [PATCH 042/106] fix(linux): keep the settings edge above content --- src/features/settings/dialog/SettingsDialog.module.css | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/features/settings/dialog/SettingsDialog.module.css b/src/features/settings/dialog/SettingsDialog.module.css index 0a1105abb..7661a3a67 100644 --- a/src/features/settings/dialog/SettingsDialog.module.css +++ b/src/features/settings/dialog/SettingsDialog.module.css @@ -206,6 +206,14 @@ /* Linux draws no shadow for a frameless window (Wayland/KDE give undecorated windows no CSD), so mark its edge in CSS instead. */ .windowEdge { + position: relative; +} +.windowEdge::after { + content: ""; + position: absolute; + inset: 0; + z-index: 1; + pointer-events: none; box-shadow: inset 0 0 0 1px var(--border-window); } .windowBody { From d9929bbe44ce86b354cfb4c355b50dbcdb8ff11d Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Tue, 3 Nov 2026 07:52:55 +0800 Subject: [PATCH 043/106] fix(vcs): resolve shelf files against their owning repository (#919) Root cause: shelved file paths are relative to the repository they were shelved from, but Compare with Local, Show Diff, navigation and Unshelve joined them to the selected VCS root. In a project folder that is not itself a repository (or holds several), the local file was not found and Compare with Local stayed disabled. Shelves now record their repository (older shelves fall back to the primary repository), imported patches record the current one, and every shelf action resolves paths against it, with Windows separators handled. Stash and Apply Patch already kept their repository. Fixes #875 Co-Authored-By: Claude Opus 5.5 --- crates/ide-host/src/ops/vcs/commands.rs | 62 ++++++--- crates/ide-host/tests/vcs_shelf_roots.rs | 123 ++++++++++++++++++ crates/ide-vcs/src/lib.rs | 3 + crates/ide-vcs/src/shelf.rs | 31 ++++- crates/ide-vcs/tests/history_ops.rs | 6 +- src-tauri/src/commands/vcs.rs | 3 +- src/bindings.ts | 4 +- .../vcs/__tests__/repositoryPaths.test.ts | 21 +++ src/features/vcs/__tests__/shelf.test.tsx | 70 +++++++++- src/features/vcs/diffInput.ts | 3 +- src/features/vcs/logChanges.ts | 3 +- src/features/vcs/operations.tsx | 5 +- src/features/vcs/repositoryPaths.ts | 10 ++ src/features/vcs/shelf.tsx | 23 +++- src/platform/locales/text/zh.json | 4 +- 15 files changed, 327 insertions(+), 44 deletions(-) create mode 100644 crates/ide-host/tests/vcs_shelf_roots.rs create mode 100644 src/features/vcs/__tests__/repositoryPaths.test.ts create mode 100644 src/features/vcs/repositoryPaths.ts diff --git a/crates/ide-host/src/ops/vcs/commands.rs b/crates/ide-host/src/ops/vcs/commands.rs index 64d47e421..eae55b6b9 100644 --- a/crates/ide-host/src/ops/vcs/commands.rs +++ b/crates/ide-host/src/ops/vcs/commands.rs @@ -123,18 +123,18 @@ fn per_repo( Ok(()) } -/// Shelf and patches work in the primary repository only: changes of a nested one -/// fail instead of being left out silently. -fn in_primary_repo(project: &Project, paths: &[PathBuf]) -> IdeResult<()> { +/// A shelf is one patch for one owning repository. Validate the whole selection before reverting files. +fn shelf_repository(project: &Project, paths: &[PathBuf]) -> IdeResult { let repos = project.repos(); - let primary = repos.primary().map(|r| r.root()); - match paths.iter().find(|p| repos.for_path(p).map(|r| r.root()) != primary) { - Some(p) => Err(ide_core::IdeError::invalid(format!( - "{} is in another git repository; shelving and patches support the project's main repository only", - p.display() - ))), - None => Ok(()), + let root = paths + .first() + .and_then(|p| repos.for_path(p)) + .map(|r| r.root()) + .ok_or_else(|| ide_core::IdeError::invalid("select files in a git repository to shelve"))?; + if paths.iter().any(|p| repos.for_path(p).map(|r| r.root()) != Some(root)) { + return Err(ide_core::IdeError::invalid("select files from a single git repository to shelve")); } + Ok(root.to_path_buf()) } async fn blocking(f: impl FnOnce() -> IdeResult + Send + 'static) -> IdeResult { @@ -854,16 +854,23 @@ pub async fn vcs_shelve(host: &Host, window: &str, name: String, paths: Vec IdeResult> { let project = host.project(window)?; - ide_vcs::shelf::list(&shelf_dir(&project.root)) + let mut shelves = ide_vcs::shelf::list(&shelf_dir(&project.root))?; + let primary = project.repos().primary().map(|r| ide_core::IpcPath::from(r.root().to_path_buf())); + for entry in &mut shelves { + if entry.repo.is_none() { + entry.repo = primary.clone(); + } + } + Ok(shelves) } /// Unshelve: applies the shelved patch (3-way merging; conflicts are left for the @@ -878,7 +885,8 @@ pub async fn vcs_unshelve( host.require_trust(window)?; let project = host.project(window)?; blocking(move || { - project.with_repo_recorded(None, Some("Unshelve"), |r| { + let repo = ide_vcs::shelf::repository(&shelf_dir(&project.root), &id)?; + project.with_repo_recorded(repo.as_deref(), Some("Unshelve"), |r| { ide_vcs::shelf::unshelve_path(r, &shelf_dir(&project.root), &id, remove, path.as_deref()) }) }) @@ -902,12 +910,30 @@ pub fn vcs_shelf_patch(host: &Host, window: &str, id: String, path: Option IdeResult> { let project = host.project(window)?; - blocking(move || project.with_repo(|r| ide_vcs::shelf::diff_files(r, &shelf_dir(&project.root), &id))).await + blocking(move || { + let repo = ide_vcs::shelf::repository(&shelf_dir(&project.root), &id)?; + project.with_repo_at(repo.as_deref(), |r| ide_vcs::shelf::diff_files(r, &shelf_dir(&project.root), &id)) + }) + .await } -pub async fn vcs_shelf_import(host: &Host, window: &str, name: String, patch: String) -> IdeResult<()> { +pub async fn vcs_shelf_import( + host: &Host, + window: &str, + repo: Option, + name: String, + patch: String, +) -> IdeResult<()> { let project = host.project(window)?; - blocking(move || ide_vcs::shelf::import_patch(&shelf_dir(&project.root), &name, &patch)).await + blocking(move || { + if repo.is_none() && project.repos().is_empty() { + return ide_vcs::shelf::import_patch(&shelf_dir(&project.root), &name, &patch, None); + } + project.with_repo_at(repo.as_deref(), |r| { + ide_vcs::shelf::import_patch(&shelf_dir(&project.root), &name, &patch, Some(r.root())) + }) + }) + .await } pub fn vcs_changelists_load(host: &Host, window: &str) -> IdeResult { @@ -1107,7 +1133,7 @@ pub(crate) fn register(d: &mut Dispatcher) { d.add("vcs_shelf_rename", |c| async move { vcs_shelf_rename(&c.host, &c.window, c.arg("id")?, c.arg("name")?) }); d.add("vcs_shelf_diff", |c| async move { vcs_shelf_diff(&c.host, &c.window, c.arg("id")?).await }); d.add("vcs_shelf_import", |c| async move { - vcs_shelf_import(&c.host, &c.window, c.arg("name")?, c.arg("patch")?).await + vcs_shelf_import(&c.host, &c.window, c.arg("repo")?, c.arg("name")?, c.arg("patch")?).await }); d.add("vcs_shelf_patch", |c| async move { vcs_shelf_patch(&c.host, &c.window, c.arg("id")?, c.arg("path")?) }); d.add("vcs_changelists_load", |c| async move { vcs_changelists_load(&c.host, &c.window) }); diff --git a/crates/ide-host/tests/vcs_shelf_roots.rs b/crates/ide-host/tests/vcs_shelf_roots.rs new file mode 100644 index 000000000..cb716546b --- /dev/null +++ b/crates/ide-host/tests/vcs_shelf_roots.rs @@ -0,0 +1,123 @@ +//! Shelves retain their repository even when the project root is above or below it. +use ide_core::NullSink; +use ide_host::{Host, HostConfig, ops::vcs}; +use std::{path::Path, process::Command, sync::Arc, time::Duration}; + +fn git(root: &Path, args: &[&str]) { + let output = Command::new("git").current_dir(root).args(args).output().unwrap(); + assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr)); +} + +async fn check_shelves(layout: &str) { + let temp = tempfile::tempdir().unwrap(); + let base = dunce::canonicalize(temp.path()).unwrap(); + let project_root = base.join("project"); + let roots = match layout { + "same" => vec![project_root.clone()], + "below" => vec![base.clone()], + "two" => vec![project_root.join("项目A"), project_root.join("repoB")], + _ => vec![project_root.join("项目A")], + }; + std::fs::create_dir_all(project_root.join("dirB")).unwrap(); + for (i, root) in roots.iter().enumerate() { + std::fs::create_dir_all(root.join("src")).unwrap(); + git(root, &["init", "-b", "main"]); + git(root, &["config", "user.name", "Test"]); + git(root, &["config", "user.email", "test@example.com"]); + std::fs::write(root.join("src/same.txt"), format!("base {i}\n")).unwrap(); + git(root, &["add", "src"]); + git(root, &["commit", "-m", "initial"]); + std::fs::write(root.join("src/same.txt"), format!("shelved {i}\n")).unwrap(); + } + let host = Host::new(HostConfig { + config_dir: base.join("config"), + data_dir: base.join("data"), + local_data_dir: base.join("data"), + languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()), + syntax: Arc::default(), + sink_for: Box::new(|_| Arc::new(NullSink)), + owner: |w| w, + runtime: tokio::runtime::Handle::current(), + project_environment: false, + }); + host.trust.decide(&project_root, true, false).unwrap(); + let project = host.open_project("main", &project_root).unwrap(); + for _ in 0..500 { + if project.repos().all().len() == roots.len() { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + assert_eq!(project.repos().all().len(), roots.len()); + let primary = project.repos().primary().unwrap().root().to_path_buf(); + if roots.len() == 2 { + assert!( + vcs::vcs_shelve(&host, "main", "mixed".into(), roots.iter().map(|r| r.join("src/same.txt")).collect()) + .await + .is_err() + ); + assert!(vcs::vcs_shelves(&host, "main").unwrap().is_empty()); + } + for (i, root) in roots.iter().enumerate() { + let entry = vcs::vcs_shelve(&host, "main", "work".into(), vec![root.join("src/same.txt")]).await.unwrap(); + assert_eq!(entry.repo.as_deref(), Some(root.as_path())); + assert_eq!(std::fs::read_to_string(root.join("src/same.txt")).unwrap(), format!("base {i}\n")); + let listed = vcs::vcs_shelves(&host, "main").unwrap(); + assert_eq!(listed.iter().find(|s| s.id == entry.id).unwrap().repo, entry.repo); + let diffs = vcs::vcs_shelf_diff(&host, "main", entry.id.clone()).await.unwrap(); + assert_eq!(diffs[0].before.as_deref(), Some(format!("base {i}\n").as_str())); + assert_eq!(diffs[0].after.as_deref(), Some(format!("shelved {i}\n").as_str())); + vcs::vcs_shelf_rename(&host, "main", entry.id.clone(), "renamed".into()).unwrap(); + let patch = vcs::vcs_shelf_patch(&host, "main", entry.id.clone(), None).unwrap(); + vcs::vcs_shelf_import(&host, "main", Some(root.clone()), "imported".into(), patch).await.unwrap(); + let imported = vcs::vcs_shelves(&host, "main").unwrap().into_iter().find(|s| s.name == "imported").unwrap(); + assert_eq!(imported.repo, entry.repo); + vcs::vcs_shelf_delete(&host, "main", imported.id, None).unwrap(); + if root != &primary { + let info = ide_core::project_dir::path(&project_root, "shelf").join(&entry.id).join("shelf.json"); + let original = std::fs::read(&info).unwrap(); + let mut metadata: serde_json::Value = serde_json::from_slice(&original).unwrap(); + metadata["repo"] = serde_json::json!(base.join("missing-repository")); + std::fs::write(&info, serde_json::to_vec(&metadata).unwrap()).unwrap(); + assert!(vcs::vcs_shelf_diff(&host, "main", entry.id.clone()).await.is_err()); + assert!(vcs::vcs_unshelve(&host, "main", entry.id.clone(), false, None).await.is_err()); + assert_eq!(std::fs::read_to_string(root.join("src/same.txt")).unwrap(), format!("base {i}\n")); + std::fs::write(info, original).unwrap(); + } + vcs::vcs_unshelve(&host, "main", entry.id.clone(), false, Some("src/same.txt".into())).await.unwrap(); + assert_eq!(std::fs::read_to_string(root.join("src/same.txt")).unwrap(), format!("shelved {i}\n")); + // Old shelves were always made in the primary repository; missing ownership keeps that meaning. + if root == &primary { + let info = ide_core::project_dir::path(&project_root, "shelf").join(&entry.id).join("shelf.json"); + let mut metadata: serde_json::Value = serde_json::from_slice(&std::fs::read(&info).unwrap()).unwrap(); + metadata.as_object_mut().unwrap().remove("repo"); + std::fs::write(info, serde_json::to_vec(&metadata).unwrap()).unwrap(); + assert_eq!( + vcs::vcs_shelves(&host, "main").unwrap().into_iter().find(|s| s.id == entry.id).unwrap().repo, + entry.repo + ); + assert_eq!( + vcs::vcs_shelf_diff(&host, "main", entry.id).await.unwrap()[0].after.as_deref(), + Some(format!("shelved {i}\n").as_str()) + ); + } + } + host.close_project("main"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn shelf_at_repo_root() { + check_shelves("same").await; +} +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn shelf_in_non_git_project() { + check_shelves("above").await; +} +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn shelves_in_two_repositories() { + check_shelves("two").await; +} +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn shelf_in_project_below_repository() { + check_shelves("below").await; +} diff --git a/crates/ide-vcs/src/lib.rs b/crates/ide-vcs/src/lib.rs index cca5669ac..7e8cd3526 100644 --- a/crates/ide-vcs/src/lib.rs +++ b/crates/ide-vcs/src/lib.rs @@ -297,6 +297,9 @@ pub struct ShelvedChangeList { pub time_ms: f64, /// Branch checked out when the changes were shelved. pub branch: Option, + /// Repository owning the patch; absent in legacy shelves. + #[serde(default)] + pub repo: Option, /// Repository-relative paths. pub files: Vec, } diff --git a/crates/ide-vcs/src/shelf.rs b/crates/ide-vcs/src/shelf.rs index 502867130..cdd5f882f 100644 --- a/crates/ide-vcs/src/shelf.rs +++ b/crates/ide-vcs/src/shelf.rs @@ -19,6 +19,8 @@ struct Info { #[serde(default)] branch: Option, files: Vec, + #[serde(default)] + repo: Option, } fn now_ms() -> f64 { @@ -67,6 +69,7 @@ pub fn list(shelf: &Path) -> IdeResult> { name: id.clone(), time_ms: 0.0, branch: None, + repo: None, files: std::fs::read_to_string(dir.join(PATCH)) .map(|p| { crate::patch::parse_patch(&p) @@ -81,6 +84,7 @@ pub fn list(shelf: &Path) -> IdeResult> { name: info.name, time_ms: info.time_ms, branch: info.branch, + repo: info.repo, files: crate::patch::parse_patch(&std::fs::read_to_string(dir.join(PATCH))?) .into_iter() .map(|f| crate::ChangedFile { path: f.path, kind: f.kind, old_path: f.old_path }) @@ -96,6 +100,11 @@ pub fn patch_of(shelf: &Path, id: &str) -> IdeResult { Ok(std::fs::read_to_string(entry_dir(shelf, id)?.join(PATCH))?) } +/// The repository captured when the shelf was made; legacy shelves used the primary repository. +pub fn repository(shelf: &Path, id: &str) -> IdeResult> { + Ok(read_info(&entry_dir(shelf, id)?).and_then(|info| info.repo).map(|repo| repo.into_path_buf())) +} + pub fn delete(shelf: &Path, id: &str) -> IdeResult<()> { std::fs::remove_dir_all(entry_dir(shelf, id)?)?; Ok(()) @@ -107,8 +116,13 @@ pub fn rename(shelf: &Path, id: &str, name: &str) -> IdeResult<()> { return Err(IdeError::invalid("name must not be empty")); } let dir = entry_dir(shelf, id)?; - let mut info = - read_info(&dir).unwrap_or(Info { name: String::new(), time_ms: now_ms(), branch: None, files: vec![] }); + let mut info = read_info(&dir).unwrap_or(Info { + name: String::new(), + time_ms: now_ms(), + branch: None, + repo: None, + files: vec![], + }); info.name = name.to_string(); std::fs::write(dir.join(INFO), serde_json::to_vec_pretty(&info).map_err(|e| IdeError::internal(e.to_string()))?)?; Ok(()) @@ -134,7 +148,13 @@ pub(crate) fn shelve(r: &Repo, shelf: &Path, name: &str, rel_paths: &[String]) - } let dir = shelf.join(&id); std::fs::create_dir_all(&dir)?; - let info = Info { name: name.to_string(), time_ms: now_ms(), branch: r.branch_info()?.head, files: files.clone() }; + let info = Info { + name: name.to_string(), + time_ms: now_ms(), + branch: r.branch_info()?.head, + files: files.clone(), + repo: Some(r.root().to_path_buf().into()), + }; let write = || -> IdeResult<()> { std::fs::write(dir.join(PATCH), patch.as_bytes())?; std::fs::write( @@ -148,7 +168,7 @@ pub(crate) fn shelve(r: &Repo, shelf: &Path, name: &str, rel_paths: &[String]) - return Err(e); } revert_files(r, &files)?; - Ok(ShelvedChangeList { id, name: info.name, time_ms: info.time_ms, branch: info.branch, files }) + Ok(ShelvedChangeList { id, name: info.name, time_ms: info.time_ms, branch: info.branch, repo: info.repo, files }) } /// Restores `files` to HEAD in both index and working tree; files absent from HEAD are removed. @@ -318,7 +338,7 @@ pub fn diff_files(r: &Repo, shelf: &Path, id: &str) -> IdeResult IdeResult<()> { +pub fn import_patch(shelf: &Path, name: &str, patch: &str, repo: Option<&Path>) -> IdeResult<()> { let files = crate::patch::parse_patch(patch); if files.is_empty() || sections(patch).len() != files.len() { return Err(IdeError::invalid("no file changes found in the patch")); @@ -334,6 +354,7 @@ pub fn import_patch(shelf: &Path, name: &str, patch: &str) -> IdeResult<()> { name: name.to_string(), time_ms: now_ms(), branch: None, + repo: repo.map(|r| r.to_path_buf().into()), files: files .into_iter() .map(|f| crate::ChangedFile { path: f.path, kind: f.kind, old_path: f.old_path }) diff --git a/crates/ide-vcs/tests/history_ops.rs b/crates/ide-vcs/tests/history_ops.rs index dd854c949..2d86ebc49 100644 --- a/crates/ide-vcs/tests/history_ops.rs +++ b/crates/ide-vcs/tests/history_ops.rs @@ -614,7 +614,7 @@ fn shelf_diff_and_scoped_operations_preserve_siblings() { assert!(!shelf::list(&shelf).unwrap()[0].files.iter().any(|f| f.path == "b.txt")); shelf::delete_path(&shelf, &entry.id, Some(QUOTED)).unwrap(); assert!(shelf::selected_patch(&shelf, &entry.id, Some("a.txt")).is_ok()); - shelf::import_patch(&shelf, "imported", &patch).unwrap(); + shelf::import_patch(&shelf, "imported", &patch, None).unwrap(); assert_eq!(shelf::list(&shelf).unwrap().len(), 2); assert_eq!(fx.read("a.txt"), "local edits\n"); } @@ -629,7 +629,7 @@ fn shelf_imported_rename_diff_and_last_file_delete() { fx.git(&["reset", "--hard", "HEAD"]); let repo = fx.repo(); let shelf = fx._tmp.path().join("shelf"); - shelf::import_patch(&shelf, "rename", &patch).unwrap(); + shelf::import_patch(&shelf, "rename", &patch, None).unwrap(); let id = &shelf::list(&shelf).unwrap()[0].id; let diffs = shelf::diff_files(&repo, &shelf, id).unwrap(); assert_eq!(diffs.len(), 1); @@ -655,7 +655,7 @@ fn shelf_new_renames_keep_self_contained_sides_and_plain_imports_keep_scope() { assert_eq!(diffs.iter().find(|f| f.path == "old.txt").unwrap().before.as_deref(), Some("renamed content\n")); assert_eq!(diffs.iter().find(|f| f.path == "new.txt").unwrap().after.as_deref(), Some("renamed content\n")); let plain = "--- a/one.txt\n+++ b/one.txt\n@@ -1 +1 @@\n--- removed\n+++ added\n--- a/two.txt\n+++ b/two.txt\n@@ -1 +1 @@\n-old\n+new\n"; - shelf::import_patch(&shelf, "plain", plain).unwrap(); + shelf::import_patch(&shelf, "plain", plain, None).unwrap(); let selected = shelf::selected_patch(&shelf, "plain", Some("one.txt")).unwrap(); assert!(selected.contains("--- removed\n+++ added")); assert!(!selected.contains("two.txt")); diff --git a/src-tauri/src/commands/vcs.rs b/src-tauri/src/commands/vcs.rs index 9f3aaf188..305fd0e8c 100644 --- a/src-tauri/src/commands/vcs.rs +++ b/src-tauri/src/commands/vcs.rs @@ -675,10 +675,11 @@ pub async fn vcs_shelf_diff( pub async fn vcs_shelf_import( window: Window, state: State<'_, AppState>, + repo: Option, name: String, patch: String, ) -> IdeResult<()> { - ide_host::ops::vcs::vcs_shelf_import(&state.host, window.label(), name, patch).await + ide_host::ops::vcs::vcs_shelf_import(&state.host, window.label(), repo, name, patch).await } #[tauri::command] diff --git a/src/bindings.ts b/src/bindings.ts index 6d71d1a38..22f176d2f 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -546,7 +546,7 @@ export const commands = { vcsShelfRename: (id: string, name: string) => typedError(__TAURI_INVOKE("vcs_shelf_rename", { id, name })), vcsShelfPatch: (id: string, path: string | null) => typedError(__TAURI_INVOKE("vcs_shelf_patch", { id, path })), vcsShelfDiff: (id: string) => typedError(__TAURI_INVOKE("vcs_shelf_diff", { id })), - vcsShelfImport: (name: string, patch: string) => typedError(__TAURI_INVOKE("vcs_shelf_import", { name, patch })), + vcsShelfImport: (repo: string | null, name: string, patch: string) => typedError(__TAURI_INVOKE("vcs_shelf_import", { repo, name, patch })), vcsChangelistsLoad: () => typedError(__TAURI_INVOKE("vcs_changelists_load")), vcsChangelistsSave: (changelists: ChangelistState) => typedError(__TAURI_INVOKE("vcs_changelists_save", { changelists })), /** Create Patch › Save to file. */ @@ -5783,6 +5783,8 @@ export type ShelvedChangeList = { timeMs: number | null, /** Branch checked out when the changes were shelved. */ branch: string | null, + /** Repository owning the patch; absent in legacy shelves. */ + repo?: IpcPath | null, /** Repository-relative paths. */ files: ChangedFile[], }; diff --git a/src/features/vcs/__tests__/repositoryPaths.test.ts b/src/features/vcs/__tests__/repositoryPaths.test.ts new file mode 100644 index 000000000..e14518494 --- /dev/null +++ b/src/features/vcs/__tests__/repositoryPaths.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +import { repositoryPath } from "../repositoryPaths"; + +describe("repository-relative paths", () => { + it.each([ + ["/repo", "src/a.txt", "/repo/src/a.txt"], + ["/project/项目A", "src/a.txt", "/project/项目A/src/a.txt"], + ["/project/repoB/", "src/a.txt", "/project/repoB/src/a.txt"], + ["/repo", "subproject/a.txt", "/repo/subproject/a.txt"], + ["C:\\project\\项目A", "src\\a.txt", "C:/project/项目A/src/a.txt"], + ["C:/project/repoB", "src\\a.txt", "C:/project/repoB/src/a.txt"], + ["C:\\", "a.txt", "C:/a.txt"], + ["\\\\server\\share\\repo", "src\\a.txt", "//server/share/repo/src/a.txt"], + ["\\\\?\\C:\\repo", "src\\a.txt", "C:/repo/src/a.txt"], + ["/", "a.txt", "/a.txt"], + ["/repo", "src/a\\b.txt", "/repo/src/a\\b.txt"], + [null, "src/a.txt", "src/a.txt"], + ])("%s + %s → %s", (root, path, expected) => { + expect(repositoryPath(root, path)).toBe(expected); + }); +}); diff --git a/src/features/vcs/__tests__/shelf.test.tsx b/src/features/vcs/__tests__/shelf.test.tsx index 69ce6d02a..3d9623644 100644 --- a/src/features/vcs/__tests__/shelf.test.tsx +++ b/src/features/vcs/__tests__/shelf.test.tsx @@ -3,7 +3,7 @@ import { act, fireEvent, render, screen, waitFor, cleanup } from "@testing-libra import { afterEach } from "vitest"; import type { ShelvedChangeList } from "@/bindings"; -const mocks = vi.hoisted(() => ({ openDiff: vi.fn(), execute: vi.fn(), ipc: vi.fn(), patch: vi.fn() })); +const mocks = vi.hoisted(() => ({ openDiff: vi.fn(), execute: vi.fn(), ipc: vi.fn(), patch: vi.fn(), probe: vi.fn() })); vi.mock("../diffInput", async (original) => ({ ...(await original()), openDiff: mocks.openDiff, @@ -19,13 +19,16 @@ vi.mock("@/platform/ipc", async (original) => ({ ...(await original()).commands, vcsShelves: () => Promise.resolve(null), vcsShelfDiff: () => Promise.resolve(null), - fsProbe: () => Promise.resolve(null), + fsProbe: mocks.probe, vcsShelfPatch: mocks.patch, + vcsUnshelve: () => Promise.resolve(null), }, })); -import { ShelfPanel, copyShelfPatch, shelfDiffs, shelfTarget, showShelfDiff, useShelf } from "../shelf"; +import { ShelfPanel, copyShelfPatch, shelfDiffs, shelfTarget, showShelfDiff, unshelve, useShelf } from "../shelf"; import { useCommit } from "../commitStore"; import { useVcs } from "../store"; +import { useChangelists } from "../changelists"; +import { VcsDialogHost } from "../dialogs"; import { ContextMenuHost } from "@/ui"; import { registerVcsActions } from "../actions"; import { getAction, isActionKeyEnabled } from "@/platform/actions"; @@ -37,6 +40,7 @@ const entry: ShelvedChangeList = { name: "Work", timeMs: 0, branch: null, + repo: null, files: [ { path: "a.txt", kind: "modified", oldPath: null }, { path: "b.txt", kind: "added", oldPath: null }, @@ -51,7 +55,7 @@ const diffs = [ beforeEach(() => { vi.clearAllMocks(); mocks.patch.mockResolvedValue(null); - useVcs.setState({ root: "/repo" }); + useVcs.setState({ root: "/repo", roots: [], repo: null, available: false, status: [] }); useCommit.setState({ selectedShelf: "work\u0000a.txt" }); useShelf.setState({ shelves: [entry], loaded: true, diffs: { work: diffs } }); mocks.ipc.mockResolvedValueOnce([entry]).mockResolvedValue(diffs); @@ -103,6 +107,64 @@ describe("shelf scope and diffs", () => { await showShelfDiff({ id: "work", path: "a.txt" }, true); expect(mocks.openDiff).toHaveBeenCalledTimes(1); }); + it.each([ + ["same root", "/repo", ["/repo"], "/repo", "src/a.txt", "/repo/src/a.txt"], + ["项目A under a non-git project", "/project", ["/project/项目A"], null, "src/a.txt", "/project/项目A/src/a.txt"], + [ + "second repo with the same file name", + "/project", + ["/project/A", "/project/B"], + "/project/B", + "src/a.txt", + "/project/B/src/a.txt", + ], + ["project below the repo", "/repo/subproject", ["/repo"], null, "src/a.txt", "/repo/src/a.txt"], + [ + "Windows stored separators", + "C:/project", + ["C:/project/项目A"], + "C:/project/项目A", + "src\\a.txt", + "C:/project/项目A/src/a.txt", + ], + ])("maps saved and local diffs to the owning repo: %s", async (_name, root, roots, repo, path, expected) => { + useVcs.setState({ root, roots, repo: roots[0] ?? null }); + useShelf.setState({ + shelves: [{ ...entry, repo }], + diffs: { work: [{ path, before: "base", after: "shelved", patch: "patch" }] }, + }); + expect(shelfDiffs({ id: "work" })[0]?.path).toBe(expected); + mocks.ipc.mockReset().mockImplementation(async () => { + if (mocks.probe.mock.lastCall?.[0] !== expected) throw new Error("wrong local file"); + return { binary: false }; + }); + await showShelfDiff({ id: "work" }, true); + expect(mocks.probe).toHaveBeenLastCalledWith(expected); + expect(mocks.openDiff).toHaveBeenCalledWith(expect.objectContaining({ path: expected }), { preview: true }); + }); + it("keeps legacy shelves on the primary repo after selecting another repo", () => { + useVcs.setState({ root: "/project", roots: ["/project/A", "/project/B"], repo: "/project/B" }); + expect(shelfDiffs({ id: "work", path: "a.txt" }, true)[0]?.path).toBe("/project/A/a.txt"); + }); + it.each([ + ["/project", "/project/项目A", "src/a.txt", "项目A/src/a.txt"], + ["/repo/subproject", "/repo", "src/a.txt", "/repo/src/a.txt"], + ["C:/project", "C:/project/项目A", "src\\a.txt", "项目A/src/a.txt"], + ])("assigns unshelved paths to the project changelist from %s", async (root, repo, path, expected) => { + useVcs.setState({ root, roots: [repo] }); + useChangelists.setState({ + loaded: false, + state: { lists: [{ id: "target", name: "Target", comment: "" }], active: "target", files: {} }, + }); + mocks.ipc + .mockReset() + .mockResolvedValueOnce({ applied: [path], conflicts: [] }) + .mockResolvedValue([]); + render(); + act(() => unshelve({ ...entry, repo })); + fireEvent.click(screen.getByRole("button", { name: "Unshelve" })); + await waitFor(() => expect(useChangelists.getState().state.files).toEqual({ [expected]: "target" })); + }); it("copies only the selected file patch through the scoped patch command", async () => { const previous = Object.getOwnPropertyDescriptor(navigator, "clipboard"); const writeText = vi.fn().mockResolvedValue(undefined); diff --git a/src/features/vcs/diffInput.ts b/src/features/vcs/diffInput.ts index 6d7cf6ec6..583c8bb93 100644 --- a/src/features/vcs/diffInput.ts +++ b/src/features/vcs/diffInput.ts @@ -6,6 +6,7 @@ import { baseName, effectiveKind } from "./model"; import { readWorkingText } from "./ops"; import { renamedFromPath } from "./changes"; import { currentRepoRoot, statusOf } from "./store"; +import { repositoryPath } from "./repositoryPaths"; export type DiffSide = /** File content at a git revision (`HEAD`, `:0` = index, a commit id, `^`). */ @@ -144,7 +145,7 @@ export function workingDiffData(path: string, line?: number): DiffData { /** A path of the selected repository's log (repository-relative) made absolute. */ function joinRoot(rel: string, root = currentRepoRoot()): string { - return root ? `${root}/${rel}` : rel; + return repositoryPath(root, rel); } /** Diff of one file changed by `commit` (first parent ↔ commit). */ diff --git a/src/features/vcs/logChanges.ts b/src/features/vcs/logChanges.ts index 12dfe3a68..87e1b0d27 100644 --- a/src/features/vcs/logChanges.ts +++ b/src/features/vcs/logChanges.ts @@ -14,6 +14,7 @@ import { baseName } from "./model"; import { readWorkingText, runOp, saveAll, writeFileText } from "./ops"; import { reportApply } from "./shelf"; import { currentRepoRoot } from "./store"; +import { repositoryPath } from "./repositoryPaths"; import { confirmDialog } from "./dialogs"; /** A file of the pane, with the revisions holding its content before and after the change. */ @@ -109,7 +110,7 @@ export function filterPatch(patch: string, paths: ReadonlySet): string { /** Absolute path of a repository-relative one. */ export function absolutePath(rel: string, root = currentRepoRoot()): string { - return root ? `${root}/${rel}` : rel; + return repositoryPath(root, rel); } /** Pane state the changed-files actions work on when they get no argument. */ diff --git a/src/features/vcs/operations.tsx b/src/features/vcs/operations.tsx index b18a7ae1d..b57298b47 100644 --- a/src/features/vcs/operations.tsx +++ b/src/features/vcs/operations.tsx @@ -21,6 +21,7 @@ import { ComparisonTree } from "./ComparisonTree"; import { buildFileTree, type FileTreeNode } from "./fileTree"; import { tx } from "@/platform/i18n"; import { displayPath } from "./compareData"; +import { repositoryPath } from "./repositoryPaths"; // --------------------------------------------------------------------------- // Operation in progress @@ -580,7 +581,7 @@ export function ChangesBetweenDialog({ const diffFor = (f: ComparisonFile): DiffData => { const missing = (label: string): DiffSide => ({ type: "text", text: "", label }); return { - path: `${f.root}/${f.path}`, + path: repositoryPath(f.root, f.path), left: f.kind === "added" || f.kind === "untracked" ? missing("(does not exist)") @@ -588,7 +589,7 @@ export function ChangesBetweenDialog({ type: "rev", rev: f.revision, label: from.label, - path: f.oldPath ? `${f.root}/${f.oldPath}` : undefined, + path: f.oldPath ? repositoryPath(f.root, f.oldPath) : undefined, }, right: f.kind === "deleted" diff --git a/src/features/vcs/repositoryPaths.ts b/src/features/vcs/repositoryPaths.ts new file mode 100644 index 000000000..686a9b5d3 --- /dev/null +++ b/src/features/vcs/repositoryPaths.ts @@ -0,0 +1,10 @@ +/** Stored Git paths are relative to their repository, independently of the open project. */ +import { ipcPath } from "@/platform/paths"; + +export function repositoryPath(root: string | null | undefined, path: string): string { + if (!root) return path; + const base = ipcPath(root).replace(/\/$/, ""); + // Backslashes are separators on Windows, but can be literal filename characters on POSIX. + const relative = /^[A-Za-z]:\//.test(`${base}/`) || base.startsWith("//") ? path.replace(/\\/g, "/") : path; + return `${base}/${relative}`; +} diff --git a/src/features/vcs/shelf.tsx b/src/features/vcs/shelf.tsx index 0979e2b49..9e808b91d 100644 --- a/src/features/vcs/shelf.tsx +++ b/src/features/vcs/shelf.tsx @@ -22,7 +22,8 @@ import { showLocalChanges } from "./commitUi"; import { confirmDialog, openDialog, promptText } from "./dialogs"; import { KIND_COLOR, baseName, dirName, effectiveKind, fileCount, formatTime, relTo, stripSlash } from "./model"; import { errorMessage, logConsole, runOp, saveAll } from "./ops"; -import { conflictedFiles, refreshNow, useVcs } from "./store"; +import { conflictedFiles, currentRepo, refreshNow, useVcs } from "./store"; +import { repositoryPath } from "./repositoryPaths"; import styles from "./common.module.css"; import viewStyles from "./CommitView.module.css"; import { tx } from "@/platform/i18n"; @@ -212,7 +213,7 @@ function UnshelveDialog({ entry, path, onClose }: { entry: ShelvedChangeList; pa const lists = useChangelists((s) => s.state); const [target, setTarget] = useState(lists.active); const [remove, setRemove] = useState(() => workflowSettings().vcs.shelf.removeApplied); - const root = useVcs((s) => s.root) ?? ""; + const repo = shelfRepository(entry); const run = async () => { onClose(); await saveAll(); @@ -225,7 +226,7 @@ function UnshelveDialog({ entry, path, onClose }: { entry: ShelvedChangeList; pa ); if (!outcome) return; await refreshNow(); - const rels = [...outcome.applied, ...outcome.conflicts].map((p) => relOf(`${root}/${p}`)); + const rels = [...outcome.applied, ...outcome.conflicts].map((p) => relOf(repositoryPath(repo, p))); updateChangelists((s) => moveFiles(s, rels, target)); void loadShelves().catch(() => undefined); await reportApply(outcome, `Unshelved “${entry.name}”`); @@ -325,13 +326,20 @@ export function shelfTarget(arg?: unknown): { entry: ShelvedChangeList; path?: s return { entry, path: target?.path }; } +/** Legacy shelves were created in the primary repository, regardless of the selected one. */ +function shelfRepository(entry?: ShelvedChangeList): string | null { + const state = useVcs.getState(); + return entry?.repo ?? state.roots[0] ?? state.root; +} + export function shelfDiffs(target: ShelfTarget, local = false): DiffData[] { - const root = useVcs.getState().root; + const state = useShelf.getState(); + const root = shelfRepository(state.shelves.find((entry) => entry.id === target.id)); if (!root) return []; - return (useShelf.getState().diffs[target.id] ?? []) + return (state.diffs[target.id] ?? []) .filter((f) => (!target.path || f.path === target.path) && f.before !== null && f.after !== null) .map((f) => ({ - path: `${root}/${f.path}`, + path: repositoryPath(root, f.path), left: { type: "text", text: (local ? f.after : f.before)!, @@ -371,6 +379,7 @@ export async function showShelfDiff(arg?: unknown, local = false, newTab = false } export async function importShelfPatches(paths?: string[]) { + const repo = currentRepo(); const picked = paths ?? (await import("@tauri-apps/plugin-dialog").then(({ open }) => @@ -380,7 +389,7 @@ export async function importShelfPatches(paths?: string[]) { for (const path of typeof picked === "string" ? [picked] : picked) { await runOp( "Import patches", - async () => call(commands.vcsShelfImport(baseName(path), await call(commands.fsReadText(path)))), + async () => call(commands.vcsShelfImport(repo, baseName(path), await call(commands.fsReadText(path)))), { refresh: false }, ); } diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index be3187ac6..5d0f755f6 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -5515,5 +5515,7 @@ "App labels, modules or dotted test paths; empty runs every test": "应用标签、模块或以点分隔的测试路径;留空则运行全部测试", "Settings module": "设置模块", "Passed as --settings; empty keeps DJANGO_SETTINGS_MODULE or the default in manage.py": "作为 --settings 传入;留空则沿用 DJANGO_SETTINGS_MODULE 或 manage.py 中的默认值", - "e.g. --keepdb --parallel": "例如 --keepdb --parallel" + "e.g. --keepdb --parallel": "例如 --keepdb --parallel", + "select files in a git repository to shelve": "请选择 Git 仓库中的文件以搁置更改", + "select files from a single git repository to shelve": "请一次只选择同一个 Git 仓库中的文件以搁置更改" } From 0a24b79b50bdb780173228809ac8ef36708d8289 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Tue, 3 Nov 2026 07:54:34 +0800 Subject: [PATCH 044/106] fix(editor): skip clean saves and refresh buffers after disk operations (#920) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause, two parts: - An explicit save (Save All before a checkout, before running, before Commit) also wrote documents that were not modified, running save actions such as stripping trailing whitespace and adding a final newline on them — files the user never touched gained whitespace-only changes. Saves now skip clean documents, as autosave already did. - After our own VCS operations rewrote files (checkout, rollback, stash, patch, ignore, commit/worktree hooks, Directory Compare), clean editor buffers were only refreshed by the file watcher, which can miss a rewrite that keeps the length and the timestamp's millisecond. Those operations now reload clean buffers synchronously before returning, keeping each document's chosen encoding; modified buffers keep the existing conflict handling. Fixes #893 Co-Authored-By: Claude Opus 5.5 --- crates/ide-host/src/ops/fs.rs | 18 +- crates/ide-host/src/ops/vcs/commands.rs | 25 ++- crates/ide-host/src/project.rs | 13 +- crates/ide-host/tests/vcs_documents.rs | 240 ++++++++++++++++++++++++ crates/ide-workspace/src/documents.rs | 30 ++- crates/ide-workspace/tests/documents.rs | 31 +++ src/editor/api.ts | 2 +- src/editor/core/model/document.test.ts | 56 +++++- src/editor/core/model/document.ts | 5 +- 9 files changed, 390 insertions(+), 30 deletions(-) create mode 100644 crates/ide-host/tests/vcs_documents.rs diff --git a/crates/ide-host/src/ops/fs.rs b/crates/ide-host/src/ops/fs.rs index fb55f6f0f..7c56cd3a3 100644 --- a/crates/ide-host/src/ops/fs.rs +++ b/crates/ide-host/src/ops/fs.rs @@ -56,14 +56,16 @@ pub async fn copy_merge( if overwrite { project.history.record_deletion(&to, DELETE_SNAPSHOT_LIMIT); } - ide_vfs::copy_merge( - &from, - &to, - overwrite, - Some(&project.root), - &project.exclude_globs(), - (&source_root, &destination_root), - ) + project.with_disk_sync(|| { + ide_vfs::copy_merge( + &from, + &to, + overwrite, + Some(&project.root), + &project.exclude_globs(), + (&source_root, &destination_root), + ) + }) }) .await } diff --git a/crates/ide-host/src/ops/vcs/commands.rs b/crates/ide-host/src/ops/vcs/commands.rs index eae55b6b9..d3913c0af 100644 --- a/crates/ide-host/src/ops/vcs/commands.rs +++ b/crates/ide-host/src/ops/vcs/commands.rs @@ -281,7 +281,7 @@ pub async fn vcs_add_to_ignore( } } apply_git_executable(&project.settings.effective()); - let result = ide_vcs::add_to_ignore(&paths, destination); + let result = project.with_disk_sync(|| ide_vcs::add_to_ignore(&paths, destination)); // Exclude may live outside a worktree's watched directory. Refresh filters and UI explicitly. project.watcher.reload_ignore_rules(); for (_, watcher) in project.content_watchers.lock().iter() { @@ -322,7 +322,7 @@ pub async fn vcs_commit( // Commit hooks may run for a long time. let project = host.project(window)?; blocking(move || { - project.with_repo_at(repo.as_deref(), |r| { + project.with_repo_recorded(repo.as_deref(), None, |r| { r.commit_with(&message, &options, &|path| project.vcs_encoding(path, None)) }) }) @@ -729,7 +729,7 @@ pub async fn vcs_add_worktree( host.require_trust(window)?; let project = host.project(window)?; blocking(move || { - project.with_repo_at(repo.as_deref(), |r| r.add_worktree(&path, &commitish, new_branch.as_deref())) + project.with_repo_recorded(repo.as_deref(), None, |r| r.add_worktree(&path, &commitish, new_branch.as_deref())) }) .await } @@ -846,7 +846,7 @@ pub async fn vcs_apply_patch( ) -> IdeResult { host.require_trust(window)?; let project = host.project(window)?; - blocking(move || project.with_repo_at(repo.as_deref(), |r| r.apply_patch(&patch))).await + blocking(move || project.with_repo_recorded(repo.as_deref(), None, |r| r.apply_patch(&patch))).await } /// Shelve Changes: saves the changes of `paths` as a patch on the shelf and reverts them. @@ -948,13 +948,20 @@ pub fn vcs_changelists_save(host: &Host, window: &str, changelists: ChangelistSt } /// Create Patch › Save to file. -pub async fn vcs_write_patch(_host: &Host, _window: &str, path: PathBuf, patch: String) -> IdeResult<()> { +pub async fn vcs_write_patch(host: &Host, window: &str, path: PathBuf, patch: String) -> IdeResult<()> { + let project = host.project(window).ok(); blocking(move || { - if let Some(dir) = path.parent() { - std::fs::create_dir_all(dir)?; + let write = || { + if let Some(dir) = path.parent() { + std::fs::create_dir_all(dir)?; + } + std::fs::write(&path, patch)?; + Ok(()) + }; + match project { + Some(project) => project.with_disk_sync(write), + None => write(), } - std::fs::write(&path, patch)?; - Ok(()) }) .await } diff --git a/crates/ide-host/src/project.rs b/crates/ide-host/src/project.rs index a42889a19..9b4dda808 100644 --- a/crates/ide-host/src/project.rs +++ b/crates/ide-host/src/project.rs @@ -682,17 +682,24 @@ impl Project { self.with_repo_at(None, f) } + pub(crate) fn with_disk_sync(&self, f: impl FnOnce() -> IdeResult) -> IdeResult { + let result = f(); + // Failed operations can also leave changed files (for example merge conflicts). + self.documents.sync_after_disk_change(); + result + } + /// Runs a VCS operation that rewrites the working tree (checkout, merge, /// rebase, pull, reset, stash, rollback): Local History gets a label and a /// snapshot of every locally changed file before, and a label after. - /// `None` runs `f` like [`with_repo`](Self::with_repo) (operations that keep the working tree). + /// Clean buffers follow the resulting files before returning; `None` omits the history labels. pub fn with_repo_recorded( &self, at: Option<&Path>, operation: Option<&str>, f: impl FnOnce(&Repo) -> IdeResult, ) -> IdeResult { - let Some(name) = operation else { return self.with_repo_at(at, f) }; + let Some(name) = operation else { return self.with_disk_sync(|| self.with_repo_at(at, f)) }; self.history_label(&format!("Before {name}")); if let Ok(changed) = self.with_repo_at(at, |r| r.status()) { for file in changed.iter().take(HISTORY_BATCH_LIMIT) { @@ -701,7 +708,7 @@ impl Project { } } } - let result = self.with_repo_at(at, f); + let result = self.with_disk_sync(|| self.with_repo_at(at, f)); self.history_label(&format!("After {name}")); result } diff --git a/crates/ide-host/tests/vcs_documents.rs b/crates/ide-host/tests/vcs_documents.rs new file mode 100644 index 000000000..b6ca6fbec --- /dev/null +++ b/crates/ide-host/tests/vcs_documents.rs @@ -0,0 +1,240 @@ +use ide_core::{ChangeSet, NullSink, TextChange}; +use ide_host::{Host, HostConfig, Project, ops::vcs}; +use std::{path::Path, process::Command, sync::Arc, time::Duration}; + +const ORIGINAL: &str = "SELECT *\nFROM patients\n\tLIMIT 10 OFFSET 0"; +const OTHER: &str = "SELECT id\nFROM patients\n\tLIMIT 20 OFFSET 0"; + +fn git(root: &Path, args: &[&str]) -> String { + let out = Command::new("git").current_dir(root).args(args).output().unwrap(); + assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); + String::from_utf8(out.stdout).unwrap() +} + +struct Fixture { + host: Host, + project: Arc, + path: std::path::PathBuf, + doc: ide_core::DocId, + _temp: tempfile::TempDir, +} + +impl Fixture { + async fn new() -> Self { + let temp = tempfile::tempdir().unwrap(); + let base = dunce::canonicalize(temp.path()).unwrap(); + let root = base.join("project"); + // The command must synchronize documents even when watcher delivery is excluded. + let path = root.join("node_modules/query.sql"); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + git(&root, &["init", "-b", "main"]); + git(&root, &["config", "user.name", "Test"]); + git(&root, &["config", "user.email", "test@example.com"]); + git(&root, &["config", "commit.gpgsign", "false"]); + git(&root, &["config", "core.autocrlf", "false"]); + std::fs::write(&path, ORIGINAL).unwrap(); + git(&root, &["add", "-f", "node_modules/query.sql"]); + git(&root, &["commit", "-m", "original"]); + git(&root, &["switch", "-c", "other"]); + std::fs::write(&path, OTHER).unwrap(); + git(&root, &["commit", "-am", "other"]); + git(&root, &["switch", "main"]); + let host = Host::new(HostConfig { + config_dir: base.join("config"), + data_dir: base.join("data"), + local_data_dir: base.join("data"), + languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()), + syntax: Arc::default(), + sink_for: Box::new(|_| Arc::new(NullSink)), + owner: |w| w, + runtime: tokio::runtime::Handle::current(), + project_environment: false, + }); + host.trust.decide(&root, true, false).unwrap(); + let project = host.open_project("main", &root).unwrap(); + for _ in 0..500 { + if !project.repos().is_empty() { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + let doc = project.documents.open(&path).unwrap().id; + Self { host, project, path, doc, _temp: temp } + } + + fn saved_edit(&self, text: &str) { + self.project.documents.replace_all(self.doc, text).unwrap(); + self.project.documents.save(self.doc).unwrap(); + } + + fn assert_clean(&self, text: &str) { + let snapshot = self.project.documents.snapshot(self.doc).unwrap(); + assert_eq!(snapshot.text, text); + assert!(!snapshot.dirty); + self.project.documents.save_all().unwrap(); + assert_eq!(std::fs::read_to_string(&self.path).unwrap(), text); + } +} + +impl Drop for Fixture { + fn drop(&mut self) { + self.host.close_project("main"); + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn checkout_reloads_clean_open_documents_before_returning() { + let f = Fixture::new().await; + vcs::vcs_checkout(&f.host, "main", None, "other".into(), false, None).await.unwrap(); + f.assert_clean(OTHER); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn rollback_reloads_clean_open_documents_before_returning() { + let f = Fixture::new().await; + f.saved_edit(&format!("{ORIGINAL}\n")); + vcs::vcs_discard(&f.host, "main", vec![f.path.clone()]).await.unwrap(); + f.assert_clean(ORIGINAL); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn stash_and_unstash_reload_clean_open_documents_before_returning() { + let f = Fixture::new().await; + let edited = format!("{ORIGINAL}\n"); + f.saved_edit(&edited); + vcs::vcs_stash_push(&f.host, "main", None, None, false, false).await.unwrap(); + f.assert_clean(ORIGINAL); + vcs::vcs_stash_apply(&f.host, "main", None, 0, true, false, None).await.unwrap(); + f.assert_clean(&edited); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn applying_patch_reloads_clean_open_documents_before_returning() { + let f = Fixture::new().await; + std::fs::write(&f.path, OTHER).unwrap(); + let patch = git(&f.project.root, &["diff"]); + git(&f.project.root, &["restore", "."]); + vcs::vcs_apply_patch(&f.host, "main", None, patch).await.unwrap(); + f.assert_clean(OTHER); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn writing_patch_file_reloads_its_clean_open_document() { + let f = Fixture::new().await; + vcs::vcs_write_patch(&f.host, "main", f.path.clone(), OTHER.into()).await.unwrap(); + f.assert_clean(OTHER); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn directory_compare_overwrite_reloads_clean_open_documents() { + let f = Fixture::new().await; + let base = f.project.root.parent().unwrap(); + let source = base.join("source"); + let destination = base.join("destination"); + std::fs::create_dir_all(&source).unwrap(); + std::fs::create_dir_all(&destination).unwrap(); + std::fs::write(source.join("query.sql"), OTHER).unwrap(); + let path = destination.join("query.sql"); + std::fs::write(&path, ORIGINAL).unwrap(); + let doc = f.project.documents.open(&path).unwrap(); + ide_host::ops::fs::copy_merge(&f.host, "main", source, destination, "query.sql".into(), true).await.unwrap(); + let snapshot = f.project.documents.snapshot(doc.id).unwrap(); + assert_eq!(snapshot.text, OTHER); + assert!(!snapshot.dirty); + f.project.documents.save_all().unwrap(); + assert_eq!(std::fs::read_to_string(&path).unwrap(), OTHER); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn failed_working_tree_operation_synchronizes_partial_disk_changes() { + let f = Fixture::new().await; + let result: ide_core::IdeResult<()> = f.project.with_repo_recorded(None, Some("Git merge"), |_| { + std::fs::write(&f.path, OTHER).unwrap(); + Err(ide_core::IdeError::invalid("merge stopped with conflicts")) + }); + assert!(result.is_err()); + f.assert_clean(OTHER); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn working_tree_operation_reloads_same_size_same_timestamp_changes() { + let f = Fixture::new().await; + let modified = std::fs::metadata(&f.path).unwrap().modified().unwrap(); + let replacement = ORIGINAL.replace("LIMIT 10", "LIMIT 20"); + f.project + .with_repo_recorded(None, Some("Git checkout"), |_| { + std::fs::write(&f.path, &replacement).unwrap(); + std::fs::File::options() + .write(true) + .open(&f.path) + .unwrap() + .set_times(std::fs::FileTimes::new().set_modified(modified)) + .unwrap(); + Ok(()) + }) + .unwrap(); + f.assert_clean(&replacement); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn working_tree_operation_does_not_discard_unsaved_edits() { + let f = Fixture::new().await; + let version = f.project.documents.snapshot(f.doc).unwrap().version; + f.project + .documents + .edit(f.doc, version, ChangeSet { changes: vec![TextChange { from: 0, to: 0, insert: "-- local\n".into() }] }) + .unwrap(); + vcs::vcs_checkout(&f.host, "main", None, "other".into(), false, None).await.unwrap(); + let snapshot = f.project.documents.snapshot(f.doc).unwrap(); + assert_eq!(snapshot.text, format!("-- local\n{ORIGINAL}")); + assert!(snapshot.dirty); + assert_eq!(std::fs::read_to_string(&f.path).unwrap(), OTHER); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn adding_local_exclude_reloads_open_ignore_document() { + let f = Fixture::new().await; + let path = f.project.root.join(".git/info/exclude"); + let doc = f.project.documents.open(&path).unwrap(); + let ignored = f.project.root.join("generated.txt"); + std::fs::write(&ignored, "generated\n").unwrap(); + vcs::vcs_add_to_ignore(&f.host, "main", vec![ignored], ide_vcs::IgnoreDestination::LocalExclude).await.unwrap(); + let disk = std::fs::read_to_string(&path).unwrap(); + assert!(disk.contains("/generated.txt")); + assert_eq!(f.project.documents.snapshot(doc.id).unwrap().text, disk); +} + +#[cfg(unix)] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn commit_hook_reloads_clean_open_documents() { + use std::os::unix::fs::PermissionsExt; + let f = Fixture::new().await; + f.saved_edit(&format!("{ORIGINAL}\n")); + git(&f.project.root, &["add", "-u"]); + let hook = f.project.root.join(".git/hooks/pre-commit"); + std::fs::write(&hook, "#!/bin/sh\nprintf 'SELECT hook;' > node_modules/query.sql\n").unwrap(); + std::fs::set_permissions(&hook, std::fs::Permissions::from_mode(0o755)).unwrap(); + vcs::vcs_commit( + &f.host, + "main", + None, + "commit with hook".into(), + ide_vcs::CommitOptions { run_hooks: true, ..Default::default() }, + ) + .await + .unwrap(); + f.assert_clean("SELECT hook;"); +} + +#[cfg(unix)] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn worktree_checkout_hook_reloads_clean_open_documents() { + use std::os::unix::fs::PermissionsExt; + let f = Fixture::new().await; + let hook = f.project.root.join(".git/hooks/post-checkout"); + std::fs::write(&hook, "#!/bin/sh\nprintf 'SELECT hook;' > ../project/node_modules/query.sql\n").unwrap(); + std::fs::set_permissions(&hook, std::fs::Permissions::from_mode(0o755)).unwrap(); + let linked = f.project.root.parent().unwrap().join("linked"); + vcs::vcs_add_worktree(&f.host, "main", None, linked, "other".into(), None).await.unwrap(); + f.assert_clean("SELECT hook;"); +} diff --git a/crates/ide-workspace/src/documents.rs b/crates/ide-workspace/src/documents.rs index 8ea241afb..dbcee862b 100644 --- a/crates/ide-workspace/src/documents.rs +++ b/crates/ide-workspace/src/documents.rs @@ -821,6 +821,17 @@ impl Documents { /// Re-checks open file buffers, including files outside the watched roots. /// Call from a blocking worker; unchanged files only need their metadata. pub fn sync_from_disk(&self) { + self.sync_open_documents(false); + } + + /// Refreshes clean buffers after an IDE operation rewrites files. Such an + /// operation can preserve both file length and the timestamp's millisecond. + /// The open document's selected codec is retained. + pub fn sync_after_disk_change(&self) { + self.sync_open_documents(true); + } + + fn sync_open_documents(&self, force_clean: bool) { let slots: Vec = self.registry.lock().docs.values().map(|e| e.slot.clone()).collect(); let paths: Vec = slots .iter() @@ -830,13 +841,17 @@ impl Documents { .then(|| st.path.clone()) }) .collect(); - self.on_disk_changed(&paths); + self.reload_from_disk(&paths, force_clean); } /// Re-reads from disk after an external change. Clean documents reload /// silently; dirty ones are left alone and reported via `IdeEvent::Notify`. /// Changes matching our own last save are ignored. pub fn on_disk_changed(&self, paths: &[PathBuf]) { + self.reload_from_disk(paths, false); + } + + fn reload_from_disk(&self, paths: &[PathBuf], force_clean: bool) { for path in paths { let Some(id) = self.by_path(path) else { continue }; let result = self.with_doc(id, |st| { @@ -845,7 +860,7 @@ impl Documents { } let Ok(meta) = std::fs::metadata(&st.path) else { return Ok(()) }; let mtime = ide_vfs::mtime_ms(&meta); - if mtime == st.disk_mtime && meta.len() == st.disk_len { + if mtime == st.disk_mtime && meta.len() == st.disk_len && (!force_clean || st.dirty) { return Ok(()); } if st.dirty { @@ -863,9 +878,14 @@ impl Documents { }); return Ok(()); } - let defaults = self.file_defaults(); - let loaded = Self::load_with_defaults(&st.path, &defaults)?; - st.native_to_ascii = defaults.native_to_ascii_for(&st.path); + let loaded = if force_clean { + Self::load_as(&st.path, &st.encoding, st.native_to_ascii)? + } else { + let defaults = self.file_defaults(); + let loaded = Self::load_with_defaults(&st.path, &defaults)?; + st.native_to_ascii = defaults.native_to_ascii_for(&st.path); + loaded + }; st.disk_mtime = loaded.mtime_ms; st.disk_len = disk_len(&st.path); if loaded.binary || st.binary { diff --git a/crates/ide-workspace/tests/documents.rs b/crates/ide-workspace/tests/documents.rs index 02370eed5..16670ce1e 100644 --- a/crates/ide-workspace/tests/documents.rs +++ b/crates/ide-workspace/tests/documents.rs @@ -57,6 +57,37 @@ fn fixture() -> Fixture { Fixture { _tmp: tmp, root, docs, events, rec } } +#[test] +fn own_disk_changes_preserve_the_open_documents_encoding() { + let f = fixture(); + let path = f.root.join("query.sql"); + let original = "SELECT '中文';\r\n"; + fs::write(&path, ide_vfs::encode(original, "gbk", false).unwrap()).unwrap(); + let mut defaults = f.docs.file_defaults(); + defaults.project_fallback = "gbk".into(); + f.docs.set_file_defaults(defaults.clone()); + let doc = f.docs.open(&path).unwrap(); + assert_eq!(doc.encoding, "gbk"); + defaults.project_fallback = "windows-1252".into(); + f.docs.set_file_defaults(defaults); + f.docs.sync_after_disk_change(); + let unchanged = f.docs.snapshot(doc.id).unwrap(); + assert_eq!(unchanged.text, doc.text); + assert_eq!(unchanged.encoding, "gbk"); + assert!(!unchanged.dirty); + let changed = "SELECT '世界';\r\n"; + let bytes = ide_vfs::encode(changed, "gbk", false).unwrap(); + fs::write(&path, &bytes).unwrap(); + f.docs.sync_after_disk_change(); + let reloaded = f.docs.snapshot(doc.id).unwrap(); + assert_eq!(reloaded.text, changed.replace("\r\n", "\n")); + assert_eq!(reloaded.encoding, "gbk"); + assert_eq!(reloaded.line_ending, LineEnding::CrLf); + assert!(!reloaded.dirty); + f.docs.save_all().unwrap(); + assert_eq!(fs::read(&path).unwrap(), bytes); +} + fn change(from: u32, to: u32, insert: &str) -> ChangeSet { ChangeSet { changes: vec![TextChange { from, to, insert: insert.into() }] } } diff --git a/src/editor/api.ts b/src/editor/api.ts index 671f0d9bc..fb5554b03 100644 --- a/src/editor/api.ts +++ b/src/editor/api.ts @@ -433,7 +433,7 @@ export function _emitEditorEvent(e: EditorEvent) { // --- save participants ---------------------------------------------------------- /** - * Runs before a document is written (format on save, organise imports…). + * Runs before a modified document is written (format on save, organise imports…). * Participants may edit the document through `handle.view` (normal * transactions); editor/core waits for them, then trims whitespace / adds the * final newline per settings, flushes the edit pipeline and saves. `handle` diff --git a/src/editor/core/model/document.test.ts b/src/editor/core/model/document.test.ts index 6ee9a7d91..1b066027e 100644 --- a/src/editor/core/model/document.test.ts +++ b/src/editor/core/model/document.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { EditorState } from "@codemirror/state"; import { language as languageFacet } from "@codemirror/language"; -import { remoteChange } from "@/editor/api"; +import { registerSaveParticipant, remoteChange } from "@/editor/api"; import { createMemoryBackend, type MemoryBackend } from "../backend"; import { _resetLanguages } from "../cm/languages"; import { DocumentManager, ModifiedRanges, saveCleanupChanges, useDocMeta } from "./document"; @@ -88,6 +88,60 @@ describe("DocumentManager + slots", () => { expect(useDocMeta.getState()["/p/a.txt"]?.dirty).toBe(false); }); + it.each(["explicit", "auto"] as const)( + "%s save leaves untouched documents byte-for-byte unchanged", + async (reason) => { + const files = { + "/p/src/main/resources/sql/no-final.sql": "SELECT *\nFROM patients\n\tLIMIT 10 OFFSET 0", + "/p/trailing.sql": "SELECT id \nFROM patients;\t \n", + "/p/crlf.sql": "SELECT id\r\nFROM patients;\r\n", + "/p/Untouched.java": "class Untouched { \n\tint id;\n}", + "/p/blank.sql": "SELECT 1;\n\n\n", + "/p/edited.java": "class Edited {}\n", + }; + const { backend, manager } = setup(files, { + editor: { general: { stripTrailingScope: "all", removeTrailingBlankLines: true } }, + }); + const participant = vi.fn(); + const unregister = registerSaveParticipant({ id: "test.save", beforeSave: participant }); + const save = vi.spyOn(backend, "save"); + try { + const slots = []; + for (const path of Object.keys(files)) slots.push(new EditorSlot(await manager.acquire(path), path, host)); + const edited = slots.at(-1)!; + edited.dispatchLocal({ changes: { from: 0, insert: "// edited\n" } }); + await manager.saveAll(reason); + for (const [path, text] of Object.entries(files)) { + if (path !== "/p/edited.java") expect(backend.files.get(path), path).toBe(text); + } + expect(backend.files.get("/p/edited.java")).toBe("// edited\nclass Edited {}\n"); + expect(save.mock.calls).toEqual([[edited.model.doc]]); + expect(participant).toHaveBeenCalledTimes(reason === "explicit" ? 1 : 0); + if (reason === "explicit") expect(participant.mock.calls[0]![0].path).toBe("/p/edited.java"); + await manager.saveAll(reason); + expect(save).toHaveBeenCalledTimes(1); + } finally { + unregister(); + manager.stop(); + } + }, + ); + + it("checks dirty state when each queued save starts", async () => { + const { backend, manager } = setup({ "/p/query.sql": "SELECT 1" }); + const model = await manager.acquire("/p/query.sql"); + const slot = new EditorSlot(model, "t1", host); + slot.dispatchLocal({ changes: { from: 7, to: 8, insert: "2" } }); + const save = vi.spyOn(backend, "save"); + try { + await Promise.all([model.save("explicit"), model.save("explicit"), model.save("auto")]); + expect(backend.files.get(model.path)).toBe("SELECT 2\n"); + expect(save).toHaveBeenCalledTimes(1); + } finally { + manager.stop(); + } + }); + it("closes the backend document when the last slot goes away", async () => { const { backend, manager } = setup({ "/p/a.txt": "x" }); const model = await manager.acquire("/p/a.txt"); diff --git a/src/editor/core/model/document.ts b/src/editor/core/model/document.ts index 9c70e8f61..a6bd1f182 100644 --- a/src/editor/core/model/document.ts +++ b/src/editor/core/model/document.ts @@ -569,8 +569,7 @@ export class DocModel { save(reason: "explicit" | "auto"): Promise { const run = async () => { - if (this.closed || this.meta.readOnly) return; - if (reason === "auto" && !this.meta.dirty) return; + if (this.closed || this.meta.readOnly || !this.meta.dirty) return; const slot = this.primarySlot(); if (reason === "explicit") { for (const p of _saveParticipants()) { @@ -764,7 +763,7 @@ export class DocumentManager { for (const m of this.byPath.values()) m.applySettings(); } - /** Saves every document; failures are notified, and an explicit Save All also rejects with them. */ + /** Saves dirty documents; failures are notified, and an explicit Save All also rejects with them. */ async saveAll(reason: "explicit" | "auto") { const errors: unknown[] = []; await Promise.all( From 416b65d65e7eb45406e42a1011cb4f8d86c1fb9b Mon Sep 17 00:00:00 2001 From: arookieofc <2128194521@qq.com> Date: Tue, 3 Nov 2026 08:31:19 +0800 Subject: [PATCH 045/106] test(editor): make the detach-tab save-failure document dirty (#924) Since #920, saves skip clean documents, so the detach-tab save-failure test never reached its mocked failing save. The test now edits the document first, keeping its intent: a failing save keeps the tab and opens no window. Co-authored-by: a-rookie-of-C-language <186315048+a-rookie-of-C-language@users.noreply.github.com> --- src/editor/core/detachTab.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/editor/core/detachTab.test.ts b/src/editor/core/detachTab.test.ts index e919c6dd0..7786c23db 100644 --- a/src/editor/core/detachTab.test.ts +++ b/src/editor/core/detachTab.test.ts @@ -82,6 +82,9 @@ describe("detachTab", () => { ctrl.start(); await ctrl.openFile(path, { focus: false }); const [pane, id] = current(); + // Clean documents skip saving entirely, so the document must be dirty for + // the mocked save failure to be reached. + ctrl.slotFor(id)!.dispatchLocal({ changes: { from: 0, to: 8, insert: "edited" } }); vi.spyOn(backend, "save").mockRejectedValue(new Error("save failed")); expect(await ctrl.detachTab(pane, id)).toBe(false); expect(hasTab(id)).toBe(true); From 93712a1588d0b5dc3b4cd30c359a3a67832a2bcc Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Tue, 3 Nov 2026 09:39:11 +0800 Subject: [PATCH 046/106] chore: add bug report and feature request issue forms (#878) Structured issue forms for bug reports and feature requests: environment, steps, expected/actual behaviour and logs for bugs; problem, proposal and alternatives for features. Both ask reporters to search existing issues first and keep one problem per issue; blank issues are disabled and the latest release is linked from the chooser. Forms do not require a particular language. Refs #860 --- .github/ISSUE_TEMPLATE/bug_report.yml | 69 ++++++++++++++++++++++ .github/ISSUE_TEMPLATE/config.yml | 5 ++ .github/ISSUE_TEMPLATE/feature_request.yml | 38 ++++++++++++ 3 files changed, 112 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/feature_request.yml diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 000000000..b7008354c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,69 @@ +name: Bug report +description: Something in OpenIDEA doesn't work as expected +labels: [bug] +body: + - type: markdown + attributes: + value: | + Thanks for reporting! Keep logs and error messages exactly as they appear. + + One problem per issue, please. If you have several, open one issue for each. + - type: checkboxes + id: checks + attributes: + label: Before you start + options: + - label: I searched [existing issues](https://github.com/l0ng-ai/openidea/issues?q=is%3Aissue) and this hasn't been reported yet. + required: true + - label: I'm on the [latest release](https://github.com/l0ng-ai/openidea/releases/latest), or I noted below that I can't update. + required: true + - type: textarea + id: environment + attributes: + label: Environment + description: | + Open **About OpenIDEA** (macOS: OpenIDEA menu; Windows/Linux: Help menu), click **Copy Info**, and paste it here. Also mention the project type if it matters (e.g. Gradle multi-module, Spring Boot, Go workspace) and whether you use remote development. + render: text + placeholder: | + About OpenIDEA + Version: 0.1.33 + Build: ... + Platform: macOS aarch64 + validations: + required: true + - type: textarea + id: steps + attributes: + label: Steps to reproduce + description: Start from opening OpenIDEA or a project. A small sample project or snippet helps a lot. + placeholder: | + 1. Open a Gradle project whose settings.gradle includes modules in a loop + 2. Open the Gradle tool window + 3. ... + validations: + required: true + - type: textarea + id: expected + attributes: + label: Expected behavior + validations: + required: true + - type: textarea + id: actual + attributes: + label: Actual behavior + description: What happened instead. Screenshots or a recording are welcome. + validations: + required: true + - type: textarea + id: logs + attributes: + label: Logs + description: | + Use **Help → Show Log in Finder / Explorer / Files** to find `openidea.log` and paste the lines around the problem, or attach the file. Include console output or error messages too. Check for tokens or passwords before posting. + render: text + - type: textarea + id: extra + attributes: + label: Additional context + description: Anything else, such as whether it worked in an earlier version. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 000000000..c6bb3f1ad --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Download the latest release + url: https://github.com/l0ng-ai/openidea/releases/latest + about: Many problems are already fixed. Please check the latest version before reporting. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 000000000..bc9ad7844 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,38 @@ +name: Feature request +description: Suggest a new feature or an improvement +labels: [enhancement] +body: + - type: markdown + attributes: + value: | + One request per issue, please. + - type: checkboxes + id: checks + attributes: + label: Before you start + options: + - label: I searched [existing issues](https://github.com/l0ng-ai/openidea/issues?q=is%3Aissue) and this hasn't been requested yet. + required: true + - type: textarea + id: problem + attributes: + label: Problem + description: What are you trying to do, and what gets in the way today? + validations: + required: true + - type: textarea + id: proposal + attributes: + label: Proposed solution + description: How you'd like it to work. Mockups or examples from other tools are welcome. + validations: + required: true + - type: textarea + id: alternatives + attributes: + label: Alternatives and workarounds + description: Other approaches you considered, or what you do now instead. + - type: textarea + id: extra + attributes: + label: Additional context From 4f64ca3149c0c91a0c8c92060c20ef50dad5139f Mon Sep 17 00:00:00 2001 From: Deng Liu Date: Mon, 2 Nov 2026 18:10:31 -0800 Subject: [PATCH 047/106] feat(agents): enable 1-click ACP registry install and updates for GitHub Copilot (#773) GitHub Copilot, Qwen Code and Mistral Vibe were listed in the ACP Registry under their registry ids (github-copilot-cli, qwen-code, mistral-vibe), so they showed as unrelated third-party agents and could not be installed over the built-in providers. Registry ids now map to the built-in ids: the rows use the built-in name and icon, install and update into /agents/.toml, show "x.y.z available" when the registry has a newer version, and fall back to the built-in command on PATH after Remove. Installing never overwrites a hand-written agent config, and Remove only deletes a registry install. Changing installed providers reloads them and drops stale probes, so New Task sees the installed version right away. Co-authored-by: dengliu <5108773+dengliu@users.noreply.github.com> --- crates/ide-agent/src/lib.rs | 17 ++++- crates/ide-agent/src/model.rs | 2 + crates/ide-agent/src/provider.rs | 18 +++-- crates/ide-agent/src/registry.rs | 80 +++++++++++++++++++++- crates/ide-agent/src/tests.rs | 75 ++++++++++++++++++++ crates/ide-host/src/ops/agent.rs | 12 +++- src/bindings.ts | 4 +- src/features/agents/AgentRegistryPage.tsx | 4 +- src/features/agents/HomeEditor.tsx | 7 +- src/features/agents/agentRegistry.test.tsx | 15 +++- src/features/agents/registry.ts | 4 ++ src/features/agents/store.ts | 4 ++ 12 files changed, 226 insertions(+), 16 deletions(-) diff --git a/crates/ide-agent/src/lib.rs b/crates/ide-agent/src/lib.rs index f2fb12a57..4f61c8e0e 100644 --- a/crates/ide-agent/src/lib.rs +++ b/crates/ide-agent/src/lib.rs @@ -314,10 +314,25 @@ impl AgentManager { source: p.source.clone(), }) .collect(); - *self.shared.providers.write() = specs; + let mut prev = self.shared.providers.write(); + let mut probes = self.shared.probes.lock(); + probes.retain(|id, _| { + let old = prev.iter().find(|p| &p.id == id); + let new = specs.iter().find(|p| &p.id == id); + matches!((old, new), (Some(a), Some(b)) if a.command == b.command && a.args == b.args && a.env == b.env) + }); + *prev = specs; list } + /// Re-reads configured/installed agents, drops cached probes, and notifies subscribers. + pub fn reload_providers(&self) { + let specs = provider::load(self.shared.paths.custom_agents_dir.as_deref()); + *self.shared.providers.write() = specs; + self.shared.probes.lock().clear(); + self.shared.emit(AgentEvent::Providers); + } + /// The slash commands to complete for `provider`, in `task`'s directory or (without one) /// in the project: what the agent offered last time there, unadvertised builtins, /// and, for Claude Code, skills and custom commands on disk it has not listed. diff --git a/crates/ide-agent/src/model.rs b/crates/ide-agent/src/model.rs index 67b2b6194..1ba50ae89 100644 --- a/crates/ide-agent/src/model.rs +++ b/crates/ide-agent/src/model.rs @@ -413,4 +413,6 @@ pub enum AgentEvent { index: u32, entry: AgentEntry, }, + /// The installed or configured agent providers changed. + Providers, } diff --git a/crates/ide-agent/src/provider.rs b/crates/ide-agent/src/provider.rs index 47902e0ff..176087045 100644 --- a/crates/ide-agent/src/provider.rs +++ b/crates/ide-agent/src/provider.rs @@ -80,9 +80,16 @@ fn builtins() -> Vec { .collect() } -/// Whether `id` names a built-in agent. +/// Whether `id` names a built-in agent that needs no registry install (native drivers +/// and `npx -y` adapters). CLI built-ins (`copilot`, `gemini`, …) can be installed and +/// updated from the ACP Registry into `/agents/.toml`. pub(crate) fn is_builtin(id: &str) -> bool { - BUILTIN.iter().any(|b| b.0 == id) + BUILTIN.iter().any(|&(bid, _, driver, cmd, _)| bid == id && (driver != Driver::Acp || cmd.first() == Some(&"npx"))) +} + +/// Display name of a built-in agent, when `id` is one. +pub(crate) fn builtin_name(id: &str) -> Option<&'static str> { + BUILTIN.iter().find(|&&(bid, _, _, _, _)| bid == id).map(|&(_, name, _, _, _)| name) } /// An agent file, `/agents/.toml`. @@ -162,7 +169,7 @@ pub(crate) fn load(custom_dir: Option<&Path>) -> Vec { Some(r) => AgentSource::Registry { version: r.version, update: None }, None => AgentSource::Custom, }; - let spec = ProviderSpec { + let mut spec = ProviderSpec { name: agent.name.unwrap_or_else(|| id.clone()), driver: agent.driver, command: agent.command, @@ -173,7 +180,10 @@ pub(crate) fn load(custom_dir: Option<&Path>) -> Vec { id, }; match specs.iter_mut().find(|s| s.id == spec.id) { - Some(existing) => *existing = spec, + Some(existing) => { + spec.login_hint = existing.login_hint.clone(); + *existing = spec; + } None => specs.push(spec), } } diff --git a/crates/ide-agent/src/registry.rs b/crates/ide-agent/src/registry.rs index 26bf31f3f..1c22007db 100644 --- a/crates/ide-agent/src/registry.rs +++ b/crates/ide-agent/src/registry.rs @@ -165,6 +165,13 @@ fn parse(text: &str) -> IdeResult> { .into_iter() .filter_map(|v| serde_json::from_value::(v).ok()) .filter(|e| safe_segment(&e.id) && safe_segment(&e.version)) + .map(|mut e| { + e.id = canonical_id(&e.id).to_owned(); + if let Some(name) = provider::builtin_name(&e.id) { + e.name = name.to_owned(); + } + e + }) .collect()) } @@ -232,11 +239,16 @@ fn choose<'a>(entry: &'a Entry, platform: Platform, on_path: &dyn Fn(&str) -> bo None } -/// Registry agents that are built-in ones under another id. -const BUILTIN_ALIASES: &[&str] = &["github-copilot-cli", "qwen-code", "mistral-vibe"]; +/// Registry ids that map to a shorter built-in provider id (`/agents/.toml`). +const BUILTIN_ALIASES: &[(&str, &str)] = + &[("github-copilot-cli", "copilot"), ("qwen-code", "qwen"), ("mistral-vibe", "vibe")]; + +fn canonical_id(id: &str) -> &str { + BUILTIN_ALIASES.iter().find_map(|&(alias, canonical)| (alias == id).then_some(canonical)).unwrap_or(id) +} fn is_builtin(id: &str) -> bool { - provider::is_builtin(id) || BUILTIN_ALIASES.contains(&id) + provider::is_builtin(canonical_id(id)) } fn on_path(command: &str) -> bool { @@ -387,6 +399,7 @@ fn write_atomic(path: &Path, bytes: &[u8]) -> std::io::Result<()> { /// Installs (or updates to) the registry's current version of `id`, streaming progress /// through `emit`, and points the agent file at it. pub fn install(paths: &RegistryPaths, id: &str, cancel: &CancellationToken, emit: Emit) -> IdeResult<()> { + let id = canonical_id(id); // The saved index is what the user picked from; fetch only when there is none. let text = match fs::read_to_string(&paths.cache_file) { Ok(text) => text, @@ -618,6 +631,7 @@ fn install_uv( /// Deletes a registry agent's file and installed files. pub fn remove(paths: &RegistryPaths, id: &str) -> IdeResult<()> { + let id = canonical_id(id); if !safe_segment(id) { return Err(IdeError::invalid(format!("invalid agent id {id}"))); } @@ -946,4 +960,64 @@ mod tests { assert!(remove(&p, "amp-acp").is_err()); assert!(p.agent_file("amp-acp").exists()); } + + #[test] + fn maps_github_copilot_cli_to_builtin_copilot_and_tracks_updates() { + let dir = tempfile::tempdir().unwrap(); + let p = paths(dir.path()); + let index = r#"{"agents":[ + {"id":"github-copilot-cli","name":"Copilot","version":"1.0.105", + "description":"GitHub's AI pair programmer", + "distribution":{"npx":{"package":"@github/copilot@1.0.105","args":["--acp"]}}} + ]}"#; + let entries = parse(index).unwrap(); + assert_eq!(entries.len(), 1); + assert_eq!((entries[0].id.as_str(), entries[0].name.as_str()), ("copilot", "GitHub Copilot")); + + let shown = visible(&p, &entries, MAC_ARM, &|c| c == "npm" || c == "node"); + assert_eq!(shown.len(), 1); + assert_eq!( + (shown[0].id.as_str(), shown[0].name.as_str(), shown[0].installed.as_deref()), + ("copilot", "GitHub Copilot", None) + ); + + CustomAgent { + name: Some("GitHub Copilot".into()), + driver: Driver::Acp, + command: "/data/tools/copilot/1.0.104/node_modules/.bin/copilot".into(), + args: vec!["--acp".into()], + env: BTreeMap::new(), + registry: Some(RegistryRef { id: "copilot".into(), version: "1.0.104".into() }), + } + .write(&p.agent_file("copilot")) + .unwrap(); + + let shown = visible(&p, &entries, MAC_ARM, &|c| c == "npm" || c == "node"); + assert_eq!((shown[0].installed.as_deref(), shown[0].update), (Some("1.0.104"), true)); + + write_atomic(&p.cache_file, index.as_bytes()).unwrap(); + let spec = provider::load(Some(&p.agents_dir)).into_iter().find(|s| s.id == "copilot").unwrap(); + assert_eq!(spec.name, "GitHub Copilot"); + assert_eq!(spec.login_hint, "Run `copilot` in a terminal and sign in."); + assert_eq!(spec.source, AgentSource::Registry { version: "1.0.104".into(), update: None }); + + let mut providers = vec![AgentProvider { + id: spec.id, + name: spec.name, + command: spec.command, + installed: true, + config: vec![], + commands: vec![], + source: spec.source, + }]; + mark_updates(&p, &mut providers); + assert_eq!( + providers[0].source, + AgentSource::Registry { version: "1.0.104".into(), update: Some("1.0.105".into()) } + ); + + remove(&p, "github-copilot-cli").unwrap(); + let fallback = provider::load(Some(&p.agents_dir)).into_iter().find(|s| s.id == "copilot").unwrap(); + assert_eq!(fallback.source, AgentSource::Builtin); + } } diff --git a/crates/ide-agent/src/tests.rs b/crates/ide-agent/src/tests.rs index b46251971..6bd3ea2ba 100644 --- a/crates/ide-agent/src/tests.rs +++ b/crates/ide-agent/src/tests.rs @@ -942,3 +942,78 @@ async fn task_project_root_controls_local_and_worktree_repositories() { } assert_eq!(git(&["worktree", "list", "--porcelain"]).matches("worktree ").count(), 1); } + +#[tokio::test(flavor = "multi_thread")] +async fn reload_providers_updates_registry_install_clears_stale_probes_and_notifies_subscribers() { + let tmp = tempfile::tempdir().unwrap(); + let agents_dir = tmp.path().join("agents"); + std::fs::create_dir_all(&agents_dir).unwrap(); + let fake_bin = tmp.path().join(if cfg!(windows) { "copilot.cmd" } else { "copilot" }); + std::fs::write(&fake_bin, "").unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&fake_bin, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + + let agent = Arc::new(FakeAgent::default()); + let connector: Connector = { + let agent = agent.clone(); + Arc::new(move |_spec, _cwd, client, _stderr| { + let (a, b) = duplex(1 << 20); + let (ar, aw) = tokio::io::split(a); + let (br, bw) = tokio::io::split(b); + let server = Peer::connect_framed(br, bw, agent.clone(), Framing::Lines); + agent.servers.lock().push(server); + Ok(Peer::connect_framed(ar, aw, client, Framing::Lines)) + }) + }; + let mgr = AgentManager::with_connector( + AgentPaths { + project_root: tmp.path().to_path_buf(), + store_dir: tmp.path().join("store"), + worktrees_dir: tmp.path().join("worktrees"), + models_file: tmp.path().join("models.json"), + custom_agents_dir: Some(agents_dir.clone()), + home_dir: None, + }, + tokio::runtime::Handle::current(), + connector, + ); + + // Seed a stale failed probe for copilot before installing its custom TOML. + mgr.shared.probes.lock().insert( + "copilot".into(), + AgentProbe { + provider: "copilot".into(), + name: None, + version: None, + protocol: "ACP".into(), + capabilities: AgentCapabilities::default(), + auth_methods: Vec::new(), + error: Some("copilot --acp was not found on PATH.".into()), + }, + ); + + let events = Arc::new(Mutex::new(Vec::new())); + let events_sink = events.clone(); + mgr.subscribe(Arc::new(move |ev| events_sink.lock().push(ev))); + + let toml = format!( + "name = \"GitHub Copilot\"\ncommand = {:?}\nargs = [\"--acp\", \"--stdio\"]\n", + fake_bin.to_string_lossy() + ); + std::fs::write(agents_dir.join("copilot.toml"), toml).unwrap(); + + mgr.reload_providers(); + + assert!(events.lock().iter().any(|ev| matches!(ev, AgentEvent::Providers))); + assert!(!mgr.shared.probes.lock().contains_key("copilot")); + + let copilot = mgr.providers().into_iter().find(|p| p.id == "copilot").unwrap(); + assert!(copilot.installed); + assert!(copilot.command.contains(&*fake_bin.to_string_lossy())); + + let probe = mgr.probe("copilot", false).await.unwrap(); + assert!(probe.error.is_none()); +} diff --git a/crates/ide-host/src/ops/agent.rs b/crates/ide-host/src/ops/agent.rs index f72d603cb..5c16f2705 100644 --- a/crates/ide-host/src/ops/agent.rs +++ b/crates/ide-host/src/ops/agent.rs @@ -63,13 +63,21 @@ pub async fn registry_install( .await .map_err(join_err); host.end_request(request); - result? + result??; + for project in host.projects() { + project.agents.reload_providers(); + } + Ok(()) } /// Removes a registry agent: its agent file and installed files. pub async fn registry_remove(host: &Host, _window: &str, id: String) -> IdeResult<()> { let paths = registry_paths(host); - tokio::task::spawn_blocking(move || ide_agent::registry::remove(&paths, &id)).await.map_err(join_err)? + tokio::task::spawn_blocking(move || ide_agent::registry::remove(&paths, &id)).await.map_err(join_err)??; + for project in host.projects() { + project.agents.reload_providers(); + } + Ok(()) } /// Slash commands to complete for `provider` in `task`'s directory, or in the project diff --git a/src/bindings.ts b/src/bindings.ts index 22f176d2f..13367ac86 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -1216,7 +1216,9 @@ export type AgentEvent = /** The task's conversation was cut to its first `length` entries (turns rolled back). */ { type: "truncated"; task: string; length: number } | /** Entry `index` of `task` (of its subagent `subagent`) was added or replaced. */ -{ type: "entry"; task: string; subagent: string | null; index: number; entry: AgentEntry }; +{ type: "entry"; task: string; subagent: string | null; index: number; entry: AgentEntry } | +/** The installed or configured agent providers changed. */ +{ type: "providers" }; export type AgentImage = { mimeType: string, diff --git a/src/features/agents/AgentRegistryPage.tsx b/src/features/agents/AgentRegistryPage.tsx index a77a67ea7..e03417900 100644 --- a/src/features/agents/AgentRegistryPage.tsx +++ b/src/features/agents/AgentRegistryPage.tsx @@ -8,8 +8,9 @@ import type { AgentRegistry, RegistryAgent, RequestId } from "@/bindings"; import { isCancelled, nextRequestId } from "@/platform/ipc"; import { tx } from "@/platform/i18n"; import { openExternalUrl } from "@/platform/openUrl"; -import { Badge, Button, ProgressBar, SearchField, Spinner, T } from "@/ui"; +import { Badge, Button, Icon, ProgressBar, SearchField, Spinner, T } from "@/ui"; import { confirm } from "@/workbench/messages"; +import { providerIcon } from "./model"; import { DISTRIBUTION_LABEL, afterInstall, @@ -213,6 +214,7 @@ function AgentRow({
  • + {agent.name} diff --git a/src/features/agents/HomeEditor.tsx b/src/features/agents/HomeEditor.tsx index 3ff76b005..e12ef1e29 100644 --- a/src/features/agents/HomeEditor.tsx +++ b/src/features/agents/HomeEditor.tsx @@ -45,6 +45,7 @@ export function HomeEditor({ docked = false }: { docked?: boolean; input?: Edito // Agents installed in Settings (its own window) show up when this one is back in front. useEffect(() => { const refresh = () => void refreshProviders(); + refresh(); window.addEventListener("focus", refresh); return () => window.removeEventListener("focus", refresh); }, []); @@ -66,7 +67,7 @@ export function HomeEditor({ docked = false }: { docked?: boolean; input?: Edito }, [root, project?.workspace]); const provider = providers.find((p) => p.id === draft.provider) ?? providers[0]; - const probe = useProbe(provider?.installed ? provider.id : null); + const probe = useProbe(provider?.installed ? provider.id : null, provider?.command); const agentCommands = useAgentCommands(provider?.id, null, provider?.commands ?? NO_COMMANDS); const providerOptions: SelectOption[] = agentOptions(providers, tx, providerIcon); // Without git under the project there are no worktrees: force the local checkout. @@ -224,7 +225,7 @@ export function HomeEditor({ docked = false }: { docked?: boolean; input?: Edito } /** What the agent reports about itself when it starts (no session is opened), once per agent. */ -function useProbe(provider: string | null): AgentProbe | null { +function useProbe(provider: string | null, command?: string): AgentProbe | null { const [probe, setProbe] = useState(null); useEffect(() => { if (!provider) return; @@ -235,6 +236,6 @@ function useProbe(provider: string | null): AgentProbe | null { return () => { live = false; }; - }, [provider]); + }, [provider, command]); return probe?.provider === provider ? probe : null; } diff --git a/src/features/agents/agentRegistry.test.tsx b/src/features/agents/agentRegistry.test.tsx index f1b88af84..f9c2bc8ed 100644 --- a/src/features/agents/agentRegistry.test.tsx +++ b/src/features/agents/agentRegistry.test.tsx @@ -3,6 +3,7 @@ import { act, fireEvent, render, screen, waitFor, within } from "@testing-librar import type { AgentProvider, AgentRegistry, InstallEvent, RegistryAgent, RequestId } from "@/bindings"; import { IpcError } from "@/platform/ipc"; import { tx } from "@/platform/i18n"; +import { iconMarkup } from "@/ui/icons"; import { MessagesHost } from "@/workbench/messages"; import { AgentRegistryPage } from "./AgentRegistryPage"; import { ADD_AGENT, providerOptions } from "./providerOptions"; @@ -96,9 +97,10 @@ describe("registry helpers", () => { named("zed", "Zed"), named("cline", "Cline"), named("amp-acp", "Amp"), + named("copilot", "GitHub Copilot"), named("bob", "Bob", "2.0.9"), ]); - expect(sorted.map((a) => a.name)).toEqual(["Bob", "Amp", "Cline", "Agoragentic", "Zed"]); + expect(sorted.map((a) => a.name)).toEqual(["Bob", "GitHub Copilot", "Amp", "Cline", "Agoragentic", "Zed"]); }); it("drops a removed agent this system cannot install again", () => { @@ -141,6 +143,17 @@ describe("agent picker", () => { }); describe("Agents settings page", () => { + it("shows the built-in Copilot name and icon in its registry row", async () => { + const backend = new FakeBackend(); + backend.registry = { agents: [agent({ id: "copilot", name: "GitHub Copilot" })], warning: null }; + render(); + const copilot = await screen.findByRole("listitem", { name: "GitHub Copilot" }); + const expected = document.createElement("div"); + expected.innerHTML = iconMarkup("agent-copilot")!; + expect(copilot.querySelector("svg")?.innerHTML).toBe(expected.querySelector("svg")?.innerHTML); + expect(copilot.querySelector("svg")).toHaveAttribute("aria-hidden", "true"); + }); + it("lists registry agents, says they are third-party, and searches them", async () => { const backend = new FakeBackend(); render(); diff --git a/src/features/agents/registry.ts b/src/features/agents/registry.ts index eb41e7680..f71772ea8 100644 --- a/src/features/agents/registry.ts +++ b/src/features/agents/registry.ts @@ -63,6 +63,10 @@ export function afterRemove(agents: RegistryAgent[], id: string): RegistryAgent[ * no popularity data and lists alphabetically, which puts small or unrelated projects on top. */ const FEATURED = [ + "copilot", + "gemini", + "cursor", + "opencode", "amp-acp", "devin", "factory-droid", diff --git a/src/features/agents/store.ts b/src/features/agents/store.ts index c2810686f..b1b54f3e3 100644 --- a/src/features/agents/store.ts +++ b/src/features/agents/store.ts @@ -257,6 +257,10 @@ function onEvent(e: AgentEvent) { scheduleChanges(e.task); break; } + case "providers": { + void refreshProviders(); + break; + } case "entry": { if ( !e.subagent && From 00e717d8fdba4bf58f8f53ea9934be893a677cd9 Mon Sep 17 00:00:00 2001 From: arookieofc <2128194521@qq.com> Date: Tue, 3 Nov 2026 10:18:39 +0800 Subject: [PATCH 048/106] feat(vcs): diff preview in the Git window's Local Changes tab (#714) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Local Changes tab of the Git tool window showed only the changes tree; a file's diff was visible only after opening a diff tab. It now previews the selected file's working-tree diff beside the tree when the window is docked at the bottom and below it when docked on a side. The preview follows the selection (arrow keys included), shows a placeholder for no or multiple selection, and can be turned off with View Options › Show Diff Preview (persisted; the selection is kept when toggling). The preview reuses the diff editor, whose toolbar did not fit a side-docked window: the editor is now a size container, the whitespace and highlighting pickers move to the settings menu below 640px and the separators go below 380px, buttons no longer shrink and the differences counter truncates. Fixes #710. Co-authored-by: a-rookie-of-C-language <186315048+a-rookie-of-C-language@users.noreply.github.com> --- src/features/vcs/CommitView.tsx | 44 +++++- src/features/vcs/DiffEditor.module.css | 21 ++- src/features/vcs/GitLog.tsx | 4 +- src/features/vcs/__tests__/diffFind.test.tsx | 19 +++ .../vcs/__tests__/localChanges.test.tsx | 130 ++++++++++++++++++ src/features/vcs/commitStore.ts | 12 ++ src/platform/locales/text/zh.json | 1 + 7 files changed, 226 insertions(+), 5 deletions(-) create mode 100644 src/features/vcs/__tests__/localChanges.test.tsx diff --git a/src/features/vcs/CommitView.tsx b/src/features/vcs/CommitView.tsx index f9019a0a3..b809529d6 100644 --- a/src/features/vcs/CommitView.tsx +++ b/src/features/vcs/CommitView.tsx @@ -32,6 +32,7 @@ import { T, } from "@/ui"; import { editorService } from "@/workbench/contributions"; +import { useToolWindowLayout } from "@/workbench/toolwindows/store"; import { aggregateCheck, buildChangeTree, @@ -47,10 +48,11 @@ import { applyStash, commitChanges, dropStash, showUnstashDialog, stashChanges } import { CommitOptionsPopup } from "./CommitOptions"; import { DEFAULT_LIST_NAME, useChangelists, withListDefaults } from "./changelists"; import { moveToChangelist } from "./changelistsUi"; -import { messageHistory, setAmend, setGroupByDir, useCommit, type CommitTab } from "./commitStore"; +import { messageHistory, setAmend, setDiffPreview, setGroupByDir, useCommit, type CommitTab } from "./commitStore"; import { pushAfterCommit } from "./commitUi"; import { stashTabOf, stashTabs, type ContentTab } from "./gitTabs"; -import { openDiff, withChain, workingDiffData } from "./diffInput"; +import { diffInput, openDiff, withChain, workingDiffData } from "./diffInput"; +import { DiffEditor } from "./DiffEditor"; import { KIND_COLOR, KIND_LABEL, fileCount, formatTime, stashLabel, stripSlash } from "./model"; import { call, commands } from "@/platform/ipc"; import type { FileStatus } from "@/bindings"; @@ -344,6 +346,37 @@ function ChangesPanel() { ); } +/** + * The Git window's Local Changes tab: a diff preview beside the tree when + * bottom-docked, below it when side-docked (off via View Options › Show Diff Preview). + */ +export function LocalChangesView() { + const preview = useCommit((s) => s.diffPreview); + const path = useCommit((s) => (s.selection.length === 1 ? s.selection[0] : null)); + const anchor = useToolWindowLayout((s) => s.placements.git?.anchor ?? "bottom"); + return ( + + + + + {preview && ( + <> + + + {path ? ( + + ) : ( + + Select a file to preview its changes + + )} + + + )} + + ); +} + /** * The change tree with its toolbar. `commit`: the Commit tool window's (inclusion * checkboxes); `local`: the Git tool window's Local Changes tab of the modal commit @@ -353,6 +386,7 @@ export function ChangesTree({ mode }: { mode: "commit" | "local" | "dialog" }) { const status = useVcs((s) => s.status); const root = useVcs((s) => s.root) ?? ""; const groupByDir = useCommit((s) => s.groupByDir); + const diffPreview = useCommit((s) => s.diffPreview); const lists = useChangelists((s) => s.state); const checks = useCommitChecks(); const checkboxes = mode !== "local"; @@ -438,6 +472,12 @@ export function ChangesTree({ mode }: { mode: "commit" | "local" | "dialog" }) { showContextMenu(bottomLeft(viewRef.current), [ { kind: "header", label: "Group By" }, { label: "Directory", checked: groupByDir, run: () => setGroupByDir(!groupByDir) }, + ...(mode === "local" + ? [ + { kind: "separator" } as const, + { label: "Show Diff Preview", checked: diffPreview, run: () => setDiffPreview(!diffPreview) }, + ] + : []), ]) } /> diff --git a/src/features/vcs/DiffEditor.module.css b/src/features/vcs/DiffEditor.module.css index acf34543a..5fc7db47a 100644 --- a/src/features/vcs/DiffEditor.module.css +++ b/src/features/vcs/DiffEditor.module.css @@ -1,5 +1,6 @@ .root { position: relative; + container: diff-editor / inline-size; flex: 1; height: 100%; min-height: 0; @@ -16,19 +17,37 @@ padding: 0 6px; border-bottom: 1px solid var(--border); } +.toolbar > * { + flex-shrink: 0; +} .sep { width: 1px; height: 14px; margin: 0 4px; background: var(--border-strong); } -.count { +.toolbar > .count { + flex-shrink: 1; + min-width: 0; margin-left: auto; padding: 0 6px; + overflow: hidden; + text-overflow: ellipsis; color: var(--fg-subtle); font-size: var(--fs-small); white-space: nowrap; } +/* Narrow (a side-docked preview): the whitespace / highlighting pickers stay in the gear menu. */ +@container diff-editor (max-width: 640px) { + .select { + display: none; + } +} +@container diff-editor (max-width: 380px) { + .toolbar > .sep { + display: none; + } +} .labels { flex: none; display: flex; diff --git a/src/features/vcs/GitLog.tsx b/src/features/vcs/GitLog.tsx index ba4c431fa..440a9b03b 100644 --- a/src/features/vcs/GitLog.tsx +++ b/src/features/vcs/GitLog.tsx @@ -93,7 +93,7 @@ import styles from "./GitLog.module.css"; import { tx } from "@/platform/i18n"; import { useWorkflowSettings } from "@/platform/workflowSettings"; import { gitTabs, shownTab } from "./gitTabs"; -import { ChangesTree, StashContent, tabItems, useStashTabCounts } from "./CommitView"; +import { LocalChangesView, StashContent, tabItems, useStashTabCounts } from "./CommitView"; import { WorktreesPanel } from "./gitMenuDialogs"; const ROW = 26; @@ -148,7 +148,7 @@ export function GitToolWindow() { aria-labelledby={`openidea-git-tab-${shown}`} > {shown === "changes" ? ( - + ) : shown === "shelf" || shown === "stashes" ? ( ) : shown === "console" ? ( diff --git a/src/features/vcs/__tests__/diffFind.test.tsx b/src/features/vcs/__tests__/diffFind.test.tsx index e7cdde2de..a42649bc1 100644 --- a/src/features/vcs/__tests__/diffFind.test.tsx +++ b/src/features/vcs/__tests__/diffFind.test.tsx @@ -40,6 +40,7 @@ vi.mock("@/editor/api", async (importOriginal) => ({ })); import { DiffEditor } from "../DiffEditor"; +import type { DiffData } from "../diffInput"; const diff = (right: object) => ({ @@ -74,6 +75,24 @@ const press = async (index: number, code: string, shift = false) => { afterEach(() => useKeymap.setState({ overrides: {} })); describe("Find in the diff viewer", () => { + it("preserves external focus when loading and changing a working-tree preview", async () => { + const preview = (input: EditorInput) => ( + <> + + + + ); + const { rerender } = render(preview(working)); + const tree = screen.getByRole("button", { name: "Changes tree" }); + tree.focus(); + const oldPane = await pane(1); + expect(tree).toHaveFocus(); + rerender(preview({ ...working, key: "diff:b.ts", data: { ...(working.data as DiffData), path: "/p/b.ts" } })); + await waitFor(() => expect(oldPane.dom.isConnected).toBe(false)); + await pane(1); + expect(tree).toHaveFocus(); + }); + it("opens the editor's find bar from the keymap and closes it with Esc", async () => { useKeymap.setState({ overrides: KEYMAP }); render(); diff --git a/src/features/vcs/__tests__/localChanges.test.tsx b/src/features/vcs/__tests__/localChanges.test.tsx new file mode 100644 index 000000000..23b2f8a52 --- /dev/null +++ b/src/features/vcs/__tests__/localChanges.test.tsx @@ -0,0 +1,130 @@ +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/platform/ipc", async (importOriginal) => { + const real = await importOriginal(); + return { ...real, call: vi.fn() }; +}); + +vi.mock("../DiffEditor", () => ({ + DiffEditor: ({ input }: { input: { data: { path: string } } }) => ( +
    + ), +})); + +const openDiff = vi.hoisted(() => vi.fn()); +vi.mock("../diffInput", async (original) => ({ + ...(await original()), + openDiff, +})); + +import { LocalChangesView } from "../CommitView"; +import { setDiffPreview, useCommit } from "../commitStore"; +import { useVcs } from "../store"; +import { useToolWindowLayout } from "@/workbench/toolwindows/store"; + +describe("Local Changes diff preview (#710)", () => { + beforeEach(() => { + localStorage.clear(); + openDiff.mockClear(); + useCommit.setState({ diffPreview: true, selection: [] }); + useVcs.setState({ root: "/p", repo: "/p", roots: ["/p"], status: [] }); + useToolWindowLayout.setState({ placements: {} }); + }); + + it("places the preview beside the tree for default bottom docking", () => { + render(); + expect(screen.getByRole("separator")).toHaveAttribute("aria-orientation", "vertical"); + }); + + it.each(["left", "right"] as const)("stacks the preview for %s docking and updates when moved", (anchor) => { + render(); + act(() => useToolWindowLayout.setState({ placements: { git: { anchor } } })); + expect(screen.getByRole("separator")).toHaveAttribute("aria-orientation", "horizontal"); + act(() => useToolWindowLayout.setState({ placements: { git: { anchor: "bottom" } } })); + expect(screen.getByRole("separator")).toHaveAttribute("aria-orientation", "vertical"); + }); + + it("previews the single selected file's working-tree diff", () => { + useCommit.setState({ selection: ["/p/src/a.ts"] }); + render(); + expect(screen.getByTestId("diff-preview").getAttribute("data-path")).toBe("/p/src/a.ts"); + }); + + it("shows a placeholder until exactly one file is selected", () => { + render(); + expect(screen.getByText("Select a file to preview its changes")).toBeTruthy(); + useCommit.setState({ selection: ["/p/a.ts", "/p/b.ts"] }); + expect(screen.getByText("Select a file to preview its changes")).toBeTruthy(); + }); + + it("follows the selection", () => { + useCommit.setState({ selection: ["/p/a.ts"] }); + render(); + expect(screen.getByTestId("diff-preview").getAttribute("data-path")).toBe("/p/a.ts"); + act(() => useCommit.setState({ selection: ["/p/b.ts"] })); + expect(screen.getByTestId("diff-preview").getAttribute("data-path")).toBe("/p/b.ts"); + act(() => useCommit.setState({ selection: [] })); + expect(screen.queryByTestId("diff-preview")).toBeNull(); + }); + + it("previews keyboard selection and opens the full diff on Enter and double click", () => { + useVcs.setState({ + status: ["a.ts", "b.ts"].map((name) => ({ + path: `/p/${name}`, + relPath: name, + staged: null, + unstaged: "modified" as const, + renamedFrom: null, + })), + }); + render(); + const tree = screen.getByRole("tree", { name: "Changes" }); + tree.focus(); + fireEvent.keyDown(tree, { key: "Home" }); + fireEvent.keyDown(tree, { key: "ArrowDown" }); + expect(screen.getByTestId("diff-preview")).toHaveAttribute("data-path", "/p/a.ts"); + fireEvent.keyDown(tree, { key: "ArrowDown" }); + expect(screen.getByTestId("diff-preview")).toHaveAttribute("data-path", "/p/b.ts"); + expect(tree).toHaveFocus(); + expect(openDiff).not.toHaveBeenCalled(); + fireEvent.keyDown(tree, { key: "Enter" }); + expect(openDiff).toHaveBeenLastCalledWith(expect.objectContaining({ path: "/p/b.ts" }), { preview: true }); + fireEvent.doubleClick(screen.getByText("a.ts")); + expect(openDiff).toHaveBeenLastCalledWith(expect.objectContaining({ path: "/p/a.ts" }), { preview: true }); + }); + + it("turns off via the View Options toggle, persists, and keeps the tree alone", () => { + useCommit.setState({ selection: ["/p/a.ts"] }); + render(); + act(() => setDiffPreview(false)); + expect(useCommit.getState().diffPreview).toBe(false); + expect(screen.queryByTestId("diff-preview")).toBeNull(); + expect(screen.getByText("No local changes")).toBeTruthy(); + expect(localStorage.getItem("openidea.vcs.diffPreview")).toBe("false"); + }); + + it("keeps the tree selection when the preview is toggled off and on", () => { + useVcs.setState({ + status: ["a.ts", "b.ts"].map((name) => ({ + path: `/p/${name}`, + relPath: name, + staged: null, + unstaged: "modified" as const, + renamedFrom: null, + })), + }); + render(); + const tree = screen.getByRole("tree", { name: "Changes" }); + tree.focus(); + fireEvent.keyDown(tree, { key: "Home" }); + fireEvent.keyDown(tree, { key: "ArrowDown" }); + fireEvent.keyDown(tree, { key: "ArrowDown" }); + expect(screen.getByText("b.ts").closest('[role="treeitem"]')).toHaveAttribute("aria-selected", "true"); + act(() => setDiffPreview(false)); + expect(screen.getByText("b.ts").closest('[role="treeitem"]')).toHaveAttribute("aria-selected", "true"); + act(() => setDiffPreview(true)); + expect(screen.getByText("b.ts").closest('[role="treeitem"]')).toHaveAttribute("aria-selected", "true"); + expect(screen.getByTestId("diff-preview")).toHaveAttribute("data-path", "/p/b.ts"); + }); +}); diff --git a/src/features/vcs/commitStore.ts b/src/features/vcs/commitStore.ts index e609cec90..b1f78b721 100644 --- a/src/features/vcs/commitStore.ts +++ b/src/features/vcs/commitStore.ts @@ -14,6 +14,8 @@ interface CommitState { /** Commit Options › Run Git hooks. */ runHooks: boolean; groupByDir: boolean; + /** Whether the Git window's Local Changes tab previews the selection's diff. */ + diffPreview: boolean; /** User overrides of the inclusion checkboxes (absolute path → state). */ checks: Map; /** Changed blocks left out of the commit (absolute path → `hunkKeys`) of `hunks` files. */ @@ -42,6 +44,7 @@ export const useCommit = create(() => ({ author: "", runHooks: true, groupByDir: readBool("openidea.vcs.groupByDir", false), + diffPreview: readBool("openidea.vcs.diffPreview", true), checks: new Map(), excludedHunks: new Map(), selection: [], @@ -71,6 +74,15 @@ export function setGroupByDir(v: boolean) { } } +export function setDiffPreview(v: boolean) { + useCommit.setState({ diffPreview: v }); + try { + localStorage.setItem("openidea.vcs.diffPreview", String(v)); + } catch { + // ignore + } +} + const historyKey = () => `openidea.vcs.commitMessages:${useVcs.getState().root ?? ""}`; const MAX_HISTORY = 20; diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 5d0f755f6..3a2d099ae 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -1755,6 +1755,7 @@ "Restore Size": "恢复大小", "Restore detected roots": "恢复检测到的根", "Result": "结果", + "Select a file to preview its changes": "选择文件以预览其更改", "Select a problem to see its details": "选择一个问题以查看详情", "Select an item to preview": "选择一项以预览", "Severity": "严重性", From ec5302d8385da9c68f13975c8733165818374a4d Mon Sep 17 00:00:00 2001 From: Piettro Castro Date: Tue, 3 Nov 2026 00:14:14 -0500 Subject: [PATCH 049/106] fix(linux): draw GTK's CSD shadow on frameless windows under Wayland tao makes frameless Wayland windows client-side decorated (an empty GTK title bar, so the compositor adds no frame) but leaves them undecorated, so GTK draws no shadow or resize margin and dark windows blend into whatever is behind them. Decorating the window after build keeps that empty title bar: GTK adds the theme's shadow and resize edges, nothing else. Applies to project windows and the Settings window; X11 is unchanged (decorating there would bring back the WM frame). - Detect Wayland from GDK's backend (tao's own test), not env vars. - Measure windows by GTK's content size, which excludes the shadow margin, so remembered geometry and Settings placement don't grow. - Skip the in-page resize handles on Wayland; GTK provides the edges. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 1 + src-tauri/Cargo.toml | 4 ++ src-tauri/src/commands/app.rs | 6 +++ src-tauri/src/linux_window_state.rs | 42 ++++++++++++++++++- src-tauri/src/window_groups.rs | 4 ++ src-tauri/src/windows.rs | 22 +++++++++- src/bindings.ts | 2 + .../settings/dialog/SettingsDialog.module.css | 4 +- src/platform/platformInfo.ts | 4 ++ src/workbench/WindowResizeHandles.tsx | 12 +++--- 10 files changed, 91 insertions(+), 10 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5275a63ff..91fb0a947 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6152,6 +6152,7 @@ dependencies = [ "block2 0.6.2", "dirs 6.0.0", "dunce", + "gtk", "ide-agent", "ide-analysis", "ide-build", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index ab942c551..633a43b9e 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -79,6 +79,10 @@ objc2-foundation = { version = "0.3", default-features = false, features = ["std # Used only by the debug font-restriction override; Cargo dependency cfg cannot test debug_assertions. objc2-web-kit = { version = "0.3", default-features = false, features = ["std", "WKWebViewConfiguration", "WKPreferences"] } +[target.'cfg(target_os = "linux")'.dependencies] +# Wayland detection and the client size without GTK's CSD shadow (linux_window_state.rs). +gtk = "0.18" + [target.'cfg(windows)'.dependencies] windows-sys = { version = "0.61", features = [ "Win32_Foundation", diff --git a/src-tauri/src/commands/app.rs b/src-tauri/src/commands/app.rs index e68485c61..e383c9fa4 100644 --- a/src-tauri/src/commands/app.rs +++ b/src-tauri/src/commands/app.rs @@ -15,6 +15,8 @@ pub struct AppInfo { pub version: String, pub platform: String, pub config_dir: ide_core::IpcPath, + /// Linux on a Wayland display: GTK draws the frameless windows' shadow and resize edges. + pub wayland: bool, } pub use ide_host::ops::project::ProjectInfo; @@ -27,6 +29,10 @@ pub fn app_info(state: State<'_, AppState>) -> AppInfo { version: env!("CARGO_PKG_VERSION").into(), platform: std::env::consts::OS.into(), config_dir: state.config_dir.clone().into(), + #[cfg(target_os = "linux")] + wayland: crate::linux_window_state::is_wayland(), + #[cfg(not(target_os = "linux"))] + wayland: false, } } diff --git a/src-tauri/src/linux_window_state.rs b/src-tauri/src/linux_window_state.rs index 3d9816dce..a05a6f7e6 100644 --- a/src-tauri/src/linux_window_state.rs +++ b/src-tauri/src/linux_window_state.rs @@ -14,6 +14,7 @@ mod runtime { use super::*; use parking_lot::Mutex; use std::collections::HashMap; + use std::sync::OnceLock; use tauri::{AppHandle, Listener, Manager, Window, WindowEvent, plugin::TauriPlugin}; const FILE: &str = tauri_plugin_window_state::DEFAULT_FILENAME; @@ -63,8 +64,42 @@ mod runtime { visible: bool, } + static WAYLAND: OnceLock = OnceLock::new(); + + /// GDK's backend, as tao tests it (`GDK_BACKEND=x11` under a Wayland session is X11). + /// Known once the plugin is set up, before any window exists. + pub fn is_wayland() -> bool { + WAYLAND.get().copied().unwrap_or(false) + } + + /// The client size that `set_size` (`gtk_window_resize`) takes. On Wayland, decorated windows + /// get a GTK CSD shadow margin that tao's inner and outer sizes (the configure size) include. + pub fn content_size(window: &Window) -> tauri::Result> { + let inner = window.inner_size()?; + if !is_wayland() { + return Ok(inner); + } + // GTK objects belong to the main thread. `gtk_window_get_size` follows the configure + // synchronously; the webview's allocation only catches up on the next layout. + let (tx, rx) = std::sync::mpsc::sync_channel(1); + let cloned = window.clone(); + window.run_on_main_thread(move || { + let sizes = cloned.gtk_window().ok().zip(cloned.inner_size().ok()).zip(cloned.scale_factor().ok()); + let _ = tx.send(sizes.map(|((gtk, inner), scale)| { + let (width, height) = gtk::prelude::GtkWindowExt::size(>k); + let content = tauri::LogicalSize::new(width.max(0) as u32, height.max(0) as u32); + (content.to_physical::(scale), inner) + })); + })?; + let Some((content, inner)) = rx.recv().ok().flatten() else { return Ok(inner) }; + // An unmapped window reports its size request; never exceed the configure size. + let usable = + content.width > 1 && content.height > 1 && content.width <= inner.width && content.height <= inner.height; + Ok(if usable { content } else { inner }) + } + fn frame(window: &Window) -> Option { - let size = window.inner_size().ok()?; + let size = content_size(window).ok()?; Some(Frame { visible: window.is_visible().ok()?, size: Size(size.width, size.height), @@ -381,6 +416,9 @@ mod runtime { pub fn plugin() -> TauriPlugin { tauri::plugin::Builder::new("linux-window-state") .setup(|app, _| { + // Plugins are set up on the main thread once GTK is initialised, before any window exists. + use gtk::gdk::prelude::DisplayExtManual; + let _ = WAYLAND.set(gtk::gdk::Display::default().is_some_and(|d| d.backend().is_wayland())); let saved = app .path() .app_config_dir() @@ -466,7 +504,7 @@ mod runtime { } #[cfg(target_os = "linux")] -pub use runtime::{normal_size_requested, plugin, prepare, save_before_close}; +pub use runtime::{content_size, is_wayland, normal_size_requested, plugin, prepare, save_before_close}; #[cfg(test)] mod tests { diff --git a/src-tauri/src/window_groups.rs b/src-tauri/src/window_groups.rs index fef00fa14..64344e0d8 100644 --- a/src-tauri/src/window_groups.rs +++ b/src-tauri/src/window_groups.rs @@ -65,6 +65,10 @@ fn copy_frame(from: &WebviewWindow, to: &WebviewWindow) -> IdeResult<()> { if !maximized { to.unmaximize().map_err(err)?; to.set_position(from.outer_position().map_err(err)?).map_err(err)?; + // `set_size` excludes GTK's CSD shadow, which tao's inner size includes on Wayland. + #[cfg(target_os = "linux")] + let size = crate::linux_window_state::content_size(&from.as_ref().window()).map_err(err)?; + #[cfg(not(target_os = "linux"))] let size = from.inner_size().map_err(err)?; #[cfg(target_os = "linux")] crate::linux_window_state::normal_size_requested(to.app_handle(), to.label(), size); diff --git a/src-tauri/src/windows.rs b/src-tauri/src/windows.rs index a47340ade..14e84c208 100644 --- a/src-tauri/src/windows.rs +++ b/src-tauri/src/windows.rs @@ -303,6 +303,8 @@ fn build_project_window(app: &AppHandle, mut config: WindowConfig) -> IdeResult< let window = WebviewWindowBuilder::from_config(app, &config) .and_then(|b| dev_font_preferences(b).build()) .map_err(|e| IdeError::internal(format!("cannot create window: {e}")))?; + #[cfg(target_os = "linux")] + decorate_csd(&window); apply_zoom(app, &window); #[cfg(target_os = "macos")] align_titlebar(app, &window); @@ -325,6 +327,17 @@ fn build_project_window(app: &AppHandle, mut config: WindowConfig) -> IdeResult< Ok(window) } +/// Wayland: tao already makes frameless windows client-side decorated (an empty GTK title bar, so +/// the compositor adds no frame), but undecorated GTK windows get no shadow or resize margin. +/// Decorating keeps that empty title bar, so GTK adds the theme's shadow and resize edges and +/// nothing else. X11 has no such title bar: decorating there would bring back the WM frame. +#[cfg(target_os = "linux")] +fn decorate_csd(window: &WebviewWindow) { + if crate::linux_window_state::is_wayland() { + let _ = window.set_decorations(true); + } +} + /// Sets the saved IDE zoom before the page loads; applied later by the frontend, /// it would visibly scale up the splash. fn apply_zoom(app: &AppHandle, window: &WebviewWindow) { @@ -807,6 +820,8 @@ pub fn open_settings_window( let window = builder .and_then(|b| dev_font_preferences(b).build()) .map_err(|e| IdeError::internal(format!("cannot create settings window: {e}")))?; + #[cfg(target_os = "linux")] + decorate_csd(&window); apply_zoom(app, &window); #[cfg(target_os = "macos")] align_titlebar(app, &window); @@ -887,7 +902,12 @@ pub(super) fn place_settings_window(window: &WebviewWindow, parent: &WebviewWind let size = parent.outer_size()?.to_logical::(scale); let own_scale = window.scale_factor()?; let own = window.outer_size()?.to_logical::(own_scale); - let inner = window.inner_size()?.to_logical::(own_scale); + // On Wayland, the frame is GTK's CSD shadow margin, which tao's inner size includes. + #[cfg(target_os = "linux")] + let inner = crate::linux_window_state::content_size(&window.as_ref().window())?; + #[cfg(not(target_os = "linux"))] + let inner = window.inner_size()?; + let inner = inner.to_logical::(own_scale); let frame = (own.width - inner.width, own.height - inner.height); let bounds = fit_bounds( (pos.x, pos.y, size.width, size.height), diff --git a/src/bindings.ts b/src/bindings.ts index 7c4a2d955..eed10ae62 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -1379,6 +1379,8 @@ export type AppInfo = { version: string, platform: string, configDir: IpcPath, + /** Linux on a Wayland display: GTK draws the frameless windows' shadow and resize edges. */ + wayland: boolean, }; export type AppMetrics = { diff --git a/src/features/settings/dialog/SettingsDialog.module.css b/src/features/settings/dialog/SettingsDialog.module.css index 7661a3a67..d7bad0428 100644 --- a/src/features/settings/dialog/SettingsDialog.module.css +++ b/src/features/settings/dialog/SettingsDialog.module.css @@ -203,8 +203,8 @@ background: var(--bg-dialog, var(--bg-elev)); color: var(--fg); } -/* Linux draws no shadow for a frameless window (Wayland/KDE give undecorated - windows no CSD), so mark its edge in CSS instead. */ +/* Linux: on Wayland GTK draws the frameless window's shadow (`decorate_csd` in + windows.rs), but frameless X11 windows have none, so mark the edge in CSS. */ .windowEdge { position: relative; } diff --git a/src/platform/platformInfo.ts b/src/platform/platformInfo.ts index 230a34bdd..95846480c 100644 --- a/src/platform/platformInfo.ts +++ b/src/platform/platformInfo.ts @@ -9,6 +9,8 @@ interface PlatformInfo { overlayTitleBar: boolean; /** Frameless window: the title bar draws the main menu and caption buttons (Tauri, Windows and Linux). */ framelessTitleBar: boolean; + /** Linux on a Wayland display: GTK draws the frameless windows' shadow and resize edges. */ + wayland: boolean; } /** OS from the user agent (before the backend answers, or in a browser). "darwin" must not read as Windows. */ @@ -28,6 +30,7 @@ export const usePlatform = create(() => { version: null, overlayTitleBar: isTauri() && os === "macos", framelessTitleBar: isTauri() && (os === "windows" || os === "linux"), + wayland: false, }; }); @@ -40,6 +43,7 @@ export async function loadPlatformInfo() { version: info.version, overlayTitleBar: info.platform === "macos", framelessTitleBar: info.platform === "windows" || info.platform === "linux", + wayland: info.wayland, }); } catch { /* keep the user-agent guess */ diff --git a/src/workbench/WindowResizeHandles.tsx b/src/workbench/WindowResizeHandles.tsx index 0b5fa9da6..dbaf4e8f3 100644 --- a/src/workbench/WindowResizeHandles.tsx +++ b/src/workbench/WindowResizeHandles.tsx @@ -1,11 +1,12 @@ /** - * Frameless-window resize handles: Windows gives undecorated windows no - * resize borders, so the title bar's frameless look leaves the window - * stuck at its launch size. Eight invisible hit areas along the edges + * Frameless-window resize handles: Windows and Linux on X11 give undecorated + * windows no resize borders, so the title bar's frameless look leaves the + * window stuck at its launch size. Eight invisible hit areas along the edges * start a native resize drag, like any other desktop app. * * macOS renders its own overlay title bar but still provides resize - * handles, so nothing is added there. + * handles, and on Wayland GTK draws resize edges in the client-side shadow + * (`decorate_csd` in windows.rs), so nothing is added there. */ import { useEffect, useState } from "react"; import { usePlatform } from "@/platform/platformInfo"; @@ -33,7 +34,8 @@ async function start(dir: Dir) { export function WindowResizeHandles() { // A frameless window needs them; a decorated one already has native borders. const frameless = usePlatform((s) => s.framelessTitleBar); - const enabled = isTauri() && frameless; + const gtkEdges = usePlatform((s) => s.os === "linux" && s.wayland); + const enabled = isTauri() && frameless && !gtkEdges; // A maximized or full-screen window has nothing to resize; the edges belong to the content. const [sized, setSized] = useState(false); useEffect(() => { From 53e76e29c641c365c31f64bcbf1ce7cda8675c56 Mon Sep 17 00:00:00 2001 From: arookieofc <2128194521@qq.com> Date: Tue, 3 Nov 2026 14:35:52 +0800 Subject: [PATCH 050/106] fix(vcs): discover worktrees created while the project is open (#930) A linked worktree created below the project while it was open never joined the repository set: its files had no status entries or gutter marks until the project was reopened. A linked worktree's `.git` is a gitfile and its HEAD lives under `
    /.git/worktrees//`. The watcher classified both as non-metadata, `.git/worktrees` was never watched, and the repository rescan only reacted to `/.git/HEAD`. The watcher now reports the `.git` path and `.git/worktrees/**` (without `.lock` files) as VCS changes and watches `.git/worktrees`; the rescan accepts a worktree's `.git`; and the Add/Remove Worktree commands refresh the repository set directly, so both the in-IDE and the external `git worktree` paths are covered. Fixes #927. Co-authored-by: a-rookie-of-C-language <186315048+a-rookie-of-C-language@users.noreply.github.com> --- crates/ide-host/src/ops/vcs/commands.rs | 15 +++- crates/ide-host/src/project.rs | 34 +++++++-- crates/ide-host/tests/vcs_worktrees.rs | 93 ++++++++++++++++++++++++ crates/ide-vfs/src/watch.rs | 95 +++++++++++++++++++++++-- 4 files changed, 222 insertions(+), 15 deletions(-) create mode 100644 crates/ide-host/tests/vcs_worktrees.rs diff --git a/crates/ide-host/src/ops/vcs/commands.rs b/crates/ide-host/src/ops/vcs/commands.rs index d3913c0af..d8655e3b6 100644 --- a/crates/ide-host/src/ops/vcs/commands.rs +++ b/crates/ide-host/src/ops/vcs/commands.rs @@ -729,7 +729,13 @@ pub async fn vcs_add_worktree( host.require_trust(window)?; let project = host.project(window)?; blocking(move || { - project.with_repo_recorded(repo.as_deref(), None, |r| r.add_worktree(&path, &commitish, new_branch.as_deref())) + let result = project + .with_repo_recorded(repo.as_deref(), None, |r| r.add_worktree(&path, &commitish, new_branch.as_deref())); + // A worktree below the project is a new repository: rescan so its files + // join the status and gutter queries (the watcher picks the gitfile up + // too, but only as a debounced event later). + project.refresh_repositories(); + result }) .await } @@ -743,7 +749,12 @@ pub async fn vcs_remove_worktree( ) -> IdeResult<()> { host.require_trust(window)?; let project = host.project(window)?; - blocking(move || project.with_repo_at(repo.as_deref(), |r| r.remove_worktree(&path, force))).await + blocking(move || { + let result = project.with_repo_at(repo.as_deref(), |r| r.remove_worktree(&path, force)); + project.refresh_repositories(); + result + }) + .await } pub async fn vcs_compare_scope(host: &Host, window: &str, paths: Vec) -> IdeResult { diff --git a/crates/ide-host/src/project.rs b/crates/ide-host/src/project.rs index 9b4dda808..1391f6182 100644 --- a/crates/ide-host/src/project.rs +++ b/crates/ide-host/src/project.rs @@ -325,15 +325,35 @@ impl Host { sink.emit(IdeEvent::ProjectRootsChanged { root: root.clone().into() }); } } - // A repository was cloned / initialised / removed below the project: its - // `.git/HEAD` (the watcher reports nothing else of `.git`) appeared or went. + // A repository was cloned / initialised / removed below the project: + // its `.git/HEAD` appeared or went. A linked worktree's `.git` is a + // plain gitfile, and that file is the only marker written inside the + // worktree itself, so the `.git` path counts too. let known = repos.read().roots(); - let git_dirs = changed.iter().chain(&batch.removed).chain(&batch.vcs).any(|p| { - let Some(dir) = p.ends_with(".git/HEAD").then(|| p.parent().and_then(Path::parent)).flatten() - else { - return false; + let mut git_dirs = changed.iter().chain(&batch.removed).chain(&batch.vcs).any(|p| { + let dir = if p.ends_with(".git/HEAD") { + p.parent().and_then(Path::parent) + } else if p.ends_with(".git") { + p.parent() + } else { + None }; - known.iter().any(|r| r == dir) != p.is_file() + let Some(dir) = dir else { return false }; + known.iter().any(|r| r == dir) != p.exists() + }); + // A linked worktree was registered or unregistered in the main + // gitdir: `git worktree add` / `remove` creates or deletes + // `.git/worktrees/`. Its own gitfile is written before the + // watcher can watch the new directory, so this entry is what + // Linux per-directory watchers see. + git_dirs |= batch.vcs.iter().any(|p| { + let rest: Vec<_> = p + .components() + .skip_while(|c| c.as_os_str() != ".git") + .skip(1) // the `.git` component itself + .map(|c| c.as_os_str().to_string_lossy().into_owned()) + .collect(); + matches!(rest.as_slice(), [first, ..] if first == "worktrees" && rest.len() <= 2) }); if git_dirs { scan_repositories(&root, &repos, &settings, &sink); diff --git a/crates/ide-host/tests/vcs_worktrees.rs b/crates/ide-host/tests/vcs_worktrees.rs new file mode 100644 index 000000000..57304d189 --- /dev/null +++ b/crates/ide-host/tests/vcs_worktrees.rs @@ -0,0 +1,93 @@ +use ide_core::NullSink; +use ide_host::{Host, HostConfig, Project, ops::vcs}; +use std::{path::PathBuf, process::Command, sync::Arc, time::Duration}; + +fn git(root: &std::path::Path, args: &[&str]) { + let out = Command::new("git").current_dir(root).args(args).output().unwrap(); + assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); +} + +async fn wait_for_repos(project: &Arc, count: usize) { + for _ in 0..200 { + if project.repos().all().len() >= count { + return; + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + panic!("expected {} repositories, found {}", count, project.repos().all().len()); +} + +struct Fixture { + host: Host, + project: Arc, + root: PathBuf, + _temp: tempfile::TempDir, +} + +impl Fixture { + async fn new() -> Self { + let temp = tempfile::tempdir().unwrap(); + let base = dunce::canonicalize(temp.path()).unwrap(); + let root = base.join("project"); + std::fs::create_dir_all(&root).unwrap(); + git(&root, &["init", "-b", "main"]); + git(&root, &["config", "user.name", "Test"]); + git(&root, &["config", "user.email", "test@example.com"]); + git(&root, &["config", "commit.gpgsign", "false"]); + git(&root, &["config", "core.autocrlf", "false"]); + std::fs::write(root.join("file.txt"), "original\n").unwrap(); + git(&root, &["add", "."]); + git(&root, &["commit", "-m", "original"]); + let host = Host::new(HostConfig { + config_dir: base.join("config"), + data_dir: base.join("data"), + local_data_dir: base.join("data"), + languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()), + syntax: Arc::default(), + sink_for: Box::new(|_| Arc::new(NullSink)), + owner: |w| w, + runtime: tokio::runtime::Handle::current(), + project_environment: false, + }); + host.trust.decide(&root, true, false).unwrap(); + let project = host.open_project("main", &root).unwrap(); + wait_for_repos(&project, 1).await; + Self { host, project, root, _temp: temp } + } +} + +impl Drop for Fixture { + fn drop(&mut self) { + self.host.close_project("main"); + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn command_added_worktree_joins_the_repository_set() { + let f = Fixture::new().await; + let linked = f.root.join("linked"); + vcs::vcs_add_worktree(&f.host, "main", None, linked.clone(), "main".into(), Some("topic".into())).await.unwrap(); + let roots = f.project.repos().roots(); + assert!(roots.contains(&linked), "worktree below the project did not join the repository set: {roots:?}"); + assert_eq!(roots.len(), 2); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn removed_worktree_leaves_the_repository_set() { + let f = Fixture::new().await; + let linked = f.root.join("linked"); + vcs::vcs_add_worktree(&f.host, "main", None, linked.clone(), "main".into(), Some("topic".into())).await.unwrap(); + assert_eq!(f.project.repos().all().len(), 2); + vcs::vcs_remove_worktree(&f.host, "main", None, linked, false).await.unwrap(); + assert_eq!(f.project.repos().all().len(), 1); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn watcher_picks_up_a_worktree_added_below_the_project() { + let f = Fixture::new().await; + let linked = f.root.join("watched"); + git(&f.root, &["worktree", "add", "watched", "-b", "watched"]); + wait_for_repos(&f.project, 2).await; + let roots = f.project.repos().roots(); + assert!(roots.contains(&linked), "watcher did not discover the worktree added below the project: {roots:?}"); +} diff --git a/crates/ide-vfs/src/watch.rs b/crates/ide-vfs/src/watch.rs index 44dbce5b7..011cab690 100644 --- a/crates/ide-vfs/src/watch.rs +++ b/crates/ide-vfs/src/watch.rs @@ -277,6 +277,15 @@ impl DirState { self.watch_tree(path, true, &mut discovered); } } + // VCS metadata directories (`.git`, `.git/worktrees/`) need + // watches too: a worktree registered in the main gitdir writes its + // entry below `.git/worktrees`, and the events in a gitdir created + // during this batch would otherwise never be seen. + for path in &batch.vcs { + if !self.watched.lock().contains(path) && path.symlink_metadata().is_ok_and(|m| m.is_dir()) { + self.watch_tree(path, false, &mut discovered); + } + } for path in &batch.removed { self.unwatch_tree(path); } @@ -415,15 +424,29 @@ impl EventFilter { let idx = comps.iter().position(|c| *c == ".git")?; let rest = &comps[idx + 1..]; Some(match rest { - [one] => *one == "HEAD" || *one == "index" || *one == "packed-refs", - [first, ..] => *first == "refs" && !rest.last().is_some_and(|l| l.to_string_lossy().ends_with(".lock")), - [] => false, + // `refs` and `worktrees` are directories whose creation is itself a + // registration event (`git init`, the first `git worktree add`); + // they must surface even though the depth below them is handled + // separately. + [one] => matches!(one.to_str(), Some("HEAD" | "index" | "packed-refs" | "refs" | "worktrees" | "info")), + [first, ..] => { + let metadata = *first == "refs" + // Linked-worktree gitdirs: a worktree added or removed below + // the project writes its HEAD here. + || *first == "worktrees"; + metadata && !rest.last().is_some_and(|l| l.to_string_lossy().ends_with(".lock")) + } + // The `.git` path itself: a linked worktree's `.git` is a plain + // file (gitfile), and it is the only marker a new worktree writes + // inside its own directory. + [] => true, }) } /// Whether a directory deserves its own watch in per-directory mode: not - /// excluded or git-ignored; inside `.git` only the directory itself and - /// `refs/` (HEAD, index and refs are what VCS refresh needs). + /// excluded or git-ignored; inside `.git` the directory itself, `refs/` + /// and `worktrees/` (HEAD, index, refs and worktree metadata are what VCS + /// refresh needs). pub fn should_watch_dir(&self, dir: &Path) -> bool { let Ok(rel) = dir.strip_prefix(&self.root) else { return false }; if rel.as_os_str().is_empty() { @@ -435,6 +458,7 @@ impl EventFilter { return match rest.as_slice() { [] => true, [first, ..] if *first == "refs" => true, + [first, ..] if *first == "worktrees" => true, [only] => *only == "info", _ => false, }; @@ -690,7 +714,18 @@ mod tests { assert_eq!(g(".git/refs/heads/main"), Some(true)); assert_eq!(g(".git/refs/heads/main.lock"), Some(false)); assert_eq!(g(".git/objects/aa/bb"), Some(false)); - assert_eq!(g(".git"), Some(false)); + // The `.git` path itself: a repo dir, or a linked worktree's gitfile. + assert_eq!(g(".git"), Some(true)); + assert_eq!(g("wt/.git"), Some(true)); + // Linked-worktree gitdir metadata below the main repository. + assert_eq!(g(".git/worktrees/wt/HEAD"), Some(true)); + assert_eq!(g(".git/worktrees/wt/index.lock"), Some(false)); + // The depth-1 directories themselves: `git init` or the first + // `git worktree add` creates them in an already-watched gitdir. + assert_eq!(g(".git/refs"), Some(true)); + assert_eq!(g(".git/worktrees"), Some(true)); + assert_eq!(g(".git/info"), Some(true)); + assert_eq!(g(".git/objects"), Some(false)); } #[test] @@ -744,6 +779,9 @@ mod tests { assert!(!w("build") && w("src/build")); assert!(w(".git") && w(".git/refs") && w(".git/refs/heads") && w(".git/info")); assert!(!w(".git/objects") && !w(".git/objects/ab") && !w(".git/logs")); + // Linked-worktree gitdirs are watched so worktrees added below the + // project while it is open are discovered. + assert!(w(".git/worktrees") && w(".git/worktrees/wt") && w(".git/worktrees/wt/rebase-merge")); assert!(!filter.should_watch_dir(Path::new("/elsewhere"))); // Excludes are replaced in place (Watcher::set_excludes). *filter.excludes.write() = ExcludeMatcher::new(&["gen".into()]); @@ -861,6 +899,51 @@ mod tests { assert_eq!(batches.lock().len(), n, "no events after drop"); } + #[test] + fn per_directory_mode_follows_worktree_registrations() { + let tmp = tempfile::tempdir().unwrap(); + let root = dunce::canonicalize(tmp.path()).unwrap(); + for d in ["wt", ".git/refs/heads"] { + std::fs::create_dir_all(root.join(d)).unwrap(); + } + // The worktree's gitfile is written before any watch on the worktree + // directory could register it — the same window a real + // `git worktree add` runs into. + std::fs::write(root.join("wt/.git"), "gitdir: ../.git/worktrees/wt\n").unwrap(); + let batches: Arc>> = Arc::default(); + let sink: SharedSink = Arc::new(|_e: IdeEvent| {}); + let watcher = { + let batches = batches.clone(); + Watcher::start_with( + &root, + Vec::new(), + sink, + Box::new(move |b| batches.lock().push(b.clone())), + WatchMode::PerDirectory, + ) + .unwrap() + }; + // Registering the worktree in the main gitdir surfaces as VCS metadata + // (the deepest directory itself may be created before its parent gains + // a watch, so `.git/worktrees` is the event that reliably fires)... + std::fs::create_dir_all(root.join(".git/worktrees/wt")).unwrap(); + assert!( + wait_for(&batches, |b| b.iter().any(|b| b.vcs.contains(&root.join(".git/worktrees")))), + "{:?}", + batches.lock() + ); + // ...and the registered gitdir gains a watch, so writes inside it are + // seen even though nothing reported the directory itself. + std::thread::sleep(Duration::from_millis(200)); + std::fs::write(root.join(".git/worktrees/wt/HEAD"), "ref: refs/heads/topic\n").unwrap(); + assert!( + wait_for(&batches, |b| b.iter().any(|b| b.vcs.contains(&root.join(".git/worktrees/wt/HEAD")))), + "{:?}", + batches.lock() + ); + drop(watcher); + } + #[test] fn flat_mode_reports_the_roots_own_entries_only() { let tmp = tempfile::tempdir().unwrap(); From 843ff74d5d9f31d70b5716516650e88ac5d08b52 Mon Sep 17 00:00:00 2001 From: Piettro <2058578+pcastro93@users.noreply.github.com> Date: Tue, 3 Nov 2026 01:37:26 -0500 Subject: [PATCH 051/106] fix(tools): name uv as the installer for Python servers when it makes the virtualenv (#936) Tools and the install log named "pip (virtualenv)" for Python language servers (basedpyright, Pyright, ty, Ruff) even when the virtualenv is made with uv, which `install_venv` prefers whenever uv is on `PATH`. The label now comes from the installer that will actually run: "uv (virtualenv)" when uv is on `PATH`, unchanged otherwise. debugpy's private copy still says pip, since it is always installed with `python -m pip`. Co-authored-by: pcastro93 <2058578+pcastro93@users.noreply.github.com> --- crates/ide-tools/src/lib.rs | 15 +++++++++++++-- crates/ide-tools/src/tests.rs | 15 +++++++++++++++ src/features/tools/backend.ts | 2 +- 3 files changed, 29 insertions(+), 3 deletions(-) diff --git a/crates/ide-tools/src/lib.rs b/crates/ide-tools/src/lib.rs index 1822367fe..af2daf933 100644 --- a/crates/ide-tools/src/lib.rs +++ b/crates/ide-tools/src/lib.rs @@ -540,6 +540,15 @@ impl Tools { if managed || self.method_missing(spec.method).is_some() { fallback } else { spec.method } } + /// [`Method::label`], naming the installer that will actually run: a virtualenv is + /// made with uv when it is on `PATH` (see `install_venv`). + fn method_label(&self, method: Method) -> &'static str { + match method { + Method::PipVenv { .. } if self.which("uv").is_some() => "uv (virtualenv)", + m => m.label(), + } + } + /// Requirement check for installing `spec` right now. fn unavailable_reason(&self, spec: &ToolSpec) -> Option { self.method_missing(self.install_method(spec)) @@ -624,7 +633,7 @@ impl Tools { status, path: path.map(Into::into), version, - install_method: Some(self.install_method(spec).label().to_string()), + install_method: Some(self.method_label(self.install_method(spec)).to_string()), can_install: unavailable_reason.is_none() && !bundled, unavailable_reason, can_uninstall, @@ -796,7 +805,9 @@ impl Tools { let result = (|| { std::fs::create_dir_all(&self.root)?; let method = self.install_method(spec); - emit(InstallEvent::Line { text: format!("Installing {} via {}…", spec.display, method.label()) }); + emit(InstallEvent::Line { + text: format!("Installing {} via {}…", spec.display, self.method_label(method)), + }); match method { Method::Bundled { .. } => { bundled::install(&self.root, spec)?; diff --git a/crates/ide-tools/src/tests.rs b/crates/ide-tools/src/tests.rs index ccb35e2be..40112196f 100644 --- a/crates/ide-tools/src/tests.rs +++ b/crates/ide-tools/src/tests.rs @@ -161,6 +161,21 @@ fn interchangeable_servers_list_their_alternatives() { assert!(find(&list, "rust-analyzer").alternatives.is_empty()); } +#[cfg(unix)] +#[test] +fn python_servers_name_uv_when_it_makes_the_virtualenv() { + let env = Env::new(); + env.script(&env.sys, "uv", "exit 0"); + let list = env.tools().list(&langs()); + for name in ["basedpyright", "ty", "ruff"] { + let tool = find(&list, name); + assert_eq!(tool.install_method.as_deref(), Some("uv (virtualenv)"), "{name}"); + assert!(tool.can_install, "{name}: uv alone is enough"); + } + // debugpy's private copy is always made by `python -m pip`. + assert_eq!(find(&list, "debugpy").install_method.as_deref(), Some("pip (private copy)")); +} + #[cfg(unix)] #[test] fn detects_tools_on_path_with_versions() { diff --git a/src/features/tools/backend.ts b/src/features/tools/backend.ts index f0ae57560..f472418a3 100644 --- a/src/features/tools/backend.ts +++ b/src/features/tools/backend.ts @@ -99,7 +99,7 @@ export function sampleTools(): ToolInfo[] { name, displayName: name === "pyright" ? "Pyright" : name === "ty" ? "ty (Python)" : name, languages: ["Python"], - installMethod: "pip (virtualenv)", + installMethod: "uv (virtualenv)", alternatives: ["basedpyright", "pyright", "ty"], }), ), From c50d212a1d0f90f60a85bdc8561237c482e77e19 Mon Sep 17 00:00:00 2001 From: youngtseup <53266026+youngtseup@users.noreply.github.com> Date: Tue, 3 Nov 2026 14:38:19 +0800 Subject: [PATCH 052/106] fix(app): keep update progress visible on the welcome screen (#951) Check for Updates on the welcome screen showed nothing while the request was out: the check ran only as a status-bar task, and the welcome screen has no status bar. Leaving a project also dropped every notification, including a running update download. A check started with no project or LightEdit window open now shows its progress in a balloon (keyed like the result, so the result replaces it); project windows keep it in the status bar. A project switch clears only project notifications and keeps the app-wide update ones. Co-authored-by: youngtseup <53266026+youngtseup@users.noreply.github.com> --- src/features/app/__tests__/updates.test.ts | 19 ++++++++++++++++ src/features/app/updates.tsx | 14 ++++++++++-- src/workbench/activate.ts | 4 ++-- src/workbench/notificationStore.ts | 5 +++++ src/workbench/projectNotifications.test.ts | 26 ++++++++++++++++++++++ 5 files changed, 64 insertions(+), 4 deletions(-) create mode 100644 src/workbench/projectNotifications.test.ts diff --git a/src/features/app/__tests__/updates.test.ts b/src/features/app/__tests__/updates.test.ts index f1710e970..1a4a71f82 100644 --- a/src/features/app/__tests__/updates.test.ts +++ b/src/features/app/__tests__/updates.test.ts @@ -41,6 +41,7 @@ vi.mock("@/platform/ipc", async (orig) => { const { checkForUpdates, installUpdate, notifyUpdate, progressText, CHECK_TASK_ID, INSTALL_TASK_ID, UPDATE_KEY } = await import("../updates"); const { provideNotificationService } = await import("@/workbench/contributions"); +const { useProject } = await import("@/platform/project"); const { useTasks } = await import("@/workbench/tasks"); type Note = Parameters[0]; @@ -59,6 +60,7 @@ beforeEach(() => { check = deferred(); provideNotificationService({ notify: (n) => void notes.push(n), expire: (k) => void expired.push(k) }); useTasks.setState({ tasks: [] }); + useProject.setState({ project: null, lightEdit: false }); }); afterEach(() => vi.clearAllMocks()); @@ -163,6 +165,23 @@ describe("Check for Updates", () => { expect(notes.at(-1)?.title).toBe(title); expect(task(CHECK_TASK_ID)?.done).toBe(true); }); + + it("shows the check in a balloon where there is no status bar (the welcome screen)", async () => { + const done = checkForUpdates(); + expect(notes[0]).toMatchObject({ level: "info", title: "Checking for updates…", key: UPDATE_KEY }); + check.resolve(ok({ enabled: true, update: null })); + await done; + expect(notes.at(-1)).toMatchObject({ title: "OpenIDEA 0.1.21 is up to date", key: UPDATE_KEY }); + }); + + it("leaves the check to the status bar in a project window", async () => { + useProject.setState({ project: { root: "/p", name: "p", trusted: true, isGit: false, settings: {} } as never }); + const done = checkForUpdates(); + expect(notes).toHaveLength(0); + check.resolve(ok({ enabled: true, update: null })); + await done; + expect(notes.at(-1)?.title).toBe("OpenIDEA 0.1.21 is up to date"); + }); }); describe("progressText", () => { diff --git a/src/features/app/updates.tsx b/src/features/app/updates.tsx index 7cdae9924..e0451b859 100644 --- a/src/features/app/updates.tsx +++ b/src/features/app/updates.tsx @@ -1,7 +1,7 @@ /** * Update check and install. Both run as background tasks in the status bar: the - * check while the request is out, the install with download progress and Cancel, - * which its balloon shows too (the welcome screen has no status bar). + * check while the request is out, the install with download progress and Cancel. + * Their balloons show the progress too (the welcome screen has no status bar). * Every entry point (Help menu, toolbar gear, welcome screen, Search Everywhere, * the "is available" notification and its log entry) goes through these, so a * repeated click joins the running check or install instead of starting another. @@ -17,6 +17,7 @@ import { type UpdateInfo, type UpdateProgress, } from "@/platform/ipc"; +import { useProject } from "@/platform/project"; import { notifications } from "@/workbench/contributions"; import { upsertTask } from "@/workbench/tasks"; import { UpdateBalloonBody } from "./UpdateBalloonBody"; @@ -45,6 +46,15 @@ async function runCheck() { let abandon = () => {}; const abandoned = new Promise<"cancelled">((resolve) => (abandon = () => resolve("cancelled"))); upsertTask({ id: CHECK_TASK_ID, title, message: null, fraction: null, done: false, cancel: () => abandon() }); + // No status bar (the welcome screen): the balloon is the only place the check can show. + const { project, lightEdit } = useProject.getState(); + if (!installing && !project && !lightEdit) + notifications().notify({ + level: "info", + title, + body: , + key: UPDATE_KEY, + }); try { const r = await Promise.race([call(commands.appCheckUpdate()), abandoned]); if (r === "cancelled") return; diff --git a/src/workbench/activate.ts b/src/workbench/activate.ts index 71ea2b99d..2cc8abd9e 100644 --- a/src/workbench/activate.ts +++ b/src/workbench/activate.ts @@ -15,7 +15,7 @@ import { installBrowserGuards } from "./browserGuards"; import { installDistractionFree } from "./distractionFree"; import { installFileDrop } from "./fileDrop"; import { installFocusTracking } from "./focus"; -import { clearNotifications, installNotifyEvents, notificationServiceImpl } from "./notificationStore"; +import { clearProjectNotifications, installNotifyEvents, notificationServiceImpl } from "./notificationStore"; import { NOTIFICATIONS_TOOL_WINDOW, NotificationsHeaderActions, @@ -51,7 +51,7 @@ export function activateWorkbench(): () => void { const unsubProject = useProject.subscribe((s, prev) => { if (s.project?.root === prev.project?.root && s.lightEdit === prev.lightEdit) return; // The previous project's notifications (and their actions) no longer apply. - if (prev.project) clearNotifications(); + if (prev.project) clearProjectNotifications(); onProject(s.project?.root ?? null); }); diff --git a/src/workbench/notificationStore.ts b/src/workbench/notificationStore.ts index 5069884a5..2a0b4e26f 100644 --- a/src/workbench/notificationStore.ts +++ b/src/workbench/notificationStore.ts @@ -175,6 +175,11 @@ export function clearNotifications() { useNotifications.setState({ items: [] }); } +/** A project switch drops the previous project's notifications; app-wide ones (updates) stay. */ +export function clearProjectNotifications() { + useNotifications.setState((s) => ({ items: s.items.filter((x) => x.group === "updates") })); +} + /** The list was seen: everything is read and no longer shown as a balloon. */ export function markAllNotificationsRead() { useNotifications.setState((s) => ({ diff --git a/src/workbench/projectNotifications.test.ts b/src/workbench/projectNotifications.test.ts new file mode 100644 index 000000000..9294cab58 --- /dev/null +++ b/src/workbench/projectNotifications.test.ts @@ -0,0 +1,26 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { useProject } from "@/platform/project"; +import { activateWorkbench } from "./activate"; +import { notify, useNotifications } from "./notificationStore"; + +afterEach(() => { + useNotifications.setState({ items: [] }); + useProject.setState({ project: null, lightEdit: false }); +}); + +describe("notifications across project changes", () => { + it("keeps app-level update notifications and drops project ones", () => { + useProject.setState({ + project: { root: "/p", name: "p", trusted: true, isGit: false, settings: {} } as never, + }); + const dispose = activateWorkbench(); + try { + notify({ level: "info", title: "Downloading OpenIDEA 0.1.35", key: "app.update", sticky: true }); + notify({ level: "info", title: "Git refresh failed", group: "vcs" }); + useProject.setState({ project: null }); + expect(useNotifications.getState().items.map((n) => n.title)).toEqual(["Downloading OpenIDEA 0.1.35"]); + } finally { + dispose(); + } + }); +}); From 2161a2ca03876418a3cc18d56609702fade0c8fa Mon Sep 17 00:00:00 2001 From: arookieofc <2128194521@qq.com> Date: Tue, 3 Nov 2026 14:39:29 +0800 Subject: [PATCH 053/106] fix(lsp): serialize the jdt.ls classpath resolve with the build lock (#946) Running a main class in a multi-module Maven project could fail with "Cannot resolve the classpath: jdtls: Attempted to beginRule: P/, does not match outer scope rule: P/". `java.project.getClasspaths` and `getSettings` ran outside the jdt.ls build lock, so they could overlap a build or an m2e refresh of a sibling module that takes a nested Eclipse scheduling rule. The launch now resolves the classpath and JDK under the same build lock the build commands use. A delegated external build already holds that lock across the launch, so its callers pass `build_lock_held` instead of waiting on it again, which would deadlock. Fixes #933. Co-authored-by: a-rookie-of-C-language <186315048+a-rookie-of-C-language@users.noreply.github.com> --- crates/ide-host/src/ops/java.rs | 2 +- crates/ide-lsp/src/jdtls.rs | 27 +++++++++++++++++------ crates/ide-lsp/tests/mock_server.rs | 34 +++++++++++++++++++++++++++++ 3 files changed, 55 insertions(+), 8 deletions(-) diff --git a/crates/ide-host/src/ops/java.rs b/crates/ide-host/src/ops/java.rs index d1cc0a616..00da18039 100644 --- a/crates/ide-host/src/ops/java.rs +++ b/crates/ide-host/src/ops/java.rs @@ -59,7 +59,7 @@ async fn resolve_launch( .map_err(|e| IdeError::internal(e.to_string()))? }; let external_build = if delegated { Some(project.lsp.java_external_build(&module, cancel).await?) } else { None }; - let mut info = project.lsp.java_launch(&module, test, build && !delegated, cancel).await?; + let mut info = project.lsp.java_launch(&module, test, build && !delegated, delegated, cancel).await?; let lsp_ms = started.elapsed().as_millis(); let data_dir = host.local_data_dir.clone(); let cancel = cancel.clone(); diff --git a/crates/ide-lsp/src/jdtls.rs b/crates/ide-lsp/src/jdtls.rs index 8198a443c..3b84f1563 100644 --- a/crates/ide-lsp/src/jdtls.rs +++ b/crates/ide-lsp/src/jdtls.rs @@ -521,11 +521,15 @@ impl LspManager { } /// Builds (when asked), then resolves the classpath and JDK of the module at `module`. + /// The classpath queries take the module's workspace scheduling rule like the build + /// commands do, so they run under the build lock; pass `build_lock_held` when the caller + /// already holds it (the delegated external build), or the wait would deadlock. pub async fn java_launch( &self, module: &Path, test: bool, build: bool, + build_lock_held: bool, cancel: &CancellationToken, ) -> IdeResult { let build = if build { Some(self.java_build(module, cancel).await?) } else { None }; @@ -538,14 +542,23 @@ impl LspManager { } _ => (Vec::new(), 0), }; - let cp = self.java_classpath(module, test, cancel).await?; - let java_home = match self.java_vm_location(module, cancel).await { - Ok(home) => home, - Err(e) if e.kind == ErrorKind::Cancelled => return Err(e), - Err(e) => { - tracing::debug!("java.project.getSettings: {e}"); + let (cp, java_home) = { + let (server, _) = self.jdtls(module, cancel).await?; + let _lock = if build_lock_held { None - } + } else { + Some(cancellable_wait(cancel, server.java_build_lock.lock()).await?) + }; + let cp = self.java_classpath(module, test, cancel).await?; + let java_home = match self.java_vm_location(module, cancel).await { + Ok(home) => home, + Err(e) if e.kind == ErrorKind::Cancelled => return Err(e), + Err(e) => { + tracing::debug!("java.project.getSettings: {e}"); + None + } + }; + (cp, java_home) }; Ok(JavaLaunchInfo { build, diff --git a/crates/ide-lsp/tests/mock_server.rs b/crates/ide-lsp/tests/mock_server.rs index b33110a20..dc66dd967 100644 --- a/crates/ide-lsp/tests/mock_server.rs +++ b/crates/ide-lsp/tests/mock_server.rs @@ -1166,6 +1166,40 @@ async fn unmanaged_java_run_imports_sources_without_an_open_editor() { assert!(state.notified("textDocument/didOpen").is_empty()); } +#[tokio::test(flavor = "multi_thread")] +async fn java_launch_serializes_the_classpath_with_the_build_lock() { + let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]); + let (_, _file) = h.open("Main.java", "public class Main {}"); + h.wait_status("jdtls", ServerStatus::Ready).await; + let state = h.launcher.state("jdtls"); + state.respond("java/buildWorkspace", json!(1)); + + // A launch with a build re-takes the lock after java_build has released it. + let info = tokio::time::timeout( + Duration::from_secs(30), + h.mgr.java_launch(&h.root, false, true, false, &Default::default()), + ) + .await + .expect("java_launch deadlocked on the build lock") + .unwrap(); + assert!(info.class_paths.is_empty()); + + // The delegated external build holds the lock while java_launch resolves the + // classpath; java_launch must not re-take it, or the launch would deadlock. + let guard = h.mgr.java_external_build(&h.root, &Default::default()).await.unwrap(); + tokio::time::timeout(Duration::from_secs(30), h.mgr.java_launch(&h.root, false, false, true, &Default::default())) + .await + .expect("java_launch deadlocked on the delegated external build's lock") + .unwrap(); + drop(guard); + let commands: Vec<_> = state + .requested("workspace/executeCommand") + .iter() + .filter_map(|p| p["command"].as_str().map(str::to_owned)) + .collect(); + assert!(commands.contains(&"java.project.getClasspaths".to_owned())); +} + #[tokio::test(flavor = "multi_thread")] async fn fallback_servers_only_run_when_the_preferred_one_is_missing() { // Preferred installed: the alternative is never started. From 038c23a2c8be7414d6e0e5b7afb6a7c11d8234b2 Mon Sep 17 00:00:00 2001 From: Jaw <53564781+jaw52@users.noreply.github.com> Date: Tue, 3 Nov 2026 14:40:18 +0800 Subject: [PATCH 054/106] feat(vcs): tint test files green in the changes tree (#940) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The changes tree in the Commit and Local Changes views now gives test files a light green background, so test changes stand out from production ones. A file counts as a test when it lies under a test source root (detected or marked by hand) or is named like a test (`*.test.ts`, `*Test.java`, …), the same check the project tree uses; folders under a test root get the tint too. Hover and selection still win. The colour is a new `--bg-file-tests` token derived from `--success` and `--bg-island`, so every theme gets a matching tint, and it follows test roots as they are marked or unmarked. Fixes #931. Co-authored-by: jaw52 <53564781+jaw52@users.noreply.github.com> --- src/features/vcs/CommitView.module.css | 4 ++ src/features/vcs/CommitView.tsx | 5 ++ .../vcs/__tests__/testFileColor.test.tsx | 65 +++++++++++++++++++ src/styles/tokens.css | 2 + 4 files changed, 76 insertions(+) create mode 100644 src/features/vcs/__tests__/testFileColor.test.tsx diff --git a/src/features/vcs/CommitView.module.css b/src/features/vcs/CommitView.module.css index bfe5eb2e0..6f69a9c53 100644 --- a/src/features/vcs/CommitView.module.css +++ b/src/features/vcs/CommitView.module.css @@ -218,6 +218,10 @@ gap: 4px; border-radius: var(--radius-small); } +/* Test files and the folders under a test root, as IDEA's green "Tests" file colour; hover and selection win. */ +.testFile:not(:hover):not([aria-selected="true"]) { + background: var(--bg-file-tests); +} .dropTarget { box-shadow: inset 0 0 0 1px var(--accent); background: color-mix(in srgb, var(--accent) 12%, transparent); diff --git a/src/features/vcs/CommitView.tsx b/src/features/vcs/CommitView.tsx index b809529d6..cfb95a41e 100644 --- a/src/features/vcs/CommitView.tsx +++ b/src/features/vcs/CommitView.tsx @@ -8,6 +8,7 @@ import { tx } from "@/platform/i18n"; import clsx from "clsx"; import { executeAction } from "@/platform/actions"; import { dirname } from "@/platform/project"; +import { isTestFile, useEffectiveRoots } from "@/platform/projectRoots"; import { Button, Checkbox, @@ -398,6 +399,7 @@ export function ChangesTree({ mode }: { mode: "commit" | "local" | "dialog" }) { const viewRef = useRef(null); const repos = useVcs((s) => s.roots); + const projectRoots = useEffectiveRoots(); const roots = useMemo( () => buildChangeTree(status, root, groupByDir, lists, repos), [status, root, groupByDir, lists, repos], @@ -524,6 +526,9 @@ export function ChangesTree({ mode }: { mode: "commit" | "local" | "dialog" }) { getLabel={nodeText} getDirectory={(n) => (n.path && n.type !== "group" ? (n.type === "dir" ? n.path : dirname(n.path)) : null)} getChildren={(n) => n.children} + rowClassName={(n) => + n.type !== "group" && n.path && isTestFile(n.path, projectRoots) ? styles.testFile : undefined + } isExpandable={(n) => n.type !== "file"} expanded={expanded} onExpandedChange={(_next, node, open) => diff --git a/src/features/vcs/__tests__/testFileColor.test.tsx b/src/features/vcs/__tests__/testFileColor.test.tsx new file mode 100644 index 000000000..83d71f606 --- /dev/null +++ b/src/features/vcs/__tests__/testFileColor.test.tsx @@ -0,0 +1,65 @@ +import { act, render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/platform/ipc", async (importOriginal) => { + const real = await importOriginal(); + return { ...real, call: vi.fn() }; +}); + +vi.mock("../DiffEditor", () => ({ DiffEditor: () =>
    })); + +import { ChangesTree } from "../CommitView"; +import { useVcs } from "../store"; +import { useProject } from "@/platform/project"; +import { useProjectRoots } from "@/platform/projectRoots"; + +const change = (relPath: string) => ({ + path: `/p/${relPath}`, + relPath, + staged: null, + unstaged: "modified" as const, + renamedFrom: null, +}); + +/** The tree row (`role=treeitem`) showing `name`. */ +const row = (name: string) => screen.getByText(name).closest('[role="treeitem"]') as HTMLElement; + +describe("test files in the changes tree (#931)", () => { + beforeEach(() => { + localStorage.clear(); + useProject.setState({ + project: { name: "p", root: "/p", trusted: true, isGit: true, settings: {} } as never, + settings: {} as never, + }); + useProjectRoots.setState({ + root: "/p", + detected: [{ path: "src/test/java", kind: "test", detectedFrom: "pom.xml" }], + }); + useVcs.setState({ + root: "/p", + repo: "/p", + roots: ["/p"], + status: [ + change("src/main/java/App.java"), + change("src/test/java/AppTest.java"), + change("web/button.test.ts"), + change("web/button.ts"), + ], + }); + }); + + it("tints files under a test root or named like a test, as IDEA's Tests scope does", () => { + render(); + expect(row("AppTest.java").className).toMatch(/testFile/); + expect(row("button.test.ts").className).toMatch(/testFile/); + expect(row("App.java").className).not.toMatch(/testFile/); + expect(row("button.ts").className).not.toMatch(/testFile/); + }); + + it("follows a folder being marked as test sources", () => { + render(); + expect(row("App.java").className).not.toMatch(/testFile/); + act(() => useProjectRoots.setState({ detected: [{ path: "src/main/java", kind: "test", detectedFrom: null }] })); + expect(row("App.java").className).toMatch(/testFile/); + }); +}); diff --git a/src/styles/tokens.css b/src/styles/tokens.css index ac12c4d87..6d27f3134 100644 --- a/src/styles/tokens.css +++ b/src/styles/tokens.css @@ -314,6 +314,8 @@ html { --bg-tree-selected: #2a4371; --bg-tree-selected-inactive: #33353b; --bg-tree-hover: #ffffff10; + /* IDEA's "Tests" file colour (FileColor.Green), derived so every theme gets a matching tint. */ + --bg-file-tests: color-mix(in srgb, var(--success) 14%, var(--bg-island)); --bg-list-selected: var(--bg-tree-selected); --bg-toolbar-hover: #ffffff17; --bg-toolbar-pressed: #ffffff29; From b55c398fb5f784751fcffc778a4d00a4a49f1343 Mon Sep 17 00:00:00 2001 From: Piettro <2058578+pcastro93@users.noreply.github.com> Date: Tue, 3 Nov 2026 01:42:45 -0500 Subject: [PATCH 055/106] feat(python): add Pyrefly language server (#911) Pyrefly (`pyrefly lsp`) joins basedpyright, Pyright and ty as a fallback Python language server. It is installable from Tools as a managed virtualenv tool and is listed among the interchangeable type checkers, before Ruff, so Ruff still runs after them. Pyrefly only reports type errors in projects with a Pyrefly configuration (`pyrefly.toml` or `[tool.pyrefly]`); hover, completion and navigation work without one. A real-server test (ignored by default, needs `pyrefly` on PATH) covers startup, go-to-definition and diagnostics. Co-authored-by: pcastro93 <2058578+pcastro93@users.noreply.github.com> --- crates/ide-language/src/registry.rs | 4 +- crates/ide-lsp/tests/real_servers.rs | 40 +++++++++++++++++++ crates/ide-sdk/src/apply.rs | 2 +- crates/ide-tools/src/registry.rs | 11 +++++ crates/ide-tools/src/tests.rs | 7 +++- languages/python.toml | 8 ++++ .../tools/__tests__/serverSettings.test.tsx | 7 ++-- src/features/tools/backend.ts | 13 ++++-- src/platform/locales/textCatalog.test.ts | 1 + 9 files changed, 83 insertions(+), 10 deletions(-) diff --git a/crates/ide-language/src/registry.rs b/crates/ide-language/src/registry.rs index d777ba370..ee48ab612 100644 --- a/crates/ide-language/src/registry.rs +++ b/crates/ide-language/src/registry.rs @@ -553,7 +553,7 @@ mod tests { fn alternative_servers_are_fallbacks() { let reg = reg(); for (lang, servers) in [ - ("python", vec!["basedpyright", "pyright", "ty"]), + ("python", vec!["basedpyright", "pyright", "ty", "pyrefly"]), ("typescript", vec!["vtsls", "typescript-language-server"]), ] { let cfg = reg.get(&LanguageId::new(lang)).unwrap(); @@ -610,7 +610,7 @@ mod tests { let cfg = reg.get(&LanguageId::new("python")).unwrap(); let names: Vec<&str> = cfg.language_servers.iter().map(|s| s.name.as_str()).collect(); // After the fallbacks: hover, rename and symbols stay with the type checker. - assert_eq!(names, ["basedpyright", "pyright", "ty", "ruff"]); + assert_eq!(names, ["basedpyright", "pyright", "ty", "pyrefly", "ruff"]); let ruff = cfg.language_servers.iter().find(|s| s.name == "ruff").unwrap(); assert_eq!((ruff.command.as_str(), ruff.args.as_slice()), ("ruff", ["server".to_string()].as_slice())); assert!(ruff.require_root_marker && !ruff.fallback); diff --git a/crates/ide-lsp/tests/real_servers.rs b/crates/ide-lsp/tests/real_servers.rs index ace064017..4ed350101 100644 --- a/crates/ide-lsp/tests/real_servers.rs +++ b/crates/ide-lsp/tests/real_servers.rs @@ -250,6 +250,46 @@ async fn ruff_next_to_basedpyright_end_to_end() { tokio::time::timeout(Duration::from_secs(10), mgr.shutdown_all()).await.unwrap(); } +#[tokio::test(flavor = "multi_thread")] +#[ignore = "requires pyrefly on PATH"] +async fn pyrefly_python_end_to_end() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().canonicalize().unwrap(); + let path = root.join("example.py"); + let source = "def greet(name: str) -> str:\n return name\n\nresult = greet('Ada')\nbad: int = 'wrong'\n"; + std::fs::write(&path, source).unwrap(); + // Pyrefly only reports type errors in projects with a Pyrefly configuration. + std::fs::write(root.join("pyrefly.toml"), "").unwrap(); + let mut python = LanguageRegistry::load(None).unwrap().get(&LanguageId::new("python")).unwrap(); + python.language_servers.retain(|server| server.name == "pyrefly"); + assert_eq!(python.language_servers.len(), 1); + let (mgr, events) = manager(&root, LanguageRegistry::from_configs(vec![python])); + let doc = DocId::next(); + mgr.did_open(doc, &path, &LanguageId::new("python"), 1, source); + retry("pyrefly ready", Duration::from_secs(30), || { + let status = last_status(&events, "pyrefly"); + if let Some((ServerStatus::Crashed | ServerStatus::Missing | ServerStatus::Misconfigured, msg)) = &status { + panic!("pyrefly failed: {msg:?}"); + } + async move { matches!(status, Some((ServerStatus::Ready | ServerStatus::Busy, _))).then_some(()) } + }) + .await; + let defs = retry("pyrefly definition", Duration::from_secs(30), || { + let mgr = mgr.clone(); + async move { mgr.goto(doc, Pos::new(3, 10), GotoKind::Definition).await.ok().filter(|d| !d.is_empty()) } + }) + .await; + assert_eq!(defs[0].path, path); + assert_eq!(defs[0].range.start.line, 0); + retry("pyrefly diagnostics", Duration::from_secs(30), || { + let found = events.lock().iter().any( + |event| matches!(event, IdeEvent::Diagnostics { source, items, .. } if source == "pyrefly" && !items.is_empty()), + ); + async move { found.then_some(()) } + }) + .await; +} + #[tokio::test(flavor = "multi_thread")] #[ignore = "requires rust-analyzer"] async fn rust_analyzer_end_to_end() { diff --git a/crates/ide-sdk/src/apply.rs b/crates/ide-sdk/src/apply.rs index ec173c954..3f368d469 100644 --- a/crates/ide-sdk/src/apply.rs +++ b/crates/ide-sdk/src/apply.rs @@ -204,7 +204,7 @@ pub fn tsdk(root: &Path, s: &Settings) -> Option { } } -const PYTHON_SERVERS: [&str; 6] = ["basedpyright", "pyright", "ty", "pylsp", "jedi-language-server", "ruff"]; +const PYTHON_SERVERS: [&str; 7] = ["basedpyright", "pyright", "ty", "pyrefly", "pylsp", "jedi-language-server", "ruff"]; fn merge(base: &mut Value, layer: Value) { match (base, layer) { diff --git a/crates/ide-tools/src/registry.rs b/crates/ide-tools/src/registry.rs index 755da795b..6602f8094 100644 --- a/crates/ide-tools/src/registry.rs +++ b/crates/ide-tools/src/registry.rs @@ -233,6 +233,17 @@ pub static TOOLS: &[ToolSpec] = &[ homepage: "https://docs.astral.sh/ty/", needs: &[], }, + ToolSpec { + name: "pyrefly", + display: "Pyrefly (Python)", + kind: LanguageServer, + commands: &["pyrefly"], + method: Method::PipVenv { package: "pyrefly" }, + fallback: None, + version: ver("pyrefly"), + homepage: "https://pyrefly.org", + needs: &[], + }, ToolSpec { name: "ruff", display: "Ruff", diff --git a/crates/ide-tools/src/tests.rs b/crates/ide-tools/src/tests.rs index 40112196f..7b15ba32c 100644 --- a/crates/ide-tools/src/tests.rs +++ b/crates/ide-tools/src/tests.rs @@ -130,6 +130,11 @@ fn everything_missing_on_an_empty_system() { assert_eq!(ty.command, "ty"); assert_eq!(ty.install_method.as_deref(), Some("pip (virtualenv)")); assert!(ty.languages.iter().any(|language| language == "Python")); + let pyrefly = find(&list, "pyrefly"); + assert_eq!(pyrefly.status, ToolStatus::Missing); + assert_eq!(pyrefly.command, "pyrefly"); + assert_eq!(pyrefly.install_method.as_deref(), Some("pip (virtualenv)")); + assert!(pyrefly.languages.iter().any(|language| language == "Python")); let angular = find(&list, "angular-language-server"); assert_eq!(angular.command, "ngserver"); assert_eq!(angular.install_method.as_deref(), Some("npm")); @@ -150,7 +155,7 @@ fn everything_missing_on_an_empty_system() { fn interchangeable_servers_list_their_alternatives() { let env = Env::new(); let list = env.tools().list(&langs()); - let python = ["basedpyright", "pyright", "ty"]; + let python = ["basedpyright", "pyright", "ty", "pyrefly"]; for name in python { assert_eq!(find(&list, name).alternatives, python); } diff --git a/languages/python.toml b/languages/python.toml index 1d3b9504b..a9b4ea922 100644 --- a/languages/python.toml +++ b/languages/python.toml @@ -40,6 +40,14 @@ args = ["server"] root-markers = ["ty.toml", "pyproject.toml", "setup.py", "setup.cfg", "requirements.txt", "Pipfile"] install-hint = "uv tool install ty (or: pip install ty)" +[[language-servers]] +name = "pyrefly" +fallback = true +command = "pyrefly" +args = ["lsp"] +root-markers = ["pyrefly.toml", "pyproject.toml", "setup.py", "setup.cfg", "requirements.txt", "Pipfile"] +install-hint = "uv tool install pyrefly (or: pip install pyrefly)" + # Lint, format, organize imports and fix all next to the type checker, in # projects configured for Ruff (formatting is Ruff's alone: basedpyright has none). [[language-servers]] diff --git a/src/features/tools/__tests__/serverSettings.test.tsx b/src/features/tools/__tests__/serverSettings.test.tsx index 2f96403d4..a176a9948 100644 --- a/src/features/tools/__tests__/serverSettings.test.tsx +++ b/src/features/tools/__tests__/serverSettings.test.tsx @@ -89,7 +89,7 @@ describe("Language Servers page settings", () => { const ty = details("ty (Python)"); expect(within(ty).getByRole("button", { name: "Language server for Python" })).toHaveTextContent("Automatic"); // The language is in the label already, so names drop a "(Python)" suffix. - expect(within(ty).getByText(/basedpyright → Pyright → ty\.$/)).toBeInTheDocument(); + expect(within(ty).getByText(/basedpyright → Pyright → ty → Pyrefly\.$/)).toBeInTheDocument(); await user.click(listItem("gopls")!); expect(within(details("gopls")).getByRole("checkbox", { name: /Use gopls/ })).toBeInTheDocument(); expect(within(details("gopls")).queryByRole("button", { name: /^Language server for/ })).toBeNull(); @@ -108,7 +108,7 @@ describe("Language Servers page settings", () => { expect(picker).toHaveTextContent("ty"); await user.click(picker); await user.click(screen.getByRole("option", { name: /Automatic/ })); - for (const name of ["basedpyright", "pyright", "ty"]) { + for (const name of ["basedpyright", "pyright", "ty", "pyrefly"]) { expect(stagedFor(`languageServers.${name}.enabled`)).toBeNull(); expect(useTestForm.getState().values).not.toHaveProperty(`languageServers.${name}.enabled`); } @@ -127,10 +127,11 @@ describe("Language Servers page settings", () => { expect(stagedFor("languageServers.basedpyright.enabled")).toBe(false); expect(stagedFor("languageServers.pyright.enabled")).toBe(true); expect(stagedFor("languageServers.ty.enabled")).toBe(false); + expect(stagedFor("languageServers.pyrefly.enabled")).toBe(false); expect(picker).toHaveTextContent("Pyright"); await user.click(picker); await user.click(screen.getByRole("option", { name: /Automatic/ })); - for (const name of ["basedpyright", "pyright", "ty"]) { + for (const name of ["basedpyright", "pyright", "ty", "pyrefly"]) { expect(stagedFor(`languageServers.${name}.enabled`)).toBe(true); } }); diff --git a/src/features/tools/backend.ts b/src/features/tools/backend.ts index f472418a3..f9dded63f 100644 --- a/src/features/tools/backend.ts +++ b/src/features/tools/backend.ts @@ -94,13 +94,20 @@ export function sampleTools(): ToolInfo[] { version: "4.3.3", path: "/opt/homebrew/bin/typescript-language-server", }), - ...["basedpyright", "pyright", "ty"].map((name) => + ...["basedpyright", "pyright", "ty", "pyrefly"].map((name) => tool({ name, - displayName: name === "pyright" ? "Pyright" : name === "ty" ? "ty (Python)" : name, + displayName: + name === "pyright" + ? "Pyright" + : name === "ty" + ? "ty (Python)" + : name === "pyrefly" + ? "Pyrefly (Python)" + : name, languages: ["Python"], installMethod: "uv (virtualenv)", - alternatives: ["basedpyright", "pyright", "ty"], + alternatives: ["basedpyright", "pyright", "ty", "pyrefly"], }), ), tool({ name: "gopls", languages: ["Go"], installMethod: "go install" }), diff --git a/src/platform/locales/textCatalog.test.ts b/src/platform/locales/textCatalog.test.ts index 1bba83ce9..380a72faf 100644 --- a/src/platform/locales/textCatalog.test.ts +++ b/src/platform/locales/textCatalog.test.ts @@ -149,6 +149,7 @@ const UNTRANSLATED = new Set([ "OpenIDEA", "Spring Boot", "PostgreSQL", + "Pyrefly", "Pyright", "pytest", "Rust", From 18e16e76a409a03c59b96ab03f53ba747929d1bd Mon Sep 17 00:00:00 2001 From: arookieofc <2128194521@qq.com> Date: Tue, 3 Nov 2026 14:50:49 +0800 Subject: [PATCH 056/106] fix(desktop): pass the real window label to project-requiring commands (#960) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add Interpreter (creating a virtualenv), the Python packages list and pip install/uninstall, Settings › Git › Test, JVM listen-mode debugging and Create Patch › Save to file failed with "no project is open in this window", or ran without the project. Their commands passed an empty window label to ops that resolve the calling window's project. They now pass the real window label, as the other project-scoped commands do. A sweep of every command that passes an empty label found exactly these seven ops using it: `sdk_locate_tools`, `sdk_python_create_venv`, `sdk_python_packages`, `sdk_python_pip`, `vcs_git_version`, `debug_listen_for_jvm` and `vcs_write_patch`. Without the window, the last one skipped the project's disk sync, so a patch saved inside the project showed up as an external change. Fixes #955. Co-authored-by: a-rookie-of-C-language <186315048+a-rookie-of-C-language@users.noreply.github.com> --- src-tauri/src/commands/debug.rs | 3 ++- src-tauri/src/commands/sdk.rs | 13 +++++++++---- src-tauri/src/commands/vcs.rs | 13 +++++++++---- 3 files changed, 20 insertions(+), 9 deletions(-) diff --git a/src-tauri/src/commands/debug.rs b/src-tauri/src/commands/debug.rs index d9cffa65e..eb2134609 100644 --- a/src-tauri/src/commands/debug.rs +++ b/src-tauri/src/commands/debug.rs @@ -261,12 +261,13 @@ pub async fn debug_attachable_processes(state: State<'_, AppState>) -> IdeResult #[tauri::command] #[specta::specta] pub async fn debug_listen_for_jvm( + window: Window, state: State<'_, AppState>, request: ide_core::RequestId, host: String, port: u16, ) -> IdeResult { - ide_host::ops::debug::debug_listen_for_jvm(&state.host, "", request, host, port).await + ide_host::ops::debug::debug_listen_for_jvm(&state.host, window.label(), request, host, port).await } /// HotSwap (Reload Changed Classes) in a java-debug session. diff --git a/src-tauri/src/commands/sdk.rs b/src-tauri/src/commands/sdk.rs index 92a8ea471..050c2c101 100644 --- a/src-tauri/src/commands/sdk.rs +++ b/src-tauri/src/commands/sdk.rs @@ -39,17 +39,19 @@ pub async fn sdk_project_libraries( #[tauri::command] #[specta::specta] pub async fn sdk_locate_tools( + window: Window, state: State<'_, AppState>, names: Vec, near: Option, ) -> IdeResult { - ide_host::ops::sdk::sdk_locate_tools(&state.host, "", names, near).await + ide_host::ops::sdk::sdk_locate_tools(&state.host, window.label(), names, near).await } /// `base -m venv location`; output lines stream through `on_output`. #[tauri::command] #[specta::specta] pub async fn sdk_python_create_venv( + window: Window, state: State<'_, AppState>, base: PathBuf, location: PathBuf, @@ -58,7 +60,7 @@ pub async fn sdk_python_create_venv( ) -> IdeResult { ide_host::ops::sdk::sdk_python_create_venv( &state.host, - "", + window.label(), base, location, request, @@ -70,17 +72,20 @@ pub async fn sdk_python_create_venv( #[tauri::command] #[specta::specta] pub async fn sdk_python_packages( + window: Window, state: State<'_, AppState>, python: PathBuf, request: RequestId, ) -> IdeResult> { - ide_host::ops::sdk::sdk_python_packages(&state.host, "", python, request).await + ide_host::ops::sdk::sdk_python_packages(&state.host, window.label(), python, request).await } /// Installs (`uninstall = false`) or removes a package; output streams through `on_output`. #[tauri::command] #[specta::specta] +#[allow(clippy::too_many_arguments)] pub async fn sdk_python_pip( + window: Window, state: State<'_, AppState>, python: PathBuf, requirement: String, @@ -91,7 +96,7 @@ pub async fn sdk_python_pip( ) -> IdeResult<()> { ide_host::ops::sdk::sdk_python_pip( &state.host, - "", + window.label(), python, requirement, uninstall, diff --git a/src-tauri/src/commands/vcs.rs b/src-tauri/src/commands/vcs.rs index 305fd0e8c..0e06e2f5e 100644 --- a/src-tauri/src/commands/vcs.rs +++ b/src-tauri/src/commands/vcs.rs @@ -371,8 +371,8 @@ pub async fn vcs_init(state: State<'_, AppState>, dir: PathBuf) -> IdeResult<()> /// executable when `path` is empty. #[tauri::command] #[specta::specta] -pub async fn vcs_git_version(state: State<'_, AppState>, path: String) -> IdeResult { - ide_host::ops::vcs::vcs_git_version(&state.host, "", path).await +pub async fn vcs_git_version(window: Window, state: State<'_, AppState>, path: String) -> IdeResult { + ide_host::ops::vcs::vcs_git_version(&state.host, window.label(), path).await } /// The auto-detected git executable (shown as the field's placeholder). @@ -656,8 +656,13 @@ pub fn vcs_changelists_save(window: Window, state: State<'_, AppState>, changeli /// Create Patch › Save to file. #[tauri::command] #[specta::specta] -pub async fn vcs_write_patch(state: State<'_, AppState>, path: PathBuf, patch: String) -> IdeResult<()> { - ide_host::ops::vcs::vcs_write_patch(&state.host, "", path, patch).await +pub async fn vcs_write_patch( + window: Window, + state: State<'_, AppState>, + path: PathBuf, + patch: String, +) -> IdeResult<()> { + ide_host::ops::vcs::vcs_write_patch(&state.host, window.label(), path, patch).await } #[tauri::command] From fcfe26474de69ce47e921c74370d1c722d6c7aa0 Mon Sep 17 00:00:00 2001 From: arookieofc <2128194521@qq.com> Date: Tue, 3 Nov 2026 14:56:56 +0800 Subject: [PATCH 057/106] fix(format): apply the Code Style indent override to reformat requests (#949) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings › Editor › Code Style › Java › Tabs and Indents had no effect on Reformat Code: a file indented with 4 spaces stayed that way after choosing "Use tab character" with tab size and indent 1. There were two causes. The reformat request sent the editor's indent, which comes from the indents detected in the file, instead of the Code Style; and jdt.ls takes the per-level indent width (`indentation.size`) only from its formatter profile, never from the request. With an explicit Tabs and Indents override for the language, Reformat Code now asks for the Code Style indent (EditorConfig still applies, the detected indents don't; without an override the editor's indent is kept). The jdt.ls profile carries the same override: it is patched into the bundled IntelliJ profile, or put into a minimal generated profile for the default formatter, and custom profiles are left alone. An unset Indent follows the tab size, as in Code Style. With tabs the request sends the tab size; every editor of a document gets the override when it is created, splits and reopened tabs included, not only after a settings change. Checked with jdt.ls 1.63 on JDK 25: tab character, tab size 1 and indent 1 reformat a 4-space file to one tab per level with both the default and the IntelliJ formatter. Fixes #935. Co-authored-by: a-rookie-of-C-language <186315048+a-rookie-of-C-language@users.noreply.github.com> --- crates/ide-lsp/src/jdtls/formatter.rs | 146 ++++++++++++++++++++++++- src/editor/core/cm/setup.ts | 21 +++- src/editor/core/model/document.test.ts | 19 ++++ src/editor/core/model/document.ts | 15 ++- src/editor/core/model/slot.ts | 1 + src/editor/lsp/format.test.ts | 37 ++++++- src/editor/lsp/format.ts | 12 +- 7 files changed, 242 insertions(+), 9 deletions(-) diff --git a/crates/ide-lsp/src/jdtls/formatter.rs b/crates/ide-lsp/src/jdtls/formatter.rs index f7c19dea6..18c2b3cd0 100644 --- a/crates/ide-lsp/src/jdtls/formatter.rs +++ b/crates/ide-lsp/src/jdtls/formatter.rs @@ -7,6 +7,61 @@ use std::path::Path; const PROFILE: &str = "OpenIDEA IntelliJ IDEA"; const XML: &str = include_str!("intellij.xml"); +const GENERATED_PROFILE: &str = "OpenIDEA Code Style"; +const TAB_CHAR: &str = "org.eclipse.jdt.core.formatter.tabulation.char"; +const TAB_SIZE: &str = "org.eclipse.jdt.core.formatter.tabulation.size"; +const INDENT_SIZE: &str = "org.eclipse.jdt.core.formatter.indentation.size"; + +/// The Tabs and Indents override as Eclipse formatter settings. The profile must +/// carry them too: `indentation.size`, the per-level width, cannot come from the +/// format request, and an unset Indent follows the tab size, as in IDEA. +fn indent_settings(style: &LanguageCodeStyle) -> Option> { + let tab_char = style.use_tab.map(|tab| if tab { "tab" } else { "space" }); + let indent = style.indent.or(style.tab_size); + if tab_char.is_none() && style.tab_size.is_none() && indent.is_none() { + return None; + } + let mut settings: Vec<(&'static str, String)> = Vec::new(); + if let Some(char) = tab_char { + settings.push((TAB_CHAR, char.to_owned())); + } + if let Some(size) = style.tab_size { + settings.push((TAB_SIZE, size.to_string())); + } + if let Some(size) = indent { + settings.push((INDENT_SIZE, size.to_string())); + } + Some(settings) +} + +fn patch_profile_xml(mut xml: String, settings: &[(&'static str, String)]) -> String { + for (id, value) in settings { + let marker = format!(r#" String { + let mut xml = format!( + r#" + + +"#, + GENERATED_PROFILE + ); + for (id, value) in settings { + xml.push_str(&format!(r#" "#)); + xml.push('\n'); + } + xml.push_str(" \n\n"); + xml +} /// Protocol settings only: switching profiles reaches a running jdtls through /// didChangeConfiguration. Explicit language-server settings are applied later. @@ -31,12 +86,17 @@ pub fn configure_formatter( settings["java"]["format"]["settings"] = formatter_overrides(defaults, overrides); cfg.settings = Some(settings); let Some(style) = style else { return Ok(()) }; + let indent = indent_settings(style); let (path, profile) = match style.formatter.as_deref() { Some("intellij") => { let path = data_dir.join("lsp/jdtls/intellij.xml"); std::fs::create_dir_all(path.parent().unwrap())?; - if std::fs::read_to_string(&path).ok().as_deref() != Some(XML) { - ide_vfs::write_atomic(&path, XML.as_bytes())?; + let xml = match indent { + Some(indent) => patch_profile_xml(XML.to_owned(), &indent), + None => XML.to_owned(), + }; + if std::fs::read_to_string(&path).ok().as_deref() != Some(xml.as_str()) { + ide_vfs::write_atomic(&path, xml.as_bytes())?; } (path, PROFILE) } @@ -54,9 +114,22 @@ pub fn configure_formatter( if !path.is_file() { return Err(ide_core::IdeError::invalid(format!("Not a formatter XML file: {}", path.display()))); } + // The user's profile defines the style; the override stays editor-only. (path, "") } - _ => return Ok(()), + _ => { + // Default formatter: with a Tabs and Indents override, hand jdtls a + // minimal generated profile carrying it — the format request cannot + // set indentation.size. Without an override the built-in style stays. + let Some(indent) = indent else { return Ok(()) }; + let path = data_dir.join("lsp/jdtls/indent.xml"); + std::fs::create_dir_all(path.parent().unwrap())?; + let xml = indent_profile_xml(&indent); + if std::fs::read_to_string(&path).ok().as_deref() != Some(xml.as_str()) { + ide_vfs::write_atomic(&path, xml.as_bytes())?; + } + (path, GENERATED_PROFILE) + } }; let mut settings = cfg.settings.take().unwrap_or_else(|| json!({})); settings["java"]["format"]["settings"] = formatter_overrides( @@ -144,6 +217,73 @@ mod tests { } } + #[test] + fn tabs_and_indents_override_reaches_the_formatter_profile() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().join("project"); + let data = dir.path().join("data"); + std::fs::create_dir_all(&root).unwrap(); + + // The IntelliJ profile is the bundled XML with the override patched in. + let mut cfg = config("jdtls"); + let style = LanguageCodeStyle { + formatter: Some("intellij".into()), + use_tab: Some(true), + tab_size: Some(1), + indent: Some(1), + ..Default::default() + }; + configure_formatter(&mut cfg, Some(&style), &root, &data, None).unwrap(); + let settings = crate::handler::all_settings(&cfg)["java"]["format"]["settings"].clone(); + assert_eq!(settings["profile"], PROFILE); + let patched = std::fs::read_to_string(data.join("lsp/jdtls/intellij.xml")).unwrap(); + assert!(patched.contains(&format!(r#""#))); + assert!(patched.contains(&format!(r#""#))); + assert!(patched.contains(&format!(r#""#))); + + // The default formatter gets a minimal generated profile. + let mut cfg = config("jdtls"); + let style = LanguageCodeStyle { use_tab: Some(false), tab_size: Some(2), ..Default::default() }; + configure_formatter(&mut cfg, Some(&style), &root, &data, None).unwrap(); + let settings = crate::handler::all_settings(&cfg)["java"]["format"]["settings"].clone(); + let path = data.join("lsp/jdtls/indent.xml"); + assert_eq!(settings["url"], crate::uri::path_to_uri_string(&path)); + assert_eq!(settings["profile"], GENERATED_PROFILE); + let xml = std::fs::read_to_string(&path).unwrap(); + assert!(xml.contains(&format!(r#""#))); + // An unset Indent follows the tab size, as in IDEA. + assert!(xml.contains(&format!(r#""#))); + + // Only Indent: the bundled tab settings stay. + let mut cfg = config("jdtls"); + let style = LanguageCodeStyle { formatter: Some("intellij".into()), indent: Some(2), ..Default::default() }; + configure_formatter(&mut cfg, Some(&style), &root, &data, None).unwrap(); + let patched = std::fs::read_to_string(data.join("lsp/jdtls/intellij.xml")).unwrap(); + assert!(patched.contains(&format!(r#""#))); + assert!(patched.contains(&format!(r#""#))); + assert!(patched.contains(&format!(r#""#))); + } + + #[test] + fn tabs_and_indents_override_leaves_custom_profiles_alone() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().join("project"); + std::fs::create_dir_all(&root).unwrap(); + let custom = root.join("style.xml"); + std::fs::write(&custom, XML).unwrap(); + let mut cfg = config("jdtls"); + let style = LanguageCodeStyle { + formatter: Some("custom".into()), + formatter_path: Some("style.xml".into()), + use_tab: Some(true), + tab_size: Some(1), + indent: Some(1), + ..Default::default() + }; + configure_formatter(&mut cfg, Some(&style), &root, dir.path(), None).unwrap(); + assert_eq!(std::fs::read_to_string(&custom).unwrap(), XML); + } + #[test] fn unavailable_custom_paths_are_nonfatal_and_do_not_set_launch_error() { let dir = tempfile::tempdir().unwrap(); diff --git a/src/editor/core/cm/setup.ts b/src/editor/core/cm/setup.ts index 059b6c139..3197fb1f1 100644 --- a/src/editor/core/cm/setup.ts +++ b/src/editor/core/cm/setup.ts @@ -2,7 +2,7 @@ * The base extension set of every text editor, with compartments for the * parts that change at runtime (language, settings, contributions). */ -import { Compartment, EditorState, Prec, type Extension, type Range } from "@codemirror/state"; +import { Compartment, EditorState, Facet, Prec, type Extension, type Range } from "@codemirror/state"; import { Decoration, EditorView, @@ -154,13 +154,26 @@ export function hardWrapAt(settings: EditorSettings | undefined, info: LanguageI return (info && style.languages[info.id]?.hardWrapAt) || style.hardWrapAt; } -export function indentExtension(indent: EffectiveIndent): Extension { +/** `format`: the indent Reformat Code asks for (see `formatIndentFacet`); `null` keeps the editor's. */ +export function indentExtension(indent: EffectiveIndent, format: EffectiveIndent | null = null): Extension { return [ + formatIndentFacet.of(format), EditorState.tabSize.of(indent.tabSize), indentUnit.of(indent.insertSpaces ? " ".repeat(Math.max(1, indent.indentSize ?? indent.tabSize)) : "\t"), ]; } +/** + * Indentation explicit reformat requests use: the Code Style without the file's + * detected indents (detection affects editing, reformatting uses the configured + * style, as in IDEA). Set only when the language has an explicit Tabs and Indents + * override — unset, formatting keeps the editor's indent, the status bar choice + * included. + */ +export const formatIndentFacet = Facet.define({ + combine: (values) => [values.find((v) => v !== null) ?? null], +}); + export function fontExtension(settings: EditorSettings | undefined): Extension { const vars: string[] = []; const prefs = prefsOf(settings); @@ -297,6 +310,8 @@ export interface SetupOptions { docLength: number; /** Effective indentation of the document (detected / overridden); computed when absent. */ indent?: EffectiveIndent; + /** Indentation for Reformat Code (`formatIndentFacet`); the editor's when absent. */ + formatIndent?: EffectiveIndent | null; /** Soft wraps toggled for this file (View › Active Editor › Soft-Wrap). */ wrapOverride?: boolean | null; /** Extra extensions owned by the slot (update listeners, focus handlers). */ @@ -358,7 +373,7 @@ export function baseExtensions(o: SetupOptions): Extension { keymap.of([...closeBracketsKeymap, ...editorBaseKeymap, indentWithTab]), compartments.language.of(o.language ?? []), compartments.comments.of(commentTokens(o.info, o.ctx.language)), - compartments.indent.of(indentExtension(o.indent ?? effectiveIndent(o.settings, o.info))), + compartments.indent.of(indentExtension(o.indent ?? effectiveIndent(o.settings, o.info), o.formatIndent)), compartments.readOnly.of(readOnlyExtension(o.ctx.readOnly)), compartments.wrap.of(wrapExtension(o.settings, o.ctx.path, o.wrapOverride)), compartments.sticky.of(stickyExtension(o.settings)), diff --git a/src/editor/core/model/document.test.ts b/src/editor/core/model/document.test.ts index 1b066027e..79c356550 100644 --- a/src/editor/core/model/document.test.ts +++ b/src/editor/core/model/document.test.ts @@ -6,6 +6,7 @@ import { createMemoryBackend, type MemoryBackend } from "../backend"; import { _resetLanguages } from "../cm/languages"; import { DocumentManager, ModifiedRanges, saveCleanupChanges, useDocMeta } from "./document"; import { EditorSlot, type SlotHost } from "./slot"; +import { formatOptionsOf } from "@/editor/lsp/format"; const host: SlotHost = { onFocus: () => {}, @@ -37,6 +38,24 @@ function setup(files: Record, settings = {}) { afterEach(() => useDocMeta.setState({}, true)); describe("DocumentManager + slots", () => { + it("gives a new slot the Code Style indent for Reformat Code, not the detected one", async () => { + const settings = { + editor: { + codeStyle: { + languages: { java: { useTab: true, tabSize: 1, indent: 1 } }, + detectIndents: true, + editorconfig: false, + }, + }, + }; + const { manager } = setup({ "/p/A.java": "class A {\n int x;\n}\n" }, settings); + const model = await manager.acquire("/p/A.java"); + const first = new EditorSlot(model, "t1", host); + const split = new EditorSlot(model, "t2", host, { from: first }); + for (const slot of [first, split]) + expect(formatOptionsOf(slot.currentState())).toEqual({ tabSize: 1, insertSpaces: false }); + }); + it("shares one document between split slots and syncs edits to the backend", async () => { const { backend, manager, frame } = setup({ "/p/a.txt": "hello\n" }); const model = await manager.acquire("/p/a.txt"); diff --git a/src/editor/core/model/document.ts b/src/editor/core/model/document.ts index a6bd1f182..e0e8355ff 100644 --- a/src/editor/core/model/document.ts +++ b/src/editor/core/model/document.ts @@ -405,6 +405,19 @@ export class DocModel { return this.indentOverride ?? effectiveIndent(this.settings(), this.info, this.editorconfig, this.detectedIndent); } + /** + * Indentation explicit reformat requests use (`formatIndentFacet`): with an + * explicit per-language Tabs and Indents override, the Code Style without the + * file's detected indents — the override must reach the formatter, or the + * language server keeps its own style. Without an override formatting keeps + * the editor's indent. + */ + formatIndent() { + const lang = this.info ? prefsOf(this.settings()).codeStyle.languages[this.info.id] : undefined; + if (!lang || (lang.useTab === undefined && lang.tabSize === undefined && lang.indent === undefined)) return null; + return effectiveIndent(this.settings(), this.info, this.editorconfig, null); + } + /** Soft wraps toggled for this file (wins over settings until the file is closed). */ softWrapOverride: boolean | null = null; @@ -429,7 +442,7 @@ export class DocModel { const indent = this.effectiveIndent(); this.setMeta({ indent: { tabSize: indent.tabSize, insertSpaces: indent.insertSpaces } }); const effects = [ - compartments.indent.reconfigure(indentExtension(indent)), + compartments.indent.reconfigure(indentExtension(indent, this.formatIndent())), compartments.wrap.reconfigure(wrapExtension(editor, this.path, this.softWrapOverride)), compartments.sticky.reconfigure(stickyExtension(editor)), compartments.font.reconfigure(fontExtension(editor)), diff --git a/src/editor/core/model/slot.ts b/src/editor/core/model/slot.ts index bb818b1cd..54221ce6c 100644 --- a/src/editor/core/model/slot.ts +++ b/src/editor/core/model/slot.ts @@ -93,6 +93,7 @@ export class EditorSlot { language: model.language, docLength: model.sync.localText.length, indent: model.effectiveIndent(), + formatIndent: model.formatIndent(), wrapOverride: model.softWrapOverride, extra: this.slotExtensions(), }), diff --git a/src/editor/lsp/format.test.ts b/src/editor/lsp/format.test.ts index 8f21dd7c7..71f46be70 100644 --- a/src/editor/lsp/format.test.ts +++ b/src/editor/lsp/format.test.ts @@ -11,7 +11,42 @@ import { protectedRegions, type FormatGuardOptions, } from "@/platform/formatGuard"; -import { dedentsToColumnZero, formatDocument, formattedLines } from "./format"; +import { indentExtension } from "@/editor/core/cm/setup"; +import { dedentsToColumnZero, formatDocument, formatOptionsOf, formattedLines } from "./format"; + +describe("formatOptionsOf", () => { + const stateWith = (...args: Parameters) => + EditorState.create({ extensions: indentExtension(...args) }); + + it("asks for the Code Style override, not the indent detected in the file", () => { + // Code Style › Java: tab character, tab size 1; the file was indented with 4 spaces. + const state = stateWith({ tabSize: 4, insertSpaces: true }, { tabSize: 1, insertSpaces: false, indentSize: 1 }); + expect(formatOptionsOf(state)).toEqual({ tabSize: 1, insertSpaces: false }); + }); + + it("sends the tab size with tabs and the indent size with spaces", () => { + const editor = { tabSize: 8, insertSpaces: true }; + expect(formatOptionsOf(stateWith(editor, { tabSize: 4, insertSpaces: false, indentSize: 2 }))).toEqual({ + tabSize: 4, + insertSpaces: false, + }); + expect(formatOptionsOf(stateWith(editor, { tabSize: 4, insertSpaces: true, indentSize: 2 }))).toEqual({ + tabSize: 2, + insertSpaces: true, + }); + }); + + it("keeps the editor's indent without an override", () => { + expect(formatOptionsOf(stateWith({ tabSize: 4, insertSpaces: true, indentSize: 2 }))).toEqual({ + tabSize: 2, + insertSpaces: true, + }); + expect(formatOptionsOf(stateWith({ tabSize: 3, insertSpaces: false }))).toEqual({ + tabSize: 3, + insertSpaces: false, + }); + }); +}); describe("formattedLines", () => { const doc = Text.of(["fn main(){", "let x=1;", "}", "", "fn other() {}"]); diff --git a/src/editor/lsp/format.ts b/src/editor/lsp/format.ts index 5d3031c3c..e3184a94d 100644 --- a/src/editor/lsp/format.ts +++ b/src/editor/lsp/format.ts @@ -24,6 +24,7 @@ import { import { useProject } from "@/platform/project"; import { CONTRIBUTED_FORMAT, contributedAt, whenSynced, type ContributedFormatter } from "@/editor/api"; import { docOf, lspRequest, reportLspError } from "./lsp"; +import { formatIndentFacet } from "../core/cm/setup"; import { offsetToPos, textEditsToChanges } from "./positions"; import { tx, txTemplate } from "@/platform/i18n"; import { showHint } from "./hint"; @@ -71,8 +72,17 @@ function formattedHint(view: EditorView, lines: number) { }); } -/** LSP `FormattingOptions`: with spaces the indent width is the indent unit, not the tab size. */ +/** + * LSP `FormattingOptions`: with spaces the indent width is the indent unit, not the tab size. + * An explicit Code Style override replaces the editor's indent; the server's formatter + * profile carries the same override, so both agree. + */ export function formatOptionsOf(state: EditorState): { tabSize: number; insertSpaces: boolean } { + const style = state.facet(formatIndentFacet)[0]; + if (style) + return style.insertSpaces + ? { tabSize: style.indentSize ?? style.tabSize, insertSpaces: true } + : { tabSize: style.tabSize, insertSpaces: false }; const unit = state.facet(indentUnit); return unit.includes("\t") ? { tabSize: state.tabSize, insertSpaces: false } From 002401e8a771714c5b2a6329f0378aedd5368297 Mon Sep 17 00:00:00 2001 From: Roberto Scinocca Date: Tue, 3 Nov 2026 08:05:09 +0100 Subject: [PATCH 058/106] feat(languages): add ERB with an HTML + Ruby editor mode (#720) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ruby gets an ERB editor mode (HTML with embedded Ruby), Ruby / Rake / RSpec run configuration types, and a Settings › Languages & Frameworks › Ruby page for choosing the interpreter (Auto picks the `.ruby-version` / mise / asdf install, else `ruby` on PATH). - Run: `.rb` scripts run from the editor context menu, `_spec.rb` / `_test.rb` files narrow an RSpec or Rake (`SPEC=` / `TEST=`) configuration, and Rakefile tasks are detected; Rake and RSpec go through `bundle exec` when the project has a Gemfile. The chosen interpreter's `ruby`, `bundle`, `rake` and `rspec` are used and its bin directory is put on PATH. - Configuration forms rebuild from a raw command line keeping option values with their options (`--format documentation`, `-I lib`), and a new Ruby configuration opens in the Ruby form with the script still to fill in. - External Libraries lists the Ruby standard library and the bundle's gems, including those Bundler installs inside the project (`vendor/bundle`). - Ruby is part of the frontend settings defaults and normalization, so the selected interpreter survives a settings round-trip. Closes #719 Co-authored-by: retsef <2175889+retsef@users.noreply.github.com> --- crates/ide-core/src/settings.rs | 8 + crates/ide-core/src/version_managers.rs | 2 + crates/ide-language/src/registry.rs | 3 + crates/ide-run/src/detect.rs | 128 ++++++++++ crates/ide-run/tests/detect.rs | 66 +++++ crates/ide-sdk/src/apply.rs | 27 ++ crates/ide-sdk/src/discover.rs | 6 +- crates/ide-sdk/src/lib.rs | 2 + crates/ide-sdk/src/libraries.rs | 21 +- crates/ide-sdk/src/ruby.rs | 237 ++++++++++++++++++ languages/erb.toml | 6 + src/bindings.ts | 8 +- src/editor/core/cm/erb.test.ts | 33 +++ src/editor/core/cm/erb.ts | 78 ++++++ src/editor/core/cm/languages.ts | 6 + src/features/libraries/index.ts | 2 + src/features/run/configs.test.ts | 19 ++ src/features/run/context.ts | 16 +- src/features/run/rubyTypes.ts | 199 +++++++++++++++ src/features/run/runConfigTypes.test.tsx | 75 ++++++ src/features/run/types.ts | 6 +- .../settings/__tests__/languages.test.ts | 9 + .../settings/languages/LanguagePages.tsx | 22 ++ src/features/settings/pages/languages.tsx | 12 +- src/platform/languageSettings.ts | 4 + src/platform/locales/text/zh.json | 24 ++ src/ui/icons.tsx | 2 +- 27 files changed, 1012 insertions(+), 9 deletions(-) create mode 100644 crates/ide-sdk/src/ruby.rs create mode 100644 languages/erb.toml create mode 100644 src/editor/core/cm/erb.test.ts create mode 100644 src/editor/core/cm/erb.ts create mode 100644 src/features/run/rubyTypes.ts diff --git a/crates/ide-core/src/settings.rs b/crates/ide-core/src/settings.rs index 453b1433f..38b676ca8 100644 --- a/crates/ide-core/src/settings.rs +++ b/crates/ide-core/src/settings.rs @@ -548,6 +548,7 @@ pub struct LanguagesSettings { pub typescript: TypeScriptSettings, pub prettier: PrettierSettings, pub java: JavaSettings, + pub ruby: RubySettings, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] @@ -605,6 +606,13 @@ impl Default for PythonSettings { } } +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)] +#[serde(default, rename_all = "camelCase")] +pub struct RubySettings { + /// Project interpreter executable; empty = auto-detect (`.ruby-version`, mise / asdf, else `ruby` on PATH). + pub interpreter: String, +} + #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)] #[serde(default, rename_all = "camelCase")] pub struct NodeSettings { diff --git a/crates/ide-core/src/version_managers.rs b/crates/ide-core/src/version_managers.rs index 19e7cd4f8..30eeae097 100644 --- a/crates/ide-core/src/version_managers.rs +++ b/crates/ide-core/src/version_managers.rs @@ -18,6 +18,7 @@ pub enum Tool { Go, Maven, Gradle, + Ruby, } impl Tool { @@ -30,6 +31,7 @@ impl Tool { Tool::Go => &["go", "golang"], Tool::Maven => &["maven"], Tool::Gradle => &["gradle"], + Tool::Ruby => &["ruby"], } } } diff --git a/crates/ide-language/src/registry.rs b/crates/ide-language/src/registry.rs index ee48ab612..984c082a3 100644 --- a/crates/ide-language/src/registry.rs +++ b/crates/ide-language/src/registry.rs @@ -22,6 +22,7 @@ const BUILTIN: &[(&str, &str)] = &[ ("dockerfile", include_str!("../../../languages/dockerfile.toml")), ("dotenv", include_str!("../../../languages/dotenv.toml")), ("elixir", include_str!("../../../languages/elixir.toml")), + ("erb", include_str!("../../../languages/erb.toml")), ("erlang", include_str!("../../../languages/erlang.toml")), ("fsharp", include_str!("../../../languages/fsharp.toml")), ("gitattributes", include_str!("../../../languages/gitattributes.toml")), @@ -520,6 +521,7 @@ mod tests { "MsGenny", "Vue", "Angular Template", + "ERB", ]; fn reg() -> LanguageRegistry { @@ -776,6 +778,7 @@ mod tests { ("AppDelegate.m", "objective-c"), ("a.rb", "ruby"), ("Gemfile", "ruby"), + ("show.html.erb", "erb"), ("index.php", "php"), ("init.lua", "lua"), ("main.dart", "dart"), diff --git a/crates/ide-run/src/detect.rs b/crates/ide-run/src/detect.rs index 0f56ad500..7e60b2fdd 100644 --- a/crates/ide-run/src/detect.rs +++ b/crates/ide-run/src/detect.rs @@ -33,6 +33,7 @@ pub(crate) fn detect_with_profiles( detect_maven(root, excluded_poms, &mut out); detect_go(root, &mut out); detect_python(root, &mut out); + detect_ruby(root, &mut out); cmake::detect(root, profiles, &mut out); dotnet::detect(root, &mut out); detect_make(root, &mut out); @@ -905,6 +906,133 @@ fn detect_python(root: &Path, out: &mut Vec) { } } +// ---------------------------------------------------------------------- ruby + +/// Tasks a Rakefile defines statically: `task :name`, `task "name" => deps`, +/// `task name: [...]`, qualified by enclosing `namespace` blocks. `desc` lines are +/// skipped; a `Rake::TestTask` contributes `test`. `load`/`import`ed files are not followed. +pub(crate) fn rake_tasks(src: &str) -> Vec { + let mut tasks = Vec::new(); + let mut namespaces: Vec = Vec::new(); + let mut depth: Vec = Vec::new(); // per open block: whether it is a namespace + let name_of = |rest: &str| -> Option { + let rest = rest.trim_start(); + let (name, _) = if let Some(r) = rest.strip_prefix(':') { + r.split(|c: char| !(c.is_alphanumeric() || c == '_')).next().map(|n| (n.to_owned(), ()))? + } else if let Some(r) = rest.strip_prefix(['"', '\'']) { + r.split(['"', '\'']).next().map(|n| (n.to_owned(), ()))? + } else { + let n: String = rest.chars().take_while(|c| c.is_alphanumeric() || *c == '_').collect(); + (!n.is_empty() && rest[n.len()..].starts_with(':')).then_some((n, ()))? + }; + (!name.is_empty()).then_some(name) + }; + for raw in src.lines() { + let line = raw.trim(); + if line.starts_with('#') { + continue; + } + let opens = line.ends_with(" do") || line.contains(" do |") || line.contains(" do ") && !line.contains(" end"); + if let Some(rest) = line.strip_prefix("namespace ") + && let Some(ns) = name_of(rest) + { + namespaces.push(ns); + depth.push(true); + continue; + } + if let Some(rest) = line.strip_prefix("task ").or_else(|| line.strip_prefix("task(")) + && let Some(name) = name_of(rest) + { + let full = namespaces.iter().chain([&name]).cloned().collect::>().join(":"); + if !tasks.contains(&full) { + tasks.push(full); + } + } + if line.contains("Rake::TestTask.new") || line.contains("RSpec::Core::RakeTask.new") { + let name = name_of(line.split_once(".new").map(|(_, r)| r.trim_start_matches(['(', ' '])).unwrap_or("")) + .unwrap_or_else(|| if line.contains("RSpec") { "spec".into() } else { "test".into() }); + let full = namespaces.iter().chain([&name]).cloned().collect::>().join(":"); + if !tasks.contains(&full) { + tasks.push(full); + } + } + if opens && !line.starts_with("namespace ") { + depth.push(false); + } + if (line == "end" || line.starts_with("end ") || line.starts_with("end#")) && depth.pop() == Some(true) { + namespaces.pop(); + } + } + tasks +} + +/// `bundle exec ` when the project has a Gemfile, else the bare tool. +fn ruby_tool(root: &Path, tool: &str) -> (String, Vec) { + if root.join("Gemfile").is_file() { + ("bundle".into(), vec!["exec".into(), tool.into()]) + } else { + (tool.into(), Vec::new()) + } +} + +fn ruby_config( + id: String, + name: String, + kind: &str, + (command, mut args): (String, Vec), + extra: &[&str], +) -> RunConfig { + args.extend(extra.iter().map(|a| (*a).to_owned())); + let mut c = config(id, name, kind, &command, &[]); + c.args = args; + c +} + +fn uses_rspec(root: &Path) -> bool { + root.join("spec").is_dir() + || root.join(".rspec").is_file() + || read(&root.join("Gemfile")).is_some_and(|g| g.contains("rspec")) +} + +fn detect_ruby(root: &Path, out: &mut Vec) { + let markers = ["Gemfile", "Rakefile", "rakefile", "Rakefile.rb", ".ruby-version", "config.ru"]; + let gemspec = fs::read_dir(root) + .into_iter() + .flatten() + .flatten() + .any(|e| e.path().extension().is_some_and(|x| x == "gemspec")); + if !gemspec && !markers.iter().any(|m| root.join(m).is_file()) { + return; + } + if root.join("bin/rails").is_file() { + let mut c = ruby_config( + "rails:server".into(), + "rails server".into(), + "ruby", + ("ruby".into(), Vec::new()), + &["bin/rails", "server"], + ); + c.extras.folder = Some("Rails".into()); + out.push(c); + } + if let Some(rakefile) = first_existing(root, &["Rakefile", "rakefile", "Rakefile.rb"]) + && let Some(src) = read(&rakefile) + { + for task in rake_tasks(&src) { + let mut c = + ruby_config(format!("rake:{task}"), format!("rake {task}"), "rake", ruby_tool(root, "rake"), &[&task]); + if task == "test" || task == "spec" || task.ends_with(":test") || task.ends_with(":spec") { + c = c.test(); + } + c.extras.folder = Some("Rake".into()); + out.push(c); + } + } + if uses_rspec(root) { + out.push(ruby_config("rspec".into(), "rspec".into(), "rspec", ruby_tool(root, "rspec"), &[]).test()); + } +} + // ---------------------------------------------------------------- make / just pub(crate) fn make_targets(src: &str) -> Vec { diff --git a/crates/ide-run/tests/detect.rs b/crates/ide-run/tests/detect.rs index 48db7c561..e207f2e35 100644 --- a/crates/ide-run/tests/detect.rs +++ b/crates/ide-run/tests/detect.rs @@ -728,3 +728,69 @@ fn cmake_profile_build_configures_only_without_a_cache() { } } } + +#[test] +fn ruby_projects_list_rake_tasks_rspec_and_rails() { + let rakefile = "\ +require 'rake/testtask' + +desc \"Build it\" +task :build do + sh 'make' +end +task \"lint\" => [:build] +task deploy: :build do |t| + puts t +end +namespace :db do + desc 'Migrate' + task :migrate, [:version] do |t, args| + puts args + end + namespace :schema do + task :load + end +end +Rake::TestTask.new do |t| + t.libs << 'test' +end +task default: :build +"; + let dir = project(&[ + ("Gemfile", "source 'https://rubygems.org'\ngem 'rspec'\n"), + ("Rakefile", rakefile), + ("bin/rails", "#!/usr/bin/env ruby\n"), + ("spec/a_spec.rb", ""), + ]); + let c = detect(dir.path()); + assert_eq!( + ids(&c), + [ + "rails:server", + "rake:build", + "rake:lint", + "rake:deploy", + "rake:db:migrate", + "rake:db:schema:load", + "rake:test", + "rake:default", + "rspec" + ] + ); + assert_eq!(cmdline(find(&c, "rails:server")), "ruby bin/rails server"); + assert_eq!(cmdline(find(&c, "rake:db:migrate")), "bundle exec rake db:migrate"); + assert_eq!(find(&c, "rake:db:migrate").kind, "rake"); + assert!(find(&c, "rake:test").is_test); + assert_eq!(cmdline(find(&c, "rspec")), "bundle exec rspec"); + assert!(find(&c, "rspec").is_test); + assert_eq!(find(&c, "rake:build").extras.folder.as_deref(), Some("Rake")); +} + +#[test] +fn ruby_without_bundler_runs_bare_tools() { + let dir = project(&[("Rakefile", "task :test\n")]); + let c = detect(dir.path()); + assert_eq!(ids(&c), ["rake:test"]); + assert_eq!(cmdline(&c[0]), "rake test"); + assert!(detect(project(&[("a.rb", "puts 1\n")]).path()).is_empty()); +} diff --git a/crates/ide-sdk/src/apply.rs b/crates/ide-sdk/src/apply.rs index 3f368d469..0f9a937b2 100644 --- a/crates/ide-sdk/src/apply.rs +++ b/crates/ide-sdk/src/apply.rs @@ -36,6 +36,12 @@ pub fn python_interpreter(root: &Path, s: &Settings) -> Option { .or_else(|| pinned::pinned_python(root)) } +/// The project Ruby: the configured one, else the `.ruby-version` / mise / asdf +/// install, else `ruby` on PATH. +pub fn ruby_interpreter(root: &Path, s: &Settings) -> Option { + setting_path(root, &s.languages.ruby.interpreter).or_else(|| crate::ruby::project_ruby(root)) +} + /// The configured `node` executable's directory, else the `bin` of the Node.js the /// project pins for mise / asdf. pub(crate) fn node_dir(root: &Path, s: &Settings) -> Option { @@ -624,6 +630,7 @@ pub fn apply_to_run_configs(configs: &mut [RunConfig], root: &Path, s: &Settings let jdk = project_jdk(root, s); let mvn = maven_command(root, s); let mvn_options = maven_options(root, s); + let ruby = ruby_interpreter(root, s); for c in configs.iter_mut() { resolve_run_command(c, root, s); @@ -709,6 +716,26 @@ pub fn apply_to_run_configs(configs: &mut [RunConfig], root: &Path, s: &Settings c.env.entry(key).or_insert(value); } } + "ruby" | "rake" | "rspec" => { + if let Some(ruby) = &ruby + && let Some(bin) = crate::ruby::bin_dir_of(ruby) + { + // `ruby`, `bundle`, `rake`, `rspec` of the chosen interpreter when installed + // alongside it; the rest (a project `bin/rspec` binstub) stays as is. + let name = Path::new(&c.command).file_name().map(|n| n.to_string_lossy().into_owned()); + if let Some(name) = name + && matches!(name.as_str(), "ruby" | "bundle" | "rake" | "rspec" | "gem" | "irb") + && !c.command.contains('/') + && !c.command.contains('\\') + { + let exe = bin.join(exe(&name)); + if exe.is_file() { + c.command = exe.to_string_lossy().into_owned(); + } + } + path_dirs.push(bin); + } + } "maven" | "tomcat" => { if let Some(jdk) = &jdk { c.env.entry("JAVA_HOME".into()).or_insert_with(|| jdk.home.to_string_lossy().into_owned()); diff --git a/crates/ide-sdk/src/discover.rs b/crates/ide-sdk/src/discover.rs index a9dec42e5..425795bdb 100644 --- a/crates/ide-sdk/src/discover.rs +++ b/crates/ide-sdk/src/discover.rs @@ -21,6 +21,7 @@ pub fn detect(kind: SdkKind, root: Option<&Path>) -> Vec { SdkKind::TypeScript => typescript(root), SdkKind::Jdk => jdk(), SdkKind::Maven => maven(), + SdkKind::Ruby => crate::ruby::detect(root), }; if let Some(root) = root { pinned_first(&mut out, kind, root); @@ -35,6 +36,7 @@ fn tool_of(kind: SdkKind) -> Option { SdkKind::Go => Some(Tool::Go), SdkKind::Jdk => Some(Tool::Java), SdkKind::Maven => Some(Tool::Maven), + SdkKind::Ruby => Some(Tool::Ruby), SdkKind::PackageManager | SdkKind::Rust | SdkKind::TypeScript => None, } } @@ -50,7 +52,7 @@ fn pinned_first(sdks: &mut Vec, kind: SdkKind, root: &Path) { } /// Runs `f` over `items` on scoped threads (version queries spawn processes). -fn par_map(items: &[T], f: impl Fn(&T) -> Option + Sync) -> Vec { +pub(crate) fn par_map(items: &[T], f: impl Fn(&T) -> Option + Sync) -> Vec { std::thread::scope(|s| { let handles: Vec<_> = items.iter().map(|item| s.spawn(|| f(item))).collect(); handles.into_iter().filter_map(|h| h.join().ok().flatten()).collect() @@ -227,7 +229,7 @@ fn pyvenv_version(python: &Path) -> Option { }) } -fn subdirs(dir: &Path) -> Vec { +pub(crate) fn subdirs(dir: &Path) -> Vec { let mut out: Vec = std::fs::read_dir(dir).into_iter().flatten().flatten().map(|e| e.path()).filter(|p| p.is_dir()).collect(); out.sort(); diff --git a/crates/ide-sdk/src/lib.rs b/crates/ide-sdk/src/lib.rs index ac98b5716..79ccfda67 100644 --- a/crates/ide-sdk/src/lib.rs +++ b/crates/ide-sdk/src/lib.rs @@ -19,6 +19,7 @@ pub mod lombok; mod npm; pub mod pinned; pub mod python; +pub mod ruby; mod vue; use serde::{Deserialize, Serialize}; @@ -42,6 +43,7 @@ pub enum SdkKind { Jdk, /// Maven homes. Maven, + Ruby, } /// One installed interpreter / toolchain. diff --git a/crates/ide-sdk/src/libraries.rs b/crates/ide-sdk/src/libraries.rs index bdc450b7e..827e42ec2 100644 --- a/crates/ide-sdk/src/libraries.rs +++ b/crates/ide-sdk/src/libraries.rs @@ -54,6 +54,7 @@ const JAVA_MANIFESTS: &[&str] = const NODE_MANIFESTS: &[&str] = &["package.json", "package-lock.json", "yarn.lock", "pnpm-lock.yaml", "bun.lockb"]; const CARGO_MANIFESTS: &[&str] = &["Cargo.toml", "Cargo.lock", "rust-toolchain", "rust-toolchain.toml"]; const GO_MANIFESTS: &[&str] = &["go.mod", "go.sum", "go.work"]; +const RUBY_MANIFESTS: &[&str] = &["Gemfile", "Gemfile.lock", ".ruby-version", ".tool-versions", "mise.toml"]; /// A Maven / Gradle dependency of the project, from the build model. #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] @@ -70,7 +71,7 @@ pub struct BuildDependency { /// `dependencies` (from the build model) are listed when their jars are in the local repositories. pub fn project_libraries(root: &Path, settings: &Settings, dependencies: &[BuildDependency]) -> Vec { type Detect = fn(&Path, &Settings) -> Vec; - let detectors: [Detect; 5] = [python_groups, jdk_groups, node_groups, rust_groups, go_groups]; + let detectors: [Detect; 6] = [python_groups, jdk_groups, node_groups, rust_groups, go_groups, ruby_groups]; std::thread::scope(|s| { let handles: Vec<_> = detectors.iter().map(|d| s.spawn(move || d(root, settings))).collect(); let deps = s.spawn(|| dependency_groups(root, settings, dependencies)); @@ -193,6 +194,24 @@ pub(crate) fn named_roots(roots: Vec<(String, PathBuf)>) -> Vec { .collect() } +// ------------------------------------------------------------------ ruby + +fn ruby_groups(root: &Path, s: &Settings) -> Vec { + let Some(files) = manifests(root, RUBY_MANIFESTS) else { return Vec::new() }; + if !root.join("Gemfile").is_file() && !root.join(".ruby-version").is_file() { + return Vec::new(); + } + let Some(ruby) = crate::apply::ruby_interpreter(root, s) else { return Vec::new() }; + cached(root, "ruby", ruby.display().to_string(), &files, || { + let name = crate::ruby::ruby_sdk(&ruby, "system").map(|s| s.name).unwrap_or_else(|| "Ruby".into()); + let (stdlib, gems) = crate::ruby::library_roots(&ruby, Some(root)); + let mut out = Vec::new(); + out.extend(group(name, "ruby", named_roots(stdlib))); + out.extend(group("Gems", "gems", named_roots(gems))); + out + }) +} + // ------------------------------------------------------------------ jdk fn jdk_groups(root: &Path, s: &Settings) -> Vec { diff --git a/crates/ide-sdk/src/ruby.rs b/crates/ide-sdk/src/ruby.rs new file mode 100644 index 000000000..b7ce8df4b --- /dev/null +++ b/crates/ide-sdk/src/ruby.rs @@ -0,0 +1,237 @@ +//! Ruby interpreters: the ones a version manager installed (rbenv, rvm, chruby, +//! mise, asdf), Homebrew's, and the ones on `PATH`; the project's `.ruby-version`; +//! and the interpreter's standard library and Bundler gems for External Libraries. + +use crate::discover::{par_map, subdirs}; +use crate::exec::{executables_in, query, query_in, search_path}; +use crate::{Sdk, exe, home_dir, pinned}; +use ide_core::version_managers::{self, Tool}; +use std::collections::HashSet; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +const VERSION_TIMEOUT: Duration = Duration::from_secs(4); +const QUERY_TIMEOUT: Duration = Duration::from_secs(20); + +fn var_path(key: &str) -> Option { + ide_core::process::host_var_os(key).filter(|v| !v.is_empty()).map(PathBuf::from) +} + +/// Where version managers keep one directory per Ruby, with the manager's name. +fn manager_roots() -> Vec<(PathBuf, &'static str)> { + let mut out = Vec::new(); + let Some(home) = home_dir() else { return out }; + out.push((var_path("RBENV_ROOT").unwrap_or_else(|| home.join(".rbenv")).join("versions"), "rbenv")); + out.push((var_path("rvm_path").unwrap_or_else(|| home.join(".rvm")).join("rubies"), "rvm")); + out.push((PathBuf::from("/usr/local/rvm/rubies"), "rvm")); + out.push((home.join(".rubies"), "chruby")); + out.push((PathBuf::from("/opt/rubies"), "chruby")); + out +} + +/// `bin/ruby` of a Ruby install directory. +pub(crate) fn ruby_in_install(dir: &Path) -> Option { + let p = dir.join("bin").join(exe("ruby")); + p.is_file().then_some(p) +} + +/// Installed Rubies: version-manager installs, Homebrew, then `PATH` (shims excluded). +pub(crate) fn detect(root: Option<&Path>) -> Vec { + let mut found: Vec<(PathBuf, &'static str)> = Vec::new(); + for (dir, manager) in manager_roots() { + found.extend(subdirs(&dir).into_iter().filter_map(|v| ruby_in_install(&v)).map(|p| (p, manager))); + } + for (dir, manager) in version_managers::installs(Tool::Ruby) { + found.extend(ruby_in_install(&dir).map(|p| (p, manager))); + } + for brew in ["/opt/homebrew/opt/ruby", "/usr/local/opt/ruby", "/home/linuxbrew/.linuxbrew/opt/ruby"] { + found.extend(ruby_in_install(Path::new(brew)).map(|p| (p, "homebrew"))); + } + let dirs = std::env::split_paths(&search_path()) + .filter(|d| !is_shims_dir(d) && !version_managers::is_shims_dir(d)) + .collect::>(); + found.extend(executables_in("ruby", dirs.into_iter()).into_iter().map(|p| (p, "system"))); + + let mut seen = HashSet::new(); + let candidates: Vec<(PathBuf, &'static str)> = found + .into_iter() + .filter(|(p, _)| p.is_file() && seen.insert(p.canonicalize().unwrap_or_else(|_| p.clone()))) + .collect(); + let mut out = par_map(&candidates, |(path, source)| ruby_sdk(path, source)); + if let Some(root) = root + && let Some(pinned) = pinned_ruby_version(root) + { + // The `.ruby-version` install first, like the project virtualenv for Python. + if let Some(i) = out.iter().position(|s| version_matches(s.version.as_deref().unwrap_or(""), &pinned)) { + let sdk = out.remove(i); + out.insert(0, sdk); + } + } + out +} + +/// rbenv's and rvm's shim directories, which only forward to an install. +fn is_shims_dir(dir: &Path) -> bool { + dir.ends_with(".rbenv/shims") || dir.ends_with(".rvm/bin") +} + +/// `ruby 3.3.1 (2024-04-23 revision …) [arm64-darwin23]` → `3.3.1`. +pub(crate) fn parse_version(out: &str) -> Option { + out.strip_prefix("ruby ")? + .split_whitespace() + .next() + .map(|v| v.trim_end_matches(|c: char| c.is_ascii_alphabetic() || c == '-').to_owned()) +} + +pub(crate) fn ruby_sdk(path: &Path, source: &str) -> Option { + let version = query(path, &["--version"], VERSION_TIMEOUT).and_then(|v| parse_version(&v))?; + let short = version.split('.').take(2).collect::>().join("."); + let name = match source { + "system" => format!("Ruby {short}"), + tool => format!("Ruby {short} ({tool})"), + }; + Some(Sdk { + path: path.into(), + name, + version: Some(version), + source: source.to_owned(), + home: path.parent().and_then(Path::parent).map(Path::to_path_buf).map(Into::into), + extra: None, + }) +} + +/// The version `.ruby-version` pins (rbenv / rvm / chruby read it; `ruby-3.3.1` → `3.3.1`), +/// from `root` up, else the mise / asdf pin. +pub fn pinned_ruby_version(root: &Path) -> Option { + let from_file = root.ancestors().find_map(|dir| { + let text = std::fs::read_to_string(dir.join(".ruby-version")).ok()?; + let v = text.lines().map(str::trim).find(|l| !l.is_empty() && !l.starts_with('#'))?; + Some(v.strip_prefix("ruby-").unwrap_or(v).to_owned()) + }); + from_file.or_else(|| pinned::pinned_version(root, Tool::Ruby)) +} + +/// Whether `version` (`3.3.1`) satisfies a pin (`3.3.1`, `3.3`, `3`). +pub(crate) fn version_matches(version: &str, pin: &str) -> bool { + version == pin || version.strip_prefix(pin).is_some_and(|rest| rest.starts_with('.')) +} + +/// The Ruby for `root` without an explicit setting: the install matching +/// `.ruby-version` (version managers, then mise / asdf), else `ruby` on PATH. +pub fn project_ruby(root: &Path) -> Option { + if let Some(pin) = pinned_ruby_version(root) { + let install = manager_roots() + .into_iter() + .flat_map(|(dir, _)| subdirs(&dir)) + .chain(version_managers::installs(Tool::Ruby).into_iter().map(|(d, _)| d)) + .find(|dir| { + let name = dir.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + version_matches(name.strip_prefix("ruby-").unwrap_or(&name), &pin) + }); + if let Some(ruby) = install.and_then(|d| ruby_in_install(&d)) { + return Some(ruby); + } + } + crate::exec::which("ruby") +} + +/// `bin` directory of `ruby` and the gems it was installed with (`gem` and `bundle` next to it). +pub fn bin_dir_of(ruby: &Path) -> Option { + ruby.parent().map(Path::to_path_buf) +} + +type NamedRoots = Vec<(String, PathBuf)>; + +/// Standard-library / site directories (`ruby/3.3.0`) and the gems Bundler resolves +/// for `root` (`rails-7.1.3`), each as `(name, path)`. +pub fn library_roots(ruby: &Path, root: Option<&Path>) -> (NamedRoots, NamedRoots) { + const PATHS: &str = "require 'rbconfig'; puts RbConfig::CONFIG['rubylibdir'], RbConfig::CONFIG['sitelibdir'], RbConfig::CONFIG['vendorlibdir']"; + let mut stdlib = Vec::new(); + let mut gems = Vec::new(); + let file_name = |p: &Path| p.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + if let Some(text) = query(ruby, &["-e", PATHS], QUERY_TIMEOUT) { + for line in text.lines().map(str::trim).filter(|l| !l.is_empty()) { + let path = PathBuf::from(line); + if path.is_dir() && !stdlib.iter().any(|(_, p)| *p == path) { + let parent = path.parent().map(file_name).unwrap_or_default(); + stdlib.push((format!("{parent}/{}", file_name(&path)), path)); + } + } + } + if let Some(root) = root.filter(|r| r.join("Gemfile").is_file()) { + let bin = bin_dir_of(ruby); + let bundle = bin.as_ref().map(|b| b.join(exe("bundle"))).filter(|b| b.is_file()); + let path = bin.as_ref().map(|b| crate::exec::path_with(&[b.as_path()])).unwrap_or_default(); + let env: &[(&str, &str)] = &[("PATH", &path)]; + let text = match &bundle { + Some(b) => query_in(b, &["list", "--paths"], Some(root), env, QUERY_TIMEOUT), + None => query_in(ruby, &["-S", "bundle", "list", "--paths"], Some(root), env, QUERY_TIMEOUT), + }; + for line in text.unwrap_or_default().lines().map(str::trim).filter(|l| !l.is_empty()) { + let path = PathBuf::from(line); + if path.is_dir() && path != root { + gems.push((file_name(&path), path)); + } + } + } + (stdlib, gems) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[cfg(unix)] + #[test] + fn includes_bundled_gems_installed_inside_the_project() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().join("project"); + let gem = root.join("vendor/bundle/ruby/4.0.0/gems/rake-13.4.0"); + let external = dir.path().join("gems/rspec-3.13.2"); + let bin = dir.path().join("bin"); + for path in [&gem, &external, &bin] { + std::fs::create_dir_all(path).unwrap(); + } + std::fs::write(root.join("Gemfile"), "gem 'rake'\ngem 'rspec'\n").unwrap(); + for (name, output) in [ + ("ruby", String::new()), + ("bundle", format!("{}\n{}\n{}", gem.display(), external.display(), root.display())), + ] { + let path = bin.join(name); + std::fs::write(&path, format!("#!/bin/sh\nprintf '%s\\n' '{output}'\n")).unwrap(); + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + + let (_, gems) = library_roots(&bin.join("ruby"), Some(&root)); + assert_eq!(gems, vec![("rake-13.4.0".into(), gem), ("rspec-3.13.2".into(), external)]); + } + + #[test] + fn parses_ruby_version_output() { + assert_eq!( + parse_version("ruby 3.3.1 (2024-04-23 revision c56cd86388) [arm64-darwin23]").as_deref(), + Some("3.3.1") + ); + assert_eq!(parse_version("ruby 3.4.0dev (2024-10-01) +PRISM [x86_64-linux]").as_deref(), Some("3.4.0")); + assert_eq!(parse_version("jruby 9.4.5.0"), None); + } + + #[test] + fn version_pins_match_prefixes() { + assert!(version_matches("3.3.1", "3.3.1")); + assert!(version_matches("3.3.1", "3.3")); + assert!(!version_matches("3.31.1", "3.3")); + assert!(!version_matches("3.3.1", "3.3.10")); + } + + #[test] + fn ruby_version_file_wins_over_managers() { + let dir = tempfile::tempdir().unwrap(); + let project = dir.path().join("app/sub"); + std::fs::create_dir_all(&project).unwrap(); + std::fs::write(dir.path().join("app/.ruby-version"), "ruby-3.2.2\n").unwrap(); + assert_eq!(pinned_ruby_version(&project).as_deref(), Some("3.2.2")); + } +} diff --git a/languages/erb.toml b/languages/erb.toml new file mode 100644 index 000000000..50cfd6d0e --- /dev/null +++ b/languages/erb.toml @@ -0,0 +1,6 @@ +id = "erb" +name = "ERB" +extensions = ["erb", "html.erb", "rhtml", "js.erb", "text.erb", "xml.erb", "json.erb"] +block-comment = ["<%#", "%>"] +indent = { tab-size = 2, insert-spaces = true } +ui-mode = "ERB" diff --git a/src/bindings.ts b/src/bindings.ts index 13367ac86..7f3e69b7c 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -4098,6 +4098,7 @@ export type LanguagesSettings = { typescript?: TypeScriptSettings, prettier?: PrettierSettings, java?: JavaSettings, + ruby?: RubySettings, }; /** What a freshly opened window should do once its frontend is up. */ @@ -5325,6 +5326,11 @@ export type RevisionChange = { export type RootKind = "source" | "test" | "resources" | "testResources" | "excluded"; +export type RubySettings = { + /** Project interpreter executable; empty = auto-detect (`.ruby-version`, mise / asdf, else `ruby` on PATH). */ + interpreter?: string, +}; + export type RouteSource = { path: string, text: string, @@ -5538,7 +5544,7 @@ export type SdkKind = "python" | "node" | /** JDK homes. */ "jdk" | /** Maven homes. */ -"maven"; +"maven" | "ruby"; /** * IDEA's search context: matches anywhere, only in, or outside comments and diff --git a/src/editor/core/cm/erb.test.ts b/src/editor/core/cm/erb.test.ts new file mode 100644 index 000000000..2d70528e5 --- /dev/null +++ b/src/editor/core/cm/erb.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { EditorState } from "@codemirror/state"; +import { ensureSyntaxTree, syntaxTree } from "@codemirror/language"; +import { classHighlighter, highlightTree } from "@lezer/highlight"; +import { findLanguageDescription, loadSupport } from "./languages"; + +async function tokens(source: string) { + const desc = findLanguageDescription("ERB", "show.html.erb")!; + const support = await loadSupport(desc); + const state = EditorState.create({ doc: source, extensions: [support!] }); + ensureSyntaxTree(state, source.length, 1000); + const result: { text: string; style: string }[] = []; + highlightTree(syntaxTree(state), classHighlighter, (from, to, style) => + result.push({ text: source.slice(from, to), style }), + ); + return result; +} + +describe("ERB mode", () => { + it("highlights HTML outside and Ruby inside the tags", async () => { + const out = await tokens('
    <%= user.name if user %>
    \n<%# note %>\n<% 42 %>'); + expect(out).toContainEqual(expect.objectContaining({ text: "div", style: "tok-typeName" })); + expect(out).toContainEqual(expect.objectContaining({ text: "if", style: "tok-keyword" })); + expect(out).toContainEqual(expect.objectContaining({ text: "42", style: "tok-number" })); + expect(out).toContainEqual(expect.objectContaining({ text: "<%# note %>", style: "tok-comment" })); + expect(out.filter((t) => t.text === "<%=" || t.text === "%>")).toHaveLength(3); + }); + + it("keeps Ruby state across lines", async () => { + const out = await tokens('<% s = "a\n

    " %>

    '); + expect(out.filter((t) => t.text === "p")).toHaveLength(1); + }); +}); diff --git a/src/editor/core/cm/erb.ts b/src/editor/core/cm/erb.ts new file mode 100644 index 000000000..8e8c3f3cf --- /dev/null +++ b/src/editor/core/cm/erb.ts @@ -0,0 +1,78 @@ +/** + * ERB (Embedded Ruby) editor mode: HTML outside `<% … %>`, Ruby inside. Each + * region is tokenized by its own legacy stream mode on a sub-stream bounded at + * the next delimiter, so neither mode sees the other's text. + */ +import { LanguageSupport, StreamLanguage, StringStream, type StreamParser } from "@codemirror/language"; +import { tags } from "@lezer/highlight"; + +type State = { html: H; ruby: R; inRuby: boolean; indentUnit: number }; + +const OPEN = /<%(?:==|=|-|#)?/; +const CLOSE = /-?%>/; + +export function erbParser(html: StreamParser, ruby: StreamParser): StreamParser> { + const copy = (base: StreamParser, state: S): S => + base.copyState + ? base.copyState(state) + : (Object.fromEntries( + Object.entries(state as object).map(([k, v]) => [k, Array.isArray(v) ? v.slice() : v]), + ) as S); + + /** Runs `base` on `stream` cut at `end`, then advances `stream` to where it stopped. */ + const within = (stream: StringStream, end: number, base: StreamParser, state: S): string | null => { + const sub = new StringStream(stream.string.slice(0, end), 4, stream.indentUnit); + sub.pos = stream.pos; + sub.start = stream.start; + const style = base.token(sub, state); + stream.pos = Math.max(sub.pos, stream.pos + (sub.pos === stream.pos ? 1 : 0)); + return style; + }; + + return { + name: "erb", + languageData: { ...html.languageData, commentTokens: { block: { open: "<%#", close: "%>" } } }, + tokenTable: { ...html.tokenTable, ...ruby.tokenTable, erbDelimiter: tags.special(tags.brace) }, + startState: (indentUnit) => ({ + html: html.startState!(indentUnit), + ruby: ruby.startState!(indentUnit), + inRuby: false, + indentUnit, + }), + copyState: (s) => ({ ...s, html: copy(html, s.html), ruby: copy(ruby, s.ruby) }), + blankLine: (s, unit) => (s.inRuby ? ruby.blankLine?.(s.ruby, unit) : html.blankLine?.(s.html, unit)), + indent: (s, text, cx) => (s.inRuby ? ruby.indent?.(s.ruby, text, cx) : html.indent?.(s.html, text, cx)) ?? null, + token(stream, state) { + const rest = stream.string.slice(stream.pos); + if (!state.inRuby) { + const open = OPEN.exec(rest); + if (open?.index === 0) { + stream.pos += open[0].length; + if (open[0] === "<%#") { + const close = CLOSE.exec(stream.string.slice(stream.pos)); + stream.pos = close ? stream.pos + close.index + close[0].length : stream.string.length; + return "comment"; + } + state.inRuby = true; + return "erbDelimiter"; + } + return within(stream, open ? stream.pos + open.index : stream.string.length, html, state.html); + } + const close = CLOSE.exec(rest); + if (close?.index === 0) { + stream.pos += close[0].length; + state.inRuby = false; + return "erbDelimiter"; + } + return within(stream, close ? stream.pos + close.index : stream.string.length, ruby, state.ruby); + }, + }; +} + +export async function erb(): Promise { + const [{ html }, { ruby }] = await Promise.all([ + import("@codemirror/legacy-modes/mode/xml"), + import("@codemirror/legacy-modes/mode/ruby"), + ]); + return new LanguageSupport(StreamLanguage.define(erbParser(html, ruby))); +} diff --git a/src/editor/core/cm/languages.ts b/src/editor/core/cm/languages.ts index 9be9fbe34..f6c01d560 100644 --- a/src/editor/core/cm/languages.ts +++ b/src/editor/core/cm/languages.ts @@ -96,6 +96,12 @@ const OWN_MODES = [ }), ), LanguageDescription.of({ name: "Angular Template", load: () => import("./angular").then((m) => m.angular()) }), + LanguageDescription.of({ + name: "ERB", + alias: ["erb", "rhtml"], + extensions: ["erb", "rhtml"], + load: () => import("./erb").then((m) => m.erb()), + }), ]; const BATCH_KEYWORDS = new Set([ diff --git a/src/features/libraries/index.ts b/src/features/libraries/index.ts index 622f88cca..350507ba9 100644 --- a/src/features/libraries/index.ts +++ b/src/features/libraries/index.ts @@ -26,6 +26,8 @@ const GROUP_ICONS: Record = { goModules: "file-gomod", maven: "maven-project", gradle: "file-gradle", + ruby: "file-ruby", + gems: "file-ruby", }; /** An archive's root (`/jdk/lib/src.zip!`): its entries live below `!/`. */ diff --git a/src/features/run/configs.test.ts b/src/features/run/configs.test.ts index 162e0adf8..d27eb2655 100644 --- a/src/features/run/configs.test.ts +++ b/src/features/run/configs.test.ts @@ -152,6 +152,8 @@ describe("context configuration", () => { cfg({ id: "pytest", kind: "pytest", command: "uv", args: ["run", "pytest"], isTest: true }), cfg({ id: "python:module:app", kind: "python", command: ".venv/bin/python", args: ["-m", "app"] }), cfg({ id: "gradle:test", kind: "gradle", command: "./gradlew", args: ["test"], isTest: true }), + cfg({ id: "rspec", kind: "rspec", command: "bundle", args: ["exec", "rspec"], isTest: true }), + cfg({ id: "rake:test", kind: "rake", command: "bundle", args: ["exec", "rake", "test"], isTest: true }), ]; it("classifies files", () => { @@ -163,6 +165,9 @@ describe("context configuration", () => { expect(fileRole("polls/tests.py")).toBe("script"); expect(fileRole("src/test/kotlin/OrderControllerTest.kt")).toBe("test"); expect(fileRole("tests/integration.rs")).toBe("test"); + expect(fileRole("spec/models/user_spec.rb")).toBe("test"); + expect(fileRole("test/user_test.rb")).toBe("test"); + expect(fileRole("lib/tasks/gen.rb")).toBe("script"); expect(fileRole("src/main.rs")).toBe("main"); expect(fileRole("cmd/main.go", "package main\nfunc main() {}")).toBe("main"); expect(fileRole("tools/gen.py")).toBe("script"); @@ -184,6 +189,20 @@ describe("context configuration", () => { expect(contextConfig("src/a.spec.js", configs)!.args).toEqual(["run", "test", "--", "src/a.spec.js"]); }); + it("narrows rspec and rake test runs to the file", () => { + expect(contextConfig("spec/models/user_spec.rb", configs)!.args).toEqual([ + "exec", + "rspec", + "spec/models/user_spec.rb", + ]); + expect(contextConfig("test/user_test.rb", configs)!.args).toEqual([ + "exec", + "rake", + "test", + "TEST=test/user_test.rb", + ]); + }); + it("narrows go, cargo, pytest and gradle runs", () => { expect(contextConfig("internal/calc/calc_test.go", configs)!.args).toEqual(["test", "./internal/calc"]); expect(contextConfig("tests/api.rs", configs)!.args).toEqual(["test", "--test", "api"]); diff --git a/src/features/run/context.ts b/src/features/run/context.ts index c71bf6e6d..2e7d9e554 100644 --- a/src/features/run/context.ts +++ b/src/features/run/context.ts @@ -19,6 +19,7 @@ export function fileRole(relPath: string, content?: string, windows = isWindows( /^test_.*\.py$|_test\.py$/.test(name) || (django && name === "tests.py") || /_test\.go$/.test(name) || + /_(spec|test)\.rb$/.test(name) || /(Test|Tests|Spec|IT)\.(kt|java|scala|groovy)$/.test(name) || (/(^|\/)tests\/[^/]+\.rs$/.test(relPath) && name !== "mod.rs") ) @@ -27,7 +28,7 @@ export function fileRole(relPath: string, content?: string, windows = isWindows( if (/\.go$/.test(name) && content !== undefined && /^\s*package\s+main\b/m.test(content)) return "main"; if (/\.(kt|java)$/.test(name) && content !== undefined && /\bfun\s+main\s*\(|static\s+void\s+main\s*\(/.test(content)) return "main"; - if (/\.(py|sh|bash|zsh|[cm]?js|ts)$/.test(name)) return "script"; + if (/\.(py|rb|sh|bash|zsh|[cm]?js|ts)$/.test(name)) return "script"; if (windows && /\.(bat|cmd)$/i.test(name)) return "script"; return null; } @@ -39,6 +40,8 @@ const stem = (p: string) => (p.split("/").pop() ?? p).replace(/\.[^.]+$/, ""); function testKinds(relPath: string): string[] { if (/\.[cm]?[jt]sx?$/.test(relPath)) return ["npm", "deno", "bun"]; if (/\.py$/.test(relPath)) return ["pytest", "python", "django-test"]; + if (/_spec\.rb$/.test(relPath)) return ["rspec", "rake"]; + if (/_test\.rb$/.test(relPath)) return ["rake", "rspec"]; if (/\.go$/.test(relPath)) return ["go"]; if (/\.rs$/.test(relPath)) return ["cargo"]; if (/\.(kt|java|scala|groovy)$/.test(relPath)) return ["gradle", "maven"]; @@ -136,7 +139,9 @@ function implicitPytest(relPath: string, configs: RunConfig[]): RunConfig | null export function testBasesFor(relPath: string, configs: RunConfig[]): RunConfig[] { const kinds = testKinds(relPath); // Configurations produced from a test or file are results of narrowing, never bases for it. - const candidates = configs.filter((c) => c.isTest && kinds.includes(c.kind) && !c.id.startsWith("context:")); + const candidates = configs + .filter((c) => c.isTest && kinds.includes(c.kind) && !c.id.startsWith("context:")) + .sort((a, b) => kinds.indexOf(a.kind) - kinds.indexOf(b.kind)); const best = allClosest(candidates, relPath); if (best.length) return best; const fallback = candidates[0] ?? implicitPytest(relPath, configs); @@ -162,7 +167,14 @@ export function testConfigFor(relPath: string, configs: RunConfig[], base?: RunC return derived(base, relPath, { args: [...base.args, file] }); case "pytest": case "python": + case "rspec": return derived(base, relPath, { args: [...base.args, file] }); + case "rake": { + // `rake test` (minitest) takes the file as TEST=; `rake spec` as SPEC=. + const task = base.args.at(-1) ?? ""; + const key = /(^|:)spec$/.test(task) ? "SPEC" : /(^|:)test$/.test(task) ? "TEST" : null; + return key ? derived(base, relPath, { args: [...base.args, `${key}=${file}`] }) : base; + } case "django-test": { const tests = djangoTestsOf(base, [djangoLabel(file)]); return tests ? derived(base, relPath, tests) : null; diff --git a/src/features/run/rubyTypes.ts b/src/features/run/rubyTypes.ts new file mode 100644 index 000000000..f7f3580d8 --- /dev/null +++ b/src/features/run/rubyTypes.ts @@ -0,0 +1,199 @@ +/** + * Ruby run configuration types: a script (`ruby [options] script [args]`), a Rake + * task and RSpec, the latter two through `bundle exec` when the project uses Bundler. + * The interpreter itself comes from Settings › Ruby (`apply_to_run_configs`). + */ +import type { RunConfig } from "@/platform/ipc"; +import type { RunConfigTypeContribution } from "@/workbench/contributions"; +import { formatArgs, parseArgs } from "./configs"; +import { commandName } from "./commandName"; + +const on = (v: string | undefined) => v === "true"; + +/** `bundle exec ` or ``, launched through `params.command` when one is set. */ +function tool(p: Readonly>, name: string): { command: string; args: string[] } { + const command = p.command?.trim(); + return on(p.bundler) + ? { command: command || "bundle", args: ["exec", name] } + : { command: command || name, args: [] }; +} + +/** Splits `bundle exec rest…` / ` rest…` into the launcher, the Bundler flag and `rest`. */ +function unwrap(c: RunConfig, name: string): { command: string; bundler: string; rest: string[] } | null { + const cmd = commandName(c.command); + const bare = c.command === cmd; + if (cmd === "bundle" && c.args[0] === "exec" && c.args[1] === name) + return { command: bare ? "" : c.command, bundler: "true", rest: c.args.slice(2) }; + if (cmd === name) return { command: bare ? "" : c.command, bundler: "", rest: c.args }; + return null; +} + +/** + * Index of the first operand in `args` (the script, spec target or Rake task): the + * first argument that is not an option, nor the value of the option before it. A + * token after an option without `=` is that option's value when the option is known + * to take one (`-I lib`, `--format documentation`) or, with `operand`, when the token + * does not look like an operand (`--foo bar spec/a_spec.rb`); `--` ends the options. + */ +function operandIndex( + args: readonly string[], + valueOptions: ReadonlySet, + operand: (a: string) => boolean = () => true, +): number { + for (let i = 0; i < args.length; i++) { + const a = args[i]!; + if (a === "--") return i + 1 < args.length ? i + 1 : -1; + if (a.startsWith("-")) continue; + const prev = i > 0 ? args[i - 1]! : ""; + const value = prev.startsWith("-") && !prev.includes("=") && (valueOptions.has(prev) || !operand(a)); + if (!value) return i; + } + return -1; +} + +const RUBY_VALUE_OPTIONS = new Set( + "-I -r -C -E -e -F --encoding --external-encoding --internal-encoding --enable --disable --dump".split(" "), +); +const RSPEC_VALUE_OPTIONS = new Set( + ( + "-I -r --require -O --options -P --pattern --exclude-pattern --default-path -o --out --deprecation-out " + + "-f --format -e --example -E --example-matches -t --tag --seed --order" + ).split(" "), +); +const RAKE_VALUE_OPTIONS = new Set( + ( + "-f --rakefile -I --libdir -r --require -R --rakelibdir -j --jobs -C --directory " + + "-e --execute -E --execute-continue -p --execute-print" + ).split(" "), +); + +const looksLikeScript = (a: string) => /[\\/]|\.rb$/.test(a); +const looksLikeSpecTarget = (a: string) => /[\\/]|\.rb(:\d+|\[[\d:]+\])?$|^(spec|\.)$/.test(a); + +const bundlerField = { + key: "bundler", + label: "Run with Bundler (bundle exec)", + control: "checkbox", +} as const; + +const commandField = (name: string) => + ({ + key: "command", + label: "Launcher", + mono: true, + placeholder: name, + hint: "Path of the tool (bundle when run with Bundler); empty uses the interpreter from Settings › Ruby", + }) as const; + +export const rubyScriptType: RunConfigTypeContribution = { + id: "ruby", + label: "Ruby", + icon: "file-ruby", + description: "Run a Ruby script", + defaults: {}, + fields: [ + { key: "script", label: "Ruby script", mono: true, required: true, placeholder: "e.g. bin/rails or lib/app.rb" }, + { key: "scriptArgs", label: "Script arguments", mono: true, placeholder: "e.g. server -p 3000" }, + { key: "rubyArgs", label: "Ruby arguments", mono: true, placeholder: "e.g. -Ilib -w" }, + bundlerField, + commandField("ruby"), + ], + commandLine(p) { + const t = tool(p, "ruby"); + return { + command: t.command, + args: [ + ...t.args, + ...parseArgs(p.rubyArgs ?? ""), + ...parseArgs(p.script?.trim() ?? ""), + ...parseArgs(p.scriptArgs ?? ""), + ], + }; + }, + paramsFrom(c) { + if (c.kind !== "ruby") return null; + const u = unwrap(c, "ruby"); + if (!u) return null; + const at = operandIndex(u.rest, RUBY_VALUE_OPTIONS, looksLikeScript); + // A new configuration (`ruby`, nothing else) opens in the form with the script still to fill in; + // options without a script (`ruby -e …`, `ruby -v`) stay a plain command. + if (at < 0) + return u.rest.length + ? null + : { command: u.command, bundler: u.bundler, rubyArgs: "", script: "", scriptArgs: "" }; + return { + command: u.command, + bundler: u.bundler, + rubyArgs: formatArgs(u.rest.slice(0, at)), + script: formatArgs([u.rest[at]!]), + scriptArgs: formatArgs(u.rest.slice(at + 1)), + }; + }, +}; + +export const rakeType: RunConfigTypeContribution = { + id: "rake", + label: "Rake", + icon: "file-ruby", + description: "Run a Rake task", + defaults: { bundler: "true" }, + fields: [ + { + key: "task", + label: "Task", + mono: true, + placeholder: "default", + hint: "Task name with arguments, e.g. db:migrate or test TEST=test/a_test.rb; empty runs the default task", + suggestions: (configs) => [ + ...new Set(configs.filter((c) => c.kind === "rake" && c.detected).map((c) => c.args.at(-1) ?? "")), + ], + }, + { key: "options", label: "Rake options", mono: true, placeholder: "e.g. --trace" }, + bundlerField, + commandField("rake"), + ], + commandLine(p) { + const t = tool(p, "rake"); + return { command: t.command, args: [...t.args, ...parseArgs(p.options ?? ""), ...parseArgs(p.task ?? "")] }; + }, + paramsFrom(c) { + if (c.kind !== "rake") return null; + const u = unwrap(c, "rake"); + if (!u) return null; + const at = operandIndex(u.rest, RAKE_VALUE_OPTIONS); + const [options, task] = at < 0 ? [u.rest, []] : [u.rest.slice(0, at), u.rest.slice(at)]; + return { command: u.command, bundler: u.bundler, options: formatArgs(options), task: formatArgs(task) }; + }, +}; + +export const rspecType: RunConfigTypeContribution = { + id: "rspec", + label: "RSpec", + icon: "file-ruby", + description: "Run RSpec examples", + defaults: { bundler: "true" }, + fields: [ + { + key: "target", + label: "Target", + mono: true, + placeholder: "e.g. spec", + hint: "Directory, file or file:line; empty runs the whole suite", + }, + { key: "options", label: "RSpec options", mono: true, placeholder: "e.g. --format documentation --fail-fast" }, + bundlerField, + commandField("rspec"), + ], + commandLine(p) { + const t = tool(p, "rspec"); + return { command: t.command, args: [...t.args, ...parseArgs(p.options ?? ""), ...parseArgs(p.target ?? "")] }; + }, + paramsFrom(c) { + if (c.kind !== "rspec") return null; + const u = unwrap(c, "rspec"); + if (!u) return null; + const at = operandIndex(u.rest, RSPEC_VALUE_OPTIONS, looksLikeSpecTarget); + const [options, target] = at < 0 ? [u.rest, []] : [u.rest.slice(0, at), u.rest.slice(at)]; + return { command: u.command, bundler: u.bundler, options: formatArgs(options), target: formatArgs(target) }; + }, +}; diff --git a/src/features/run/runConfigTypes.test.tsx b/src/features/run/runConfigTypes.test.tsx index fd207b13f..1fb73c57b 100644 --- a/src/features/run/runConfigTypes.test.tsx +++ b/src/features/run/runConfigTypes.test.tsx @@ -103,6 +103,12 @@ describe("configuration types", () => { ["pytest", "pytest.exe", ["tests/test_app.py", "-q"]], ["pytest", String.raw`C:\project\.venv\Scripts\PYTEST.EXE`, ["-q"]], ["pytest", "PYTHON.EXE", ["-m", "pytest", "tests"]], + ["ruby", String.raw`C:\Ruby33\bin\ruby.exe`, ["-Ilib", "bin/app", "--port", "3000"]], + ["rake", "bundle", ["exec", "rake", "--trace", "db:migrate"]], + ["rspec", "/home/me/.rbenv/versions/3.3.1/bin/rspec", ["--fail-fast", "spec/a_spec.rb:12"]], + ["rspec", "bundle", ["exec", "rspec", "--format", "documentation", "--fail-fast", "spec/foo_spec.rb"]], + ["ruby", "ruby", ["-I", "lib", "-w", "bin/app", "--port", "3000"]], + ["rake", "bundle", ["exec", "rake", "-f", "lib/tasks.rake", "--trace", "db:migrate"]], ["npm", String.raw`C:\Program Files\nodejs\NPM.CMD`, ["run", "test", "--", "two words"]], ["npm", "pnpm.COM", ["test"]], ["application", String.raw`C:\jdk\bin\JAVA.EXE`, ["-cp", "lib/*", "a.Main"]], @@ -172,6 +178,75 @@ describe("configuration types", () => { expect(godot.paramsFrom!(cfg("x", { command: "cargo" }))).toBeNull(); }); + it("ruby types default to Bundler for rake and rspec and mark rspec as tests", () => { + expect(newConfigOfType(typeFor("rake")!, [])).toMatchObject({ command: "bundle", args: ["exec", "rake"] }); + const ruby = newConfigOfType(typeFor("ruby")!, []); + expect(ruby).toMatchObject({ command: "ruby", args: [] }); + expect(formParams(ruby)).toEqual({ command: "", bundler: "", rubyArgs: "", script: "", scriptArgs: "" }); + const rspec = newConfigOfType(typeFor("rspec")!, []); + expect(rspec).toMatchObject({ command: "bundle", args: ["exec", "rspec"], isTest: true }); + expect(withParams(rspec, { bundler: "", target: "spec/a_spec.rb", options: "-fd" })).toMatchObject({ + command: "rspec", + args: ["-fd", "spec/a_spec.rb"], + isTest: true, + }); + expect(formParams(cfg("r", { kind: "ruby", command: "ruby", args: ["app.rb", "-v"] }))).toEqual({ + command: "", + bundler: "", + rubyArgs: "", + script: "app.rb", + scriptArgs: "-v", + }); + }); + + it("ruby types keep option values with their options and the argument order as written", () => { + const ruby = (args: string[]) => formParams(cfg("r", { kind: "ruby", command: "ruby", args })); + expect(ruby(["-I", "lib", "-w", "bin/app", "--port", "3000"])).toEqual({ + command: "", + bundler: "", + rubyArgs: "-I lib -w", + script: "bin/app", + scriptArgs: "--port 3000", + }); + expect(ruby(["-W0", "--", "-weird.rb", "x"])).toMatchObject({ + rubyArgs: "-W0 --", + script: "-weird.rb", + scriptArgs: "x", + }); + expect(ruby(["-e", "puts 1"])).toBeNull(); + expect(ruby(["-v"])).toBeNull(); + + const rspec = (args: string[]) => + formParams(cfg("s", { kind: "rspec", command: "bundle", args: ["exec", "rspec", ...args] })); + expect(rspec(["--format", "documentation", "--fail-fast", "spec/foo_spec.rb"])).toEqual({ + command: "", + bundler: "true", + options: "--format documentation --fail-fast", + target: "spec/foo_spec.rb", + }); + expect(rspec(["--tag", "~slow", "-fd", "spec/a_spec.rb:12", "spec/b_spec.rb"])).toMatchObject({ + options: "--tag ~slow -fd", + target: "spec/a_spec.rb:12 spec/b_spec.rb", + }); + expect(rspec(["--format", "documentation"])).toMatchObject({ options: "--format documentation", target: "" }); + expect(rspec(["--new-option", "value", "spec"])).toMatchObject({ options: "--new-option value", target: "spec" }); + expect(rspec([])).toMatchObject({ options: "", target: "" }); + + const rake = (args: string[]) => + formParams(cfg("k", { kind: "rake", command: "bundle", args: ["exec", "rake", ...args] })); + expect(rake(["-f", "lib/tasks.rake", "--trace", "db:migrate"])).toEqual({ + command: "", + bundler: "true", + options: "-f lib/tasks.rake --trace", + task: "db:migrate", + }); + expect(rake(["--trace", "test", "TEST=test/a_test.rb"])).toMatchObject({ + options: "--trace", + task: "test TEST=test/a_test.rb", + }); + expect(rake(["-T"])).toMatchObject({ options: "-T", task: "" }); + }); + it("Django tests round-trip commandLine / paramsFrom and always report to the test tree", () => { const django = typeFor("django-test")!; expect(django.debugAdapter).toBe("debugpy"); diff --git a/src/features/run/types.ts b/src/features/run/types.ts index a5b51f4fa..172b07443 100644 --- a/src/features/run/types.ts +++ b/src/features/run/types.ts @@ -23,6 +23,7 @@ import { tomcatType } from "./tomcat"; import { goBuildType, goTestType } from "./goTypes"; import { dotnetProjectType, dotnetTestType } from "./dotnetTypes"; +import { rakeType, rspecType, rubyScriptType } from "./rubyTypes"; import { djangoTestType } from "./djangoTypes"; export const COMPOUND = "compound"; @@ -399,6 +400,9 @@ export const BUILTIN_TYPES: readonly RunConfigTypeContribution[] = [ maven, godot, pytest, + rubyScriptType, + rakeType, + rspecType, djangoTestType, dotnetProjectType, dotnetTestType, @@ -453,7 +457,7 @@ export function withParams(config: RunConfig, params: Record, ty ...(config.kind === "dotnet" && config.beforeLaunch === undefined ? { beforeLaunch: [{ kind: "dotnetBuild", target: "" }] } : {}), - ...(config.kind === "dotnet-test" || config.kind === "django-test" ? { isTest: true } : {}), + ...(config.kind === "dotnet-test" || config.kind === "django-test" || config.kind === "rspec" ? { isTest: true } : {}), params: params.goKind ? { ...params, programArgv: JSON.stringify(parseArgs(params.programArgs ?? "")) } : params, ...(config.kind === "go-test" && !config.id.startsWith("template:") ? { kind: "go" } : {}), ...(params.goKind ? { isTest: params.goKind === "test" } : {}), diff --git a/src/features/settings/__tests__/languages.test.ts b/src/features/settings/__tests__/languages.test.ts index 5864b0586..0f78d2950 100644 --- a/src/features/settings/__tests__/languages.test.ts +++ b/src/features/settings/__tests__/languages.test.ts @@ -35,6 +35,14 @@ describe("languages settings", () => { expect(validateSettings({}).languages).toEqual(DEFAULT_LANGUAGES); }); + it("keeps the Ruby interpreter through settings normalization and defaults to Auto", () => { + expect(validateSettings({}).languages.ruby.interpreter).toBe(""); + expect(validateLanguages({ ruby: { interpreter: 42 } }).ruby.interpreter).toBe(""); + const settings = validateSettings({ languages: { ruby: { interpreter: " /opt/ruby/bin/ruby " } } }); + expect(settings.languages.ruby.interpreter).toBe("/opt/ruby/bin/ruby"); + expect(validateSettings(settings).languages.ruby.interpreter).toBe("/opt/ruby/bin/ruby"); + }); + it("keeps Prettier opt-in on save and normalizes its enabled languages", () => { expect(validateLanguages({}).prettier).toMatchObject({ prefer: false, runOnSave: false, path: "" }); expect( @@ -69,6 +77,7 @@ describe("language pages", () => { "Node.js", "Python Interpreter", "Prettier", + "Ruby", "Rust", "TypeScript", ]); diff --git a/src/features/settings/languages/LanguagePages.tsx b/src/features/settings/languages/LanguagePages.tsx index e507588a1..ca3a14f75 100644 --- a/src/features/settings/languages/LanguagePages.tsx +++ b/src/features/settings/languages/LanguagePages.tsx @@ -109,6 +109,28 @@ export function NodePage() { ); } +// --------------------------------------------------------------------------- +// Ruby + +export function RubyPage() { + return ( +

    +
    + +
    +
    + ); +} + // --------------------------------------------------------------------------- // Rust diff --git a/src/features/settings/pages/languages.tsx b/src/features/settings/pages/languages.tsx index 181afc052..8f217c377 100644 --- a/src/features/settings/pages/languages.tsx +++ b/src/features/settings/pages/languages.tsx @@ -3,7 +3,7 @@ * Languages & Frameworks › Go / Java / Node.js / Rust / TypeScript. Pages live in * `../languages/`; settings are `languages.*` / `projectStructure.*`. */ -import { GoPage, JavaPage, NodePage, RustPage, TypeScriptPage } from "../languages/LanguagePages"; +import { GoPage, JavaPage, NodePage, RubyPage, RustPage, TypeScriptPage } from "../languages/LanguagePages"; import { PrettierPage } from "../languages/PrettierPage"; import { ProjectStructurePage } from "../languages/ProjectStructurePage"; import { PythonInterpreterPage } from "../languages/PythonInterpreterPage"; @@ -118,6 +118,16 @@ export const LANGUAGE_PAGES: PageSpec[] = [ keywords: ["formatter", "prettier", "markdown", "yaml", "css", "format on save", "node_modules"], component: PrettierPage, }, + { + id: "languages.ruby", + projectScope: true, + defaultScope: "project", + title: "Ruby", + group: LANGUAGES_GROUP, + order: 403.8, + keywords: ["ruby interpreter", "rbenv", "rvm", "chruby", "bundler", "gems", "rake", "rspec"], + component: RubyPage, + }, { id: "languages.rust", projectScope: true, diff --git a/src/platform/languageSettings.ts b/src/platform/languageSettings.ts index 055ff9d47..01032a2a7 100644 --- a/src/platform/languageSettings.ts +++ b/src/platform/languageSettings.ts @@ -27,6 +27,7 @@ const DEFAULT_GO_BUILD_TAGS: GoBuildTags = { export interface LanguagesValid { python: { interpreter: string; activateVirtualenv: boolean }; + ruby: { interpreter: string }; node: { interpreter: string; packageManager: "" | "npm" | "pnpm" | "yarn" | "bun" }; rust: { toolchain: string; @@ -77,6 +78,7 @@ export type FolderListKey = export const DEFAULT_LANGUAGES: LanguagesValid = { python: { interpreter: "", activateVirtualenv: true }, + ruby: { interpreter: "" }, node: { interpreter: "", packageManager: "" }, rust: { toolchain: "", stdlib: "", checkOnSave: true, checkCommand: "check", rustfmtArgs: "" }, go: { goroot: "", gopath: "", buildTags: DEFAULT_GO_BUILD_TAGS, modules: { env: {}, autoVendor: true } }, @@ -158,6 +160,7 @@ export function validateLanguages(raw: unknown): LanguagesValid { const d = DEFAULT_LANGUAGES; const l = obj(raw); const py = obj(l.python); + const ruby = obj(l.ruby); const node = obj(l.node); const rust = obj(l.rust); const go = obj(l.go); @@ -173,6 +176,7 @@ export function validateLanguages(raw: unknown): LanguagesValid { interpreter: text(py.interpreter, d.python.interpreter), activateVirtualenv: bool(py.activateVirtualenv, d.python.activateVirtualenv), }, + ruby: { interpreter: text(ruby.interpreter, d.ruby.interpreter) }, node: { interpreter: text(node.interpreter, d.node.interpreter), packageManager: oneOf(node.packageManager, ["", "npm", "pnpm", "yarn", "bun"] as const, d.node.packageManager), diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 3a2d099ae..5891763c5 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -4876,6 +4876,30 @@ "Directory, file or test id; empty runs the whole suite": "目录、文件或测试 ID;留空则运行整个测试套件", "pytest options": "pytest 选项", "e.g. -x -q --tb=short": "例如 -x -q --tb=short", + "Ruby": "Ruby 语言", + "Run a Ruby script": "运行 Ruby 脚本", + "Ruby script": "Ruby 脚本", + "e.g. bin/rails or lib/app.rb": "例如 bin/rails 或 lib/app.rb", + "Script arguments": "脚本实参", + "e.g. server -p 3000": "例如 server -p 3000", + "Ruby arguments": "Ruby 实参", + "e.g. -Ilib -w": "例如 -Ilib -w", + "Run with Bundler (bundle exec)": "通过 Bundler 运行(bundle exec)", + "Launcher": "启动器", + "Path of the tool (bundle when run with Bundler); empty uses the interpreter from Settings › Ruby": "工具的路径(通过 Bundler 运行时为 bundle);留空则使用“设置 › Ruby”中的解释器", + "Rake": "Rake 任务", + "Run a Rake task": "运行 Rake 任务", + "Task name with arguments, e.g. db:migrate or test TEST=test/a_test.rb; empty runs the default task": "任务名及其实参,例如 db:migrate 或 test TEST=test/a_test.rb;留空则运行默认任务", + "Rake options": "Rake 选项", + "e.g. --trace": "例如 --trace", + "RSpec": "RSpec 测试", + "Run RSpec examples": "运行 RSpec 示例", + "e.g. spec": "例如 spec", + "Directory, file or file:line; empty runs the whole suite": "目录、文件或 文件:行号;留空则运行整个测试套件", + "RSpec options": "RSpec 选项", + "e.g. --format documentation --fail-fast": "例如 --format documentation --fail-fast", + "Ruby interpreter": "Ruby 解释器", + "Runs the ruby, rake and rspec configurations and lists the standard library and gems under External Libraries. Installs from rbenv, rvm, chruby, mise and asdf are detected.": "用于运行 ruby、rake 和 rspec 配置,并在“外部库”下列出标准库和 gem。会检测 rbenv、rvm、chruby、mise 和 asdf 安装的版本。", "Monospaced": "等宽", "Proportional": "非等宽", "Loading system fonts…": "正在加载系统字体…", diff --git a/src/ui/icons.tsx b/src/ui/icons.tsx index bcaf888bf..047091b3b 100644 --- a/src/ui/icons.tsx +++ b/src/ui/icons.tsx @@ -874,7 +874,7 @@ const EXT_ICONS = table({ "file-swift": "swift swiftinterface", "file-go": "go", "file-rust": "rs", - "file-ruby": "rb rake gemspec ru rbi jbuilder podspec", + "file-ruby": "rb rake gemspec ru rbi jbuilder podspec erb rhtml", "file-lua": "lua luau rockspec", "file-zig": "zig zon", "file-gdscript": "gd", From f67c478b8b92c09bfc75d492e38bc49e01121a99 Mon Sep 17 00:00:00 2001 From: Kevin <68811197+yangyezhuang@users.noreply.github.com> Date: Tue, 3 Nov 2026 15:22:26 +0800 Subject: [PATCH 059/106] fix(ui): wrap long tooltip text inside the bubble (#983) Hovering an editor tab with a long path showed a tooltip whose text ran past the bubble: tooltips never wrapped (`white-space: nowrap`) and were capped at 360px. Tooltip text now wraps inside the bubble, breaking long paths where needed, and the shortcut stays on one line next to it. The bubble grows up to 720px or the window width less 32px, so most paths still fit on one line. Short tooltips look as before (same size and line height), and newlines in tooltip text are still collapsed. Fixes #968. Co-authored-by: yangyezhuang <68811197+yangyezhuang@users.noreply.github.com> --- src/ui/Tooltip.module.css | 10 ++++-- src/ui/Tooltip.test.tsx | 67 +++++++++++++++++++++++++++++++++++++++ src/ui/Tooltip.tsx | 2 +- 3 files changed, 76 insertions(+), 3 deletions(-) create mode 100644 src/ui/Tooltip.test.tsx diff --git a/src/ui/Tooltip.module.css b/src/ui/Tooltip.module.css index 48cd3c4a7..d5717f3d1 100644 --- a/src/ui/Tooltip.module.css +++ b/src/ui/Tooltip.module.css @@ -5,7 +5,7 @@ display: flex; align-items: center; gap: 8px; - max-width: 360px; + max-width: min(720px, calc(100vw - 32px)); padding: 6px 10px; border-radius: var(--radius-tooltip, 6px); background: var(--bg-tooltip); @@ -15,17 +15,23 @@ font-size: var(--fs-ui); line-height: 16px; pointer-events: none; - white-space: nowrap; opacity: 0; } .tooltip[data-placed] { opacity: 1; transition: opacity var(--dur-fast) var(--ease); } +/* Long paths wrap inside the bubble; the shortcut stays on one line. */ +.text { + min-width: 0; + overflow-wrap: anywhere; +} .shortcut { font-family: var(--font-keys); font-size: var(--fs-ui); color: color-mix(in srgb, var(--fg-tooltip) 62%, transparent); + white-space: nowrap; + flex-shrink: 0; } @media (prefers-reduced-motion: reduce) { diff --git a/src/ui/Tooltip.test.tsx b/src/ui/Tooltip.test.tsx new file mode 100644 index 000000000..813bd8a8f --- /dev/null +++ b/src/ui/Tooltip.test.tsx @@ -0,0 +1,67 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { act, cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { TOOLTIP_DELAY, TooltipHost, tooltipAttrs } from "./Tooltip"; +import styles from "./Tooltip.module.css"; + +describe("tooltipAttrs", () => { + it("returns empty object when text is missing", () => { + expect(tooltipAttrs(undefined)).toEqual({}); + expect(tooltipAttrs("")).toEqual({}); + }); + + it("sets data-tooltip and optional shortcut", () => { + expect(tooltipAttrs("Close Tab")).toEqual({ "data-tooltip": "Close Tab" }); + expect(tooltipAttrs("Save All", "Ctrl+S")).toEqual({ + "data-tooltip": "Save All", + "data-tooltip-shortcut": "Ctrl+S", + }); + }); +}); + +describe("TooltipHost", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + cleanup(); + vi.useRealTimers(); + }); + + it("shows tooltip text with styles.text class and shortcut after delay", async () => { + render( +
    + + +
    , + ); + + const button = screen.getByRole("button", { name: "Tab" }); + expect(screen.queryByRole("tooltip")).toBeNull(); + + fireEvent.pointerOver(button); + expect(screen.queryByRole("tooltip")).toBeNull(); + + act(() => { + vi.advanceTimersByTime(TOOLTIP_DELAY); + }); + + const tooltip = screen.getByRole("tooltip"); + expect(tooltip).toBeInTheDocument(); + expect(tooltip).toHaveClass(styles.tooltip!); + + const textSpan = tooltip.querySelector(`.${styles.text!}`); + expect(textSpan).toBeInTheDocument(); + expect(textSpan?.textContent).toBe("d:/project/src/main.rs"); + + const shortcutSpan = tooltip.querySelector(`.${styles.shortcut!}`); + expect(shortcutSpan).toBeInTheDocument(); + expect(shortcutSpan?.textContent).toBe("Ctrl+Shift+S"); + + // Pointer out hides the tooltip + fireEvent.pointerOut(button); + expect(screen.queryByRole("tooltip")).toBeNull(); + }); +}); diff --git a/src/ui/Tooltip.tsx b/src/ui/Tooltip.tsx index 893e2b54c..0b9702208 100644 --- a/src/ui/Tooltip.tsx +++ b/src/ui/Tooltip.tsx @@ -121,7 +121,7 @@ export function TooltipHost() { data-placed={shown.placed || undefined} style={{ left: shown.x, top: shown.y }} > - {shown.text} + {shown.text} {shown.shortcut && {shown.shortcut}}
    , document.body, From 5e7ee5e08139b7ad8b06326f7672e75d908547e2 Mon Sep 17 00:00:00 2001 From: arookieofc <2128194521@qq.com> Date: Tue, 3 Nov 2026 15:25:45 +0800 Subject: [PATCH 060/106] fix(agents): delete agent tasks whose worktree git no longer recognizes (#979) Deleting a worktree-isolated agent task together with its worktree failed for good with "fatal: '' is not a working tree" once git no longer recognized the worktree: its registration was pruned, the repository moved, or the worktree's `.git` file was deleted. When `git worktree remove --force` fails but the folder is still there and lies inside the agent worktrees directory, the folder is now removed directly, so the task can always be deleted. A stale registration is pruned by git later. Paths outside that directory (the task's cwd comes from its stored record) keep git's error and are never deleted by hand. Fixes #969. Co-authored-by: a-rookie-of-C-language <186315048+a-rookie-of-C-language@users.noreply.github.com> --- crates/ide-agent/src/lib.rs | 3 +- crates/ide-agent/src/worktree.rs | 55 +++++++++++++++++++++++++++++--- 2 files changed, 53 insertions(+), 5 deletions(-) diff --git a/crates/ide-agent/src/lib.rs b/crates/ide-agent/src/lib.rs index 4f61c8e0e..cabe079ea 100644 --- a/crates/ide-agent/src/lib.rs +++ b/crates/ide-agent/src/lib.rs @@ -823,7 +823,8 @@ impl AgentManager { // A copy of the task (a fork) may work in the same worktree. let shared = self.tasks.read().values().any(|t| t.id != id && t.state.lock().task.cwd.0 == cwd); if remove_worktree && !shared && isolation == Isolation::Worktree && cwd.exists() { - blocking(move || worktree::remove(&root, &cwd)).await?; + let worktrees = self.shared.paths.worktrees_dir.clone(); + blocking(move || worktree::remove(&root, &worktrees, &cwd)).await?; } self.tasks.write().remove(id); *task.deleted.lock() = true; diff --git a/crates/ide-agent/src/worktree.rs b/crates/ide-agent/src/worktree.rs index 6b261b3ac..b0e2f62fa 100644 --- a/crates/ide-agent/src/worktree.rs +++ b/crates/ide-agent/src/worktree.rs @@ -48,8 +48,24 @@ pub(crate) fn create(project_root: &Path, worktrees_dir: &Path, title: &str, bas Ok(Created { path, branch, base, base_commit }) } -pub(crate) fn remove(project_root: &Path, path: &Path) -> IdeResult<()> { - repo_at(project_root)?.remove_worktree(path, true) +/// `git worktree remove --force -- `. When git no longer recognizes the +/// worktree (a pruned registration, a moved repository, a deleted `.git` file) +/// it fails with "is not a working tree". A folder under `worktrees_dir` is still +/// ours, so it is removed directly to keep the task deletable (a stale +/// registration prunes later); anything else keeps git's error. +pub(crate) fn remove(project_root: &Path, worktrees_dir: &Path, path: &Path) -> IdeResult<()> { + let repo = repo_at(project_root)?; + let err = match repo.remove_worktree(path, true) { + Ok(()) => return Ok(()), + Err(e) => e, + }; + if !path.exists() { + return Ok(()); + } + if !path.starts_with(worktrees_dir) || path == worktrees_dir { + return Err(err); + } + std::fs::remove_dir_all(path).map_err(|e| IdeError::internal(e.to_string())) } /// Files under `cwd` that differ from `base` (untracked files included), with line counts. @@ -153,7 +169,7 @@ mod tests { assert!(snapshot(&dir).unwrap().is_empty()); assert_eq!(file_at(&dir, &dir.join("a.txt"), "HEAD").unwrap(), None); assert!(create(&dir, &tmp.path().join("wt"), "Edit A", None).is_err()); - assert!(remove(&dir, &dir).is_err()); + assert!(remove(&dir, tmp.path(), &dir).is_err()); } fn git(dir: &Path, args: &[&str]) { @@ -202,7 +218,38 @@ mod tests { // A second task with the same title gets its own worktree. assert_eq!(create(&repo, &tmp.path().join("wt"), "Edit A", None).unwrap().branch, "agent/edit-a-2"); - remove(&repo, &created.path).unwrap(); + remove(&repo, &tmp.path().join("wt"), &created.path).unwrap(); assert!(!created.path.exists()); } + + #[test] + fn a_broken_worktree_registration_still_leaves_a_deletable_folder() { + let tmp = tempfile::tempdir().unwrap(); + let repo = tmp.path().join("repo"); + std::fs::create_dir(&repo).unwrap(); + git(&repo, &["init", "-q", "-b", "main"]); + std::fs::write(repo.join("a.txt"), "one\n").unwrap(); + git(&repo, &["add", "."]); + git(&repo, &["commit", "-q", "-m", "init"]); + + // A pruned registration (the repository moved, `git worktree prune` ran). + let created = create(&repo, &tmp.path().join("wt"), "Edit A", None).unwrap(); + let registration = repo.join(".git/worktrees").join(created.path.file_name().unwrap()); + std::fs::remove_dir_all(registration).unwrap(); + remove(&repo, &tmp.path().join("wt"), &created.path).unwrap(); + assert!(!created.path.exists()); + + // A deleted `.git` file in the worktree itself. + let created = create(&repo, &tmp.path().join("wt"), "Edit B", None).unwrap(); + std::fs::remove_file(created.path.join(".git")).unwrap(); + remove(&repo, &tmp.path().join("wt"), &created.path).unwrap(); + assert!(!created.path.exists()); + + // A folder outside the worktrees directory is never deleted by hand. + let elsewhere = tmp.path().join("elsewhere"); + std::fs::create_dir(&elsewhere).unwrap(); + std::fs::write(elsewhere.join("keep.txt"), "x\n").unwrap(); + assert!(remove(&repo, &tmp.path().join("wt"), &elsewhere).is_err()); + assert!(elsewhere.join("keep.txt").exists()); + } } From 22a5f21a900d179545f5aac542cd69e325e7de4f Mon Sep 17 00:00:00 2001 From: ntbang0901 <121003585+ntbang0901@users.noreply.github.com> Date: Tue, 3 Nov 2026 14:32:19 +0700 Subject: [PATCH 061/106] perf(vfs): list directories off the main thread (#973) `fs_read_dir` was a synchronous Tauri command, so every directory listing ran on the main thread. On a slow file system such as a network share (`\\wsl.localhost\...`), each stat while expanding the project tree stalled the whole UI. The listing now runs on the blocking pool, as the other disk-heavy commands do, both for the desktop command and over RPC. Refs #916. Co-authored-by: ntbang0901 <121003585+ntbang0901@users.noreply.github.com> --- crates/ide-host/src/ops/fs.rs | 38 +++++++++++++++++++---------------- crates/ide-host/src/rpc.rs | 2 +- src-tauri/src/commands/fs.rs | 4 ++-- 3 files changed, 24 insertions(+), 20 deletions(-) diff --git a/crates/ide-host/src/ops/fs.rs b/crates/ide-host/src/ops/fs.rs index 7c56cd3a3..0277dfc44 100644 --- a/crates/ide-host/src/ops/fs.rs +++ b/crates/ide-host/src/ops/fs.rs @@ -73,24 +73,28 @@ pub async fn copy_merge( /// Files of a deleted folder snapshotted in Local History. const DELETE_SNAPSHOT_LIMIT: usize = 2000; -pub fn read_dir(host: &Host, window: &str, path: PathBuf) -> IdeResult> { - let excludes = host - .project(window) - .map(|p| { - let globs = p.exclude_globs(); - // `read_dir` matches relative to the enclosing repository: re-anchor - // project-root patterns (`/build`) when the project is a repo subfolder. - let repo = p.root.ancestors().find(|d| d.join(".git").exists()); - match repo.and_then(|r| p.root.strip_prefix(r).ok()).filter(|rel| !rel.as_os_str().is_empty()) { - Some(rel) => { - let rel = rel.to_string_lossy().replace('\\', "/"); - globs.into_iter().map(|g| if g.starts_with('/') { format!("/{rel}{g}") } else { g }).collect() +/// Runs on the blocking pool: on a network share (`\\wsl.localhost`) every stat is slow. +pub async fn read_dir(host: &Host, window: &str, path: PathBuf) -> IdeResult> { + let project = host.project(window); + host.blocking(move || { + let excludes = project + .map(|p| { + let globs = p.exclude_globs(); + // `read_dir` matches relative to the enclosing repository: re-anchor + // project-root patterns (`/build`) when the project is a repo subfolder. + let repo = p.root.ancestors().find(|d| d.join(".git").exists()); + match repo.and_then(|r| p.root.strip_prefix(r).ok()).filter(|rel| !rel.as_os_str().is_empty()) { + Some(rel) => { + let rel = rel.to_string_lossy().replace('\\', "/"); + globs.into_iter().map(|g| if g.starts_with('/') { format!("/{rel}{g}") } else { g }).collect() + } + None => globs, } - None => globs, - } - }) - .unwrap_or_default(); - ide_vfs::read_dir(&path, &excludes) + }) + .unwrap_or_default(); + ide_vfs::read_dir(&path, &excludes) + }) + .await } /// Creates an empty file; inside a project, honours the EditorConfig diff --git a/crates/ide-host/src/rpc.rs b/crates/ide-host/src/rpc.rs index fb0cc28e9..2ab5bd7f6 100644 --- a/crates/ide-host/src/rpc.rs +++ b/crates/ide-host/src/rpc.rs @@ -178,7 +178,7 @@ impl Dispatcher { Ok(()) }) .add("cancel_task", |c| async move { Ok(ide_core::task::cancel(c.arg::("id")?)) }) - .add("fs_read_dir", |c| async move { ops::fs::read_dir(&c.host, &c.window, c.arg("path")?) }) + .add("fs_read_dir", |c| async move { ops::fs::read_dir(&c.host, &c.window, c.arg("path")?).await }) .add("fs_entry_kind", |c| async move { ops::fs::entry_kind(&c.host, c.arg("path")?).await }) .add("fs_create_file", |c| async move { ops::fs::create_file(&c.host, &c.window, c.arg("path")?) }) .add("fs_create_file_bytes", |c| async move { diff --git a/src-tauri/src/commands/fs.rs b/src-tauri/src/commands/fs.rs index efa29a9b5..2e41aebfa 100644 --- a/src-tauri/src/commands/fs.rs +++ b/src-tauri/src/commands/fs.rs @@ -39,8 +39,8 @@ pub async fn fs_copy_merge( #[tauri::command] #[specta::specta] -pub fn fs_read_dir(window: Window, state: State<'_, AppState>, path: PathBuf) -> IdeResult> { - fs::read_dir(&state.host, window.label(), path) +pub async fn fs_read_dir(window: Window, state: State<'_, AppState>, path: PathBuf) -> IdeResult> { + fs::read_dir(&state.host, window.label(), path).await } /// Creates an empty file; inside a project, honours the EditorConfig From cc4ab5d4a8b2f3a21a23519ce0fc83725a50a390 Mon Sep 17 00:00:00 2001 From: arookieofc <2128194521@qq.com> Date: Tue, 3 Nov 2026 15:35:01 +0800 Subject: [PATCH 062/106] feat(vcs): push notifications say how many commits went where (#961) After a push the notification only said "Push successful". It now says what went out, for example "Pushed 3 commits to origin/main", counting the commits the branch was ahead of its upstream before the push. This covers Push and pushing a branch to its own upstream. A push across several repositories stays generic unless exactly one of them had commits to send. The title is translated as a whole for each count. Fixes #954. Co-authored-by: a-rookie-of-C-language <186315048+a-rookie-of-C-language@users.noreply.github.com> --- src/features/vcs/__tests__/pushResult.test.ts | 69 +++++++++++++++++++ src/features/vcs/ops.ts | 18 ++++- src/platform/locales/text/zh.json | 2 + 3 files changed, 87 insertions(+), 2 deletions(-) create mode 100644 src/features/vcs/__tests__/pushResult.test.ts diff --git a/src/features/vcs/__tests__/pushResult.test.ts b/src/features/vcs/__tests__/pushResult.test.ts new file mode 100644 index 000000000..4e1a73a43 --- /dev/null +++ b/src/features/vcs/__tests__/pushResult.test.ts @@ -0,0 +1,69 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { BranchInfo } from "@/bindings"; + +let branch: Partial = {}; + +vi.mock("@tauri-apps/api/core", async (importOriginal) => ({ + ...(await importOriginal()), + Channel: class { + onmessage: (line: string) => void = () => {}; + }, +})); + +vi.mock("@/platform/ipc", async (importOriginal) => { + const real = await importOriginal(); + return { + ...real, + commands: { + ...real.commands, + vcsBranchInfo: async () => ({ + status: "ok", + data: { head: "main", headCommit: "c1", upstream: null, ahead: 0, behind: 0, operation: null, ...branch }, + }), + vcsRemote: async () => ({ status: "ok", data: null }), + cancelRequest: async () => ({ status: "ok", data: null }), + }, + }; +}); + +import { _resetI18nForTests, setLocale } from "@/platform/i18n"; +import { provideNotificationService } from "@/workbench/contributions"; +import { runRemote } from "../ops"; + +type Note = { title: string; level: string }; +let notes: Note[] = []; + +beforeEach(() => { + notes = []; + provideNotificationService({ notify: (n) => void notes.push(n as Note), expire: () => {} }); +}); + +afterEach(() => { + branch = {}; + _resetI18nForTests(); +}); + +const pushTitle = async () => { + expect(await runRemote({ op: "push" } as never, undefined, ["/p"])).toBe(true); + return notes.at(-1)!.title; +}; + +describe("push result notification", () => { + it("says how many commits went to which upstream", async () => { + branch = { upstream: "origin/main", ahead: 3 }; + expect(await pushTitle()).toBe("Pushed 3 commits to origin/main"); + branch = { upstream: "origin/main", ahead: 1 }; + expect(await pushTitle()).toBe("Pushed 1 commit to origin/main"); + }); + + it("stays generic when nothing was ahead", async () => { + branch = { upstream: "origin/main", ahead: 0 }; + expect(await pushTitle()).toMatch(/successful$/); + }); + + it("is translated as a whole, count included", async () => { + await setLocale("zh"); + branch = { upstream: "origin/main", ahead: 3 }; + expect(await pushTitle()).toBe("已推送 3 个提交到 origin/main"); + }); +}); diff --git a/src/features/vcs/ops.ts b/src/features/vcs/ops.ts index c76fb17bb..8e81fd0c2 100644 --- a/src/features/vcs/ops.ts +++ b/src/features/vcs/ops.ts @@ -188,6 +188,7 @@ export async function runRemote( // repositories that have nothing to pull from (detached HEAD, e.g. submodules; no upstream). const failures: string[] = []; const updated: UpdatedRange[] = []; + const pushed: Array<{ to: string; commits: number }> = []; for (const repo of repos) { const name = repos.length > 1 && repo ? repoName(repo) : null; if (name) logConsole("info", name); @@ -200,6 +201,12 @@ export async function runRemote( } try { const before = op.op === "pull" || op.op === "pullFrom" ? await headCommit(repo) : null; + // The push summary counts what the push carries: the commits the branch is + // ahead of its upstream before the push (pushing does not move the local head). + if (op.op === "push" || (op.op === "pushBranch" && !op.target)) { + const info = await call(commands.vcsBranchInfo(repo)).catch(() => null); + if (info?.upstream && info.ahead > 0) pushed.push({ to: info.upstream, commits: info.ahead }); + } await call(commands.vcsRemote(repo, request, op, channel)); const after = before ? await headCommit(repo) : null; if (before && after && before !== after) updated.push({ repo, from: before, to: after }); @@ -212,14 +219,17 @@ export async function runRemote( if (failures.length) throw new Error(failures.join("\n")); lastRemoteError = null; if (op.op === "pull" || op.op === "pullFrom") await notifyUpdated(updated); - else + else { + // One repository's push names what went out; a partial multi-repo push stays generic. + const one = pushed.length === 1 ? pushed[0] : null; notifications().notify({ group: "vcs", level: "info", key: "vcs.remote", - title: `${doneTitle} successful`, + title: one ? pushedTitle(one.commits, one.to) : `${doneTitle} successful`, body: lastLine && !/%/.test(lastLine) ? lastLine : undefined, }); + } return true; } catch (e) { const cancelled = isCancelled(e); @@ -258,6 +268,10 @@ const headCommit = (repo: string | null) => const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? "" : "s"}`; +/** "Pushed 3 commits to origin/main", translated per count. */ +const pushedTitle = (n: number, to: string) => + n === 1 ? tx`Pushed ${n} commit to ${to}` : tx`Pushed ${n} commits to ${to}`; + /** IDEA's Update Project result: "N files updated in M commits" with View Commits. */ async function notifyUpdated(updated: readonly UpdatedRange[]) { if (!updated.length) { diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 5891763c5..2e00073a9 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -4114,6 +4114,8 @@ "Rebasing {n} commit from the root": "正在从根变基 {n} 个提交", "Rebasing {n} commits from the root": "正在从根变基 {n} 个提交", "Push Commits to {remote}": "推送提交到 {remote}", + "Pushed {n} commit to {remote}": "已推送 {n} 个提交到 {remote}", + "Pushed {n} commits to {remote}": "已推送 {n} 个提交到 {remote}", "You're going to force push to '{target}'. It may overwrite commits at the remote. Are you sure you want to proceed?": "您将强制推送到 '{target}'。这可能会覆盖远程上的提交。确定要继续吗?", "Delete “{name}” from the shelf? This cannot be undone.": "是否从搁置中删除“{name}”?此操作无法撤消。", "Handler for {keystroke}": "{keystroke} 的处理程序", From 59766278c521e7b9a402e2108be130690b8ae9a9 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Tue, 3 Nov 2026 15:56:05 +0800 Subject: [PATCH 063/106] fix(i18n): translate counts inside the Update Project and Hot Swap results (#994) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In Chinese the Update Project notification read "已在 2 commits 中更新 3 files" and the Hot Swap result "已重新加载 3 classes". Both sentences were translated, but the counts inside them were English phrases passed in as placeholder values, which are only looked up as exact catalog entries. Each count is now its own source text per variant (`{count} file` / `{count} files`, `{count} commit` / `{count} commits`, `{count} class` / `{count} classes`), built in the current language before it goes into the sentence. These were the only two places where a translated sentence receives an English count phrase as a value. --- .../debugger/__tests__/hotswap.test.ts | 11 ++++++ src/features/debugger/hotswap.ts | 7 ++-- ...shResult.test.ts => remoteResults.test.ts} | 34 +++++++++++++++++-- src/features/vcs/ops.ts | 6 ++-- src/platform/locales/text/zh.json | 5 ++- 5 files changed, 54 insertions(+), 9 deletions(-) rename src/features/vcs/__tests__/{pushResult.test.ts => remoteResults.test.ts} (59%) diff --git a/src/features/debugger/__tests__/hotswap.test.ts b/src/features/debugger/__tests__/hotswap.test.ts index ace2fc1b3..3473a32b1 100644 --- a/src/features/debugger/__tests__/hotswap.test.ts +++ b/src/features/debugger/__tests__/hotswap.test.ts @@ -29,6 +29,7 @@ vi.mock("@/workbench/contributions", async (importOriginal) => { import type { DebugCapabilities, DebugSessionId } from "@/platform/ipc"; import { getContext } from "@/platform/context"; +import { _resetI18nForTests, setLocale } from "@/platform/i18n"; import { hotSwapMessage, onHotCodeReplace, reloadChangedClasses } from "../hotswap"; import { publishContext, useDebug, type DebugSession } from "../state"; @@ -107,9 +108,19 @@ describe("HotSwap", () => { it("words results like IDEA", () => { expect(hotSwapMessage(0, []).title).toBe("Loaded classes are up to date. Nothing to reload."); expect(hotSwapMessage(1, []).title).toBe("1 class reloaded"); + expect(hotSwapMessage(3, []).title).toBe("3 classes reloaded"); expect(hotSwapMessage(3, ["boom"])).toMatchObject({ level: "error", body: "boom" }); }); + it("translates the reload count with the sentence", async () => { + await setLocale("zh"); + try { + expect(hotSwapMessage(3, []).title).toBe("已重新加载 3 个类"); + } finally { + _resetI18nForTests(); + } + }); + it("offers a reload when java-debug reports a finished build", () => { // Past the quiet period after the reloads above (their own builds raise no prompt). vi.spyOn(Date, "now").mockReturnValue(Date.now() + 120_000); diff --git a/src/features/debugger/hotswap.ts b/src/features/debugger/hotswap.ts index a4825af1a..b24eb225b 100644 --- a/src/features/debugger/hotswap.ts +++ b/src/features/debugger/hotswap.ts @@ -18,9 +18,8 @@ export function hotSwapSessions(): DebugSession[] { return Object.values(useDebug.getState().sessions).filter((s) => isLive(s) && !!s.caps?.hotCodeReplace); } -function plural(n: number, word: string) { - return `${n} ${word}${n === 1 ? "" : "es"}`; -} +/** "3 classes", translated per count (the sentence around it is translated on its own). */ +const classesText = (n: number) => (n === 1 ? tx`${n} class` : tx`${n} classes`); /** Message for the reload result (IDEA wording). */ export function hotSwapMessage( @@ -29,7 +28,7 @@ export function hotSwapMessage( ): { level: "info" | "error"; title: string; body?: string } { if (errors.length) return { level: "error", title: "Hot Swap failed", body: errors.join("\n") }; if (changed === 0) return { level: "info", title: "Loaded classes are up to date. Nothing to reload." }; - return { level: "info", title: tx`${plural(changed, "class")} reloaded` }; + return { level: "info", title: tx`${classesText(changed)} reloaded` }; } /** Compiles (unless `build` is false) and reloads changed classes into every Java debug session. */ diff --git a/src/features/vcs/__tests__/pushResult.test.ts b/src/features/vcs/__tests__/remoteResults.test.ts similarity index 59% rename from src/features/vcs/__tests__/pushResult.test.ts rename to src/features/vcs/__tests__/remoteResults.test.ts index 4e1a73a43..fc32d8a0b 100644 --- a/src/features/vcs/__tests__/pushResult.test.ts +++ b/src/features/vcs/__tests__/remoteResults.test.ts @@ -2,6 +2,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { BranchInfo } from "@/bindings"; let branch: Partial = {}; +/** What a pull brings in: the head moves to `c2`, with these changed files and commits. */ +let pulled = { files: 0, commits: 0 }; vi.mock("@tauri-apps/api/core", async (importOriginal) => ({ ...(await importOriginal()), @@ -20,13 +22,18 @@ vi.mock("@/platform/ipc", async (importOriginal) => { status: "ok", data: { head: "main", headCommit: "c1", upstream: null, ahead: 0, behind: 0, operation: null, ...branch }, }), - vcsRemote: async () => ({ status: "ok", data: null }), + vcsRemote: async () => { + if (pulled.commits) branch = { ...branch, headCommit: "c2" }; + return { status: "ok", data: null }; + }, + vcsDiffRevisions: async () => ({ status: "ok", data: Array.from({ length: pulled.files }, (_, i) => ({ i })) }), + vcsLog: async () => ({ status: "ok", data: Array.from({ length: pulled.commits }, (_, i) => ({ i })) }), cancelRequest: async () => ({ status: "ok", data: null }), }, }; }); -import { _resetI18nForTests, setLocale } from "@/platform/i18n"; +import { _resetI18nForTests, setLocale, txTemplate } from "@/platform/i18n"; import { provideNotificationService } from "@/workbench/contributions"; import { runRemote } from "../ops"; @@ -40,6 +47,7 @@ beforeEach(() => { afterEach(() => { branch = {}; + pulled = { files: 0, commits: 0 }; _resetI18nForTests(); }); @@ -67,3 +75,25 @@ describe("push result notification", () => { expect(await pushTitle()).toBe("已推送 3 个提交到 origin/main"); }); }); + +const updateTitle = async () => { + expect(await runRemote({ op: "pull" } as never, undefined, ["/p"])).toBe(true); + return notes.at(-1)!.title; +}; + +describe("Update Project result notification", () => { + it("counts the files and commits that came in", async () => { + pulled = { files: 3, commits: 2 }; + expect(await updateTitle()).toBe("3 files updated in 2 commits"); + branch = {}; + pulled = { files: 1, commits: 1 }; + expect(await updateTitle()).toBe("1 file updated in 1 commit"); + }); + + it("translates the counts inside the sentence", async () => { + await setLocale("zh"); + pulled = { files: 3, commits: 2 }; + // Notification titles are translated as they are shown. + expect(txTemplate(await updateTitle())).toBe("已在 2 个提交中更新 3 个文件"); + }); +}); diff --git a/src/features/vcs/ops.ts b/src/features/vcs/ops.ts index 8e81fd0c2..17d442e9a 100644 --- a/src/features/vcs/ops.ts +++ b/src/features/vcs/ops.ts @@ -266,7 +266,9 @@ const headCommit = (repo: string | null) => () => null, ); -const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? "" : "s"}`; +/** Counts for a translated sentence: each variant is its own source text, as "{count} files". */ +const filesText = (n: number) => (n === 1 ? tx`${n} file` : tx`${n} files`); +const commitsText = (n: number) => (n === 1 ? tx`${n} commit` : tx`${n} commits`); /** "Pushed 3 commits to origin/main", translated per count. */ const pushedTitle = (n: number, to: string) => @@ -304,7 +306,7 @@ async function notifyUpdated(updated: readonly UpdatedRange[]) { group: "vcs", level: "info", key: "vcs.remote", - title: `${plural(files, "file")} updated in ${plural(commits, "commit")}`, + title: `${filesText(files)} updated in ${commitsText(commits)}`, actions: [ { label: "View Commits", diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 2e00073a9..0522011de 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -3781,7 +3781,7 @@ "Select two commits to compare": "选择两个提交进行比较", "Label '{value}' added to Local History": "已将标签 '{value}' 添加到本地历史记录", "{doneTitle} successful": "{doneTitle}成功", - "{file} updated in {commit}": "已在 {commit} 中更新 {file}", + "{file} updated in {commit}": "已在 {commit}中更新 {file}", "Shelved {length} file": "已搁置 {length} 个文件", "Shelved {length} files": "已搁置 {length} 个文件", "{what}: {length} file with conflicts": "{what}:{length} 个文件存在冲突", @@ -3943,6 +3943,8 @@ "Cannot copy the DDL": "无法复制 DDL", "Start the remote JVM with -agentlib:jdwp=transport=dt_socket,server=n,address={host}:{port},suspend=y": "使用 -agentlib:jdwp=transport=dt_socket,server=n,address={host}:{port},suspend=y 启动远程 JVM", "{class} reloaded": "已重新加载 {class}", + "{count} class": "{count} 个类", + "{count} classes": "{count} 个类", "The JavaScript debugger (vscode-js-debug) is not installed": "JavaScript 调试器 (vscode-js-debug) 未安装", "Delve is not installed": "Delve 未安装", "CodeLLDB is not installed": "CodeLLDB 未安装", @@ -5147,6 +5149,7 @@ "Mark as Not Viewed": "标记为未查看", "Mark All as Viewed": "全部标记为已查看", "Mark All as Not Viewed": "全部标记为未查看", + "{count} commit": "{count} 个提交", "{count} commits": "{count} 个提交", "1 commit": "1 个提交", "All commits ({count})": "所有提交 ({count})", From ec465964d278749ccc82c99eccdb3bab9963ad03 Mon Sep 17 00:00:00 2001 From: Jaw <53564781+jaw52@users.noreply.github.com> Date: Tue, 3 Nov 2026 17:29:59 +0800 Subject: [PATCH 064/106] feat(editor): highlight JSP pages (#937) JSP files had no highlighting and showed a `?` icon; the HTML mode left directives, scriptlets, JSP comments and EL as plain text and flagged `` / `` as unclosed elements. - A JSP grammar layer (comments, directives, scriptlets, expressions, declarations, EL) with HTML and Java overlays, so markup split by JSP elements still nests and braces match across scriptlets. - EL as its own grammar top: member/index/method calls, namespaced functions, keyword operators, ternaries, lambdas and collection literals. - Block comments use `<%-- --%>` in markup and `//` in scriptlets; JSP comments and scriptlets fold. - `.jsp .jspf .jspx .jsph .tag .tagf .tagx` map to the JSP language with a file icon; `gen:jsp-grammar` regenerates the parser and a test checks it matches the grammar. Closes #870. Co-authored-by: jaw52 <53564781+jaw52@users.noreply.github.com> --- .oxfmtrc.json | 1 + crates/ide-language/src/registry.rs | 4 + crates/ide-language/src/syntax/mod.rs | 4 +- knip.ts | 1 + languages/jsp.toml | 6 + package.json | 2 + pnpm-lock.yaml | 3 + src-tauri/tauri.macos.conf.json | 1 + src/editor/core/cm/jsp/index.ts | 176 +++++++++++++++++ src/editor/core/cm/jsp/jsp.grammar | 132 +++++++++++++ src/editor/core/cm/jsp/jsp.test.ts | 252 +++++++++++++++++++++++++ src/editor/core/cm/jsp/parser.terms.ts | 39 ++++ src/editor/core/cm/jsp/parser.ts | 24 +++ src/editor/core/cm/jsp/tokens.ts | 101 ++++++++++ src/editor/core/cm/languages.ts | 9 +- src/ui/icons.test.tsx | 3 + src/ui/icons.tsx | 2 + src/ui/openidea/fileTypes/jsp.svg | 4 + src/ui/openidea/fileTypes/jsp_dark.svg | 4 + 19 files changed, 764 insertions(+), 4 deletions(-) create mode 100644 languages/jsp.toml create mode 100644 src/editor/core/cm/jsp/index.ts create mode 100644 src/editor/core/cm/jsp/jsp.grammar create mode 100644 src/editor/core/cm/jsp/jsp.test.ts create mode 100644 src/editor/core/cm/jsp/parser.terms.ts create mode 100644 src/editor/core/cm/jsp/parser.ts create mode 100644 src/editor/core/cm/jsp/tokens.ts create mode 100644 src/ui/openidea/fileTypes/jsp.svg create mode 100644 src/ui/openidea/fileTypes/jsp_dark.svg diff --git a/.oxfmtrc.json b/.oxfmtrc.json index d2730f45d..70f64132f 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -10,6 +10,7 @@ "target", "src/bindings.ts", "src/editor/core/cm/angular/parser*.ts", + "src/editor/core/cm/jsp/parser*.ts", "docs/plugins/themes.md", "pnpm-lock.yaml", "**/*.toml", diff --git a/crates/ide-language/src/registry.rs b/crates/ide-language/src/registry.rs index 984c082a3..34caf9517 100644 --- a/crates/ide-language/src/registry.rs +++ b/crates/ide-language/src/registry.rs @@ -45,6 +45,7 @@ const BUILTIN: &[(&str, &str)] = &[ ("javascriptreact", include_str!("../../../languages/javascriptreact.toml")), ("json", include_str!("../../../languages/json.toml")), ("jsonc", include_str!("../../../languages/jsonc.toml")), + ("jsp", include_str!("../../../languages/jsp.toml")), ("julia", include_str!("../../../languages/julia.toml")), ("kotlin", include_str!("../../../languages/kotlin.toml")), ("latex", include_str!("../../../languages/latex.toml")), @@ -522,6 +523,7 @@ mod tests { "Vue", "Angular Template", "ERB", + "JSP", ]; fn reg() -> LanguageRegistry { @@ -803,6 +805,8 @@ mod tests { ("a.less", "less"), ("App.vue", "vue"), ("App.svelte", "svelte"), + ("index.jsp", "jsp"), + ("WEB-INF/tags/card.tag", "jsp"), ("package.json", "json"), ("tsconfig.json", "jsonc"), ("tsconfig.app.json", "jsonc"), diff --git a/crates/ide-language/src/syntax/mod.rs b/crates/ide-language/src/syntax/mod.rs index c578dbba8..e961eb352 100644 --- a/crates/ide-language/src/syntax/mod.rs +++ b/crates/ide-language/src/syntax/mod.rs @@ -201,11 +201,11 @@ impl Syntax { /// The grammar web features parse `lang` with: its own for HTML and CSS, the /// CSS grammar for SCSS / Less (selectors and custom properties parse alike), - /// HTML for Vue / Svelte single-file components. + /// HTML for Vue / Svelte single-file components and JSP pages. fn web_grammar(&self, lang: &LanguageId) -> Option<&Grammar> { let name = match lang.as_str() { "scss" | "less" => "css", - "vue" | "svelte" => "html", + "vue" | "svelte" | "jsp" => "html", _ => self.grammar(lang).map(|g| g.spec.name)?, }; web::is_web_grammar(name).then(|| self.grammar(&LanguageId::new(name))).flatten() diff --git a/knip.ts b/knip.ts index 50822935e..cce08398d 100644 --- a/knip.ts +++ b/knip.ts @@ -5,6 +5,7 @@ const config: KnipConfig = { project: [ "src/**/*.{ts,tsx,css}", "!src/editor/core/cm/angular/parser.terms.ts", + "!src/editor/core/cm/jsp/parser.terms.ts", "scripts/**/*.ts", "perf/**/*.ts", "e2e/**/*.ts", diff --git a/languages/jsp.toml b/languages/jsp.toml new file mode 100644 index 000000000..1489eca58 --- /dev/null +++ b/languages/jsp.toml @@ -0,0 +1,6 @@ +id = "jsp" +name = "JSP" +extensions = ["jsp", "jspx", "jspf", "jsph", "tag", "tagf", "tagx"] +block-comment = ["<%--", "--%>"] +indent = { tab-size = 2, insert-spaces = true } +ui-mode = "JSP" diff --git a/package.json b/package.json index 6bad3b8c8..5426f9f6b 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "typecheck": "tsrs --noEmit && tsrs -p tsconfig.node.json --noEmit && tsrs -p perf --noEmit && tsrs -p scripts --noEmit", "gen:bindings": "cargo test -p openidea --test export_bindings", "gen:angular-grammar": "lezer-generator src/editor/core/cm/angular/angular.grammar --typeScript -o src/editor/core/cm/angular/parser.ts", + "gen:jsp-grammar": "lezer-generator src/editor/core/cm/jsp/jsp.grammar --typeScript -o src/editor/core/cm/jsp/parser.ts", "gen:theme-docs": "node scripts/theme/gen-docs.ts", "theme:import-intellij": "node scripts/theme/import-intellij-theme.ts", "dev:app": "node scripts/tauri.ts dev --config src-tauri/tauri.dev.conf.json", @@ -39,6 +40,7 @@ "@codemirror/autocomplete": "^6.20.3", "@codemirror/commands": "^6.11.1", "@codemirror/lang-html": "^6.4.12", + "@codemirror/lang-java": "^6.0.2", "@codemirror/lang-javascript": "^6.2.5", "@codemirror/language": "^6.13.1", "@codemirror/language-data": "^6.5.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6e2995a32..6c8cb996b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: '@codemirror/lang-html': specifier: ^6.4.12 version: 6.4.12 + '@codemirror/lang-java': + specifier: ^6.0.2 + version: 6.0.2 '@codemirror/lang-javascript': specifier: ^6.2.5 version: 6.2.5 diff --git a/src-tauri/tauri.macos.conf.json b/src-tauri/tauri.macos.conf.json index 3993b3041..2bf5a414d 100644 --- a/src-tauri/tauri.macos.conf.json +++ b/src-tauri/tauri.macos.conf.json @@ -46,6 +46,7 @@ "jsx", "vue", "svelte", + "jsp", "dart", "cs", "fs", diff --git a/src/editor/core/cm/jsp/index.ts b/src/editor/core/cm/jsp/index.ts new file mode 100644 index 000000000..5662174a3 --- /dev/null +++ b/src/editor/core/cm/jsp/index.ts @@ -0,0 +1,176 @@ +/** + * JSP pages, layered like IntelliJ's JSP view provider: `jsp.grammar` parses + * the JSP layer, and the template text around its elements is one HTML + * document. That is an overlay, so markup split by a scriptlet still nests + * and `value="<%= id %>"` is an attribute with a hole. The scriptlets are + * one Java method body, an overlay too (`<% for (…) { %> … <% } %>` balances + * its braces), each expression and declaration Java of its own. + */ +import { LRLanguage, LanguageSupport, foldInside, foldNodeProp } from "@codemirror/language"; +import { html } from "@codemirror/lang-html"; +import { javaLanguage } from "@codemirror/lang-java"; +import { styleTags, tags as t } from "@lezer/highlight"; +import { + Parser, + parseMixed, + type Input, + type NestedParse, + type PartialParse, + type SyntaxNode, + type SyntaxNodeRef, + type TreeFragment, +} from "@lezer/common"; +import { JAVA_TAGS } from "../languages"; +import { parser } from "./parser"; + +const baseHTML = html({ selfClosingTags: true }); + +const JSP_COMMENT = { block: { open: "<%--", close: "--%>" } }; + +const baseParser = parser.configure({ + props: [ + styleTags({ + JspComment: t.blockComment, + '"<%" "<%=" "<%!" "<%@" "%>"': t.processingInstruction, + // Not `ELExpression/"}"`: the cursor highlighting a page does not match + // that context once the expression's content is mounted. + '"${" "#{" ELEnd/...': t.special(t.brace), + DirectiveName: t.keyword, + AttributeName: t.attributeName, + AttributeValue: t.attributeValue, + Is: t.definitionOperator, + VariableName: t.variableName, + PropertyName: t.propertyName, + "CallExpression/MemberExpression/PropertyName": t.function(t.propertyName), + "CallExpression/VariableName FunctionName": t.function(t.variableName), + Namespace: t.namespace, + Number: t.number, + String: t.string, + BooleanLiteral: t.bool, + null: t.null, + "not empty div mod lt gt le ge instanceof eq ne and or": t.operatorKeyword, + ArithOp: t.arithmeticOperator, + CompareOp: t.compareOperator, + LogicOp: t.logicOperator, + '"->"': t.operator, + '"."': t.derefOperator, + "( )": t.paren, + "[ ]": t.squareBracket, + 'SetOrMapLiteral/"{" SetOrMapLiteral/"}"': t.brace, + ", ;": t.separator, + '":"': t.punctuation, + }), + foldNodeProp.add({ + "Scriptlet Declaration JspExpression": foldInside, + JspComment: (node, state) => ({ + from: node.from + 4, + to: state.sliceDoc(node.to - 4, node.to) == "--%>" ? node.to - 4 : node.to, + }), + }), + ], +}); + +const javaParser = javaLanguage.parser.configure({ props: [styleTags(JAVA_TAGS)] }); + +/** `input` with a `;` at `at`. */ +class TerminatedInput implements Input { + constructor( + private readonly input: Input, + private readonly at: number, + ) {} + + get length() { + return this.input.length; + } + + get lineChunks() { + return this.input.lineChunks; + } + + chunk(from: number): string { + if (from == this.at) return ";"; + const text = this.input.chunk(from); + return from < this.at && this.at < from + text.length ? text.slice(0, this.at - from) : text; + } + + read(from: number, to: number): string { + const text = this.input.read(from, to); + return from <= this.at && this.at < to + ? text.slice(0, this.at - from) + ";" + text.slice(this.at - from + 1) + : text; + } +} + +/** + * Java has no expression top: an expression is parsed as a statement, its + * trailing space read as the `;` (`<%= name %>`), so that a lone name is not + * a call missing its arguments. Without one, it ends in an error node. + */ +class ExpressionParser extends Parser { + private readonly java = javaParser.configure({}); + + createParse( + input: Input, + fragments: readonly TreeFragment[], + ranges: readonly { from: number; to: number }[], + ): PartialParse { + const end = ranges[ranges.length - 1]!.to; + const terminated = /\s/.test(input.read(end - 1, end)) ? new TerminatedInput(input, end - 1) : input; + return this.java.createParse(terminated, fragments, ranges); + } +} + +// To reuse old inner trees, parseMixed takes those that the node at a mount's +// start and its ancestors had with the same parser, so each kind of mount has +// its own instance: an expression at the start of a page would otherwise take +// the scriptlets' tree. +const htmlMixed: NestedParse = { parser: baseHTML.language.parser, overlay: (node) => node.name == "Text" }, + scriptletParser = javaParser.configure({}), + expressionMixed = { parser: new ExpressionParser() }, + declarationMixed = { parser: javaParser.configure({ top: "ClassContent" }) }, + elMixed = { parser: baseParser.configure({ top: "EL" }) }; + +/** The code of the scriptlets in `template`. */ +function scriptletCode(template: SyntaxNode): { from: number; to: number }[] { + const code: { from: number; to: number }[] = []; + for (const scriptlet of template.getChildren("Scriptlet")) { + const java = scriptlet.getChild("JavaCode"); + if (java) code.push({ from: java.from, to: java.to }); + } + return code; +} + +// The HTML overlay is on the document and the scriptlets' on the template it +// holds: a node mounts one tree, and an overlay's predicate does not see the +// nodes of an overlay inside it. +function mixJsp(node: SyntaxNodeRef): NestedParse | null { + switch (node.name) { + case "Document": + return htmlMixed; + case "Template": { + const overlay = scriptletCode(node.node); + return overlay.length ? { parser: scriptletParser, overlay } : null; + } + case "JavaCode": { + const element = node.node.parent?.name; + return element == "JspExpression" ? expressionMixed : element == "Declaration" ? declarationMixed : null; + } + case "ELContent": + return elMixed; + } + return null; +} + +export const jspLanguage = LRLanguage.define({ + name: "jsp", + parser: baseParser.configure({ wrap: parseMixed(mixJsp) }), + languageData: { commentTokens: JSP_COMMENT }, +}); + +export function jsp(): LanguageSupport { + return new LanguageSupport(jspLanguage, [ + baseHTML.support, + // Markup comments out of the page, with a JSP comment, as in IntelliJ. + baseHTML.language.data.of({ commentTokens: JSP_COMMENT }), + ]); +} diff --git a/src/editor/core/cm/jsp/jsp.grammar b/src/editor/core/cm/jsp/jsp.grammar new file mode 100644 index 000000000..3181b948f --- /dev/null +++ b/src/editor/core/cm/jsp/jsp.grammar @@ -0,0 +1,132 @@ +// JSP pages: the JSP layer of a page, as IntelliJ's JSP view provider sees it. +// Comments, directives, scripting elements and EL expressions are parsed here; +// the template text between them is HTML and the scripting elements' code is +// Java, both mounted by `./index.ts`. EL expressions (`${…}`, `#{…}`) are their +// own top. Regenerate with `pnpm gen:jsp-grammar`. + +@top Document { Template } + +// The page's content, a node of its own: the HTML overlay is mounted on the +// document and the scriptlets' Java overlay on this. +Template { + (Text | JspComment | Directive | Declaration | JspExpression | Scriptlet | ELExpression)* +} + +Scriptlet { "<%" JavaCode? "%>" } + +JspExpression { "<%=" JavaCode? "%>" } + +Declaration { "<%!" JavaCode? "%>" } + +ELExpression { ("${" | "#{") ELContent? ELEnd { "}" } } + +@skip { space } { + Directive { "<%@" DirectiveName { name } Attribute* "%>" } + + Attribute { AttributeName { name } (Is AttributeValue)? } + + @top EL { expression? (";" expression?)* } + + expression[@isGroup=Expression] { + literal | + VariableName | + ParenthesizedExpression { "(" (expression ("," expression)*)? ")" } | + ListLiteral { "[" (expression ("," expression)*)? "]" } | + SetOrMapLiteral { "{" (entry ("," entry)*)? "}" } | + MemberExpression { expression !member ("." PropertyName | "[" expression "]") } | + CallExpression { expression !call ArgList } | + FunctionCall { Namespace ":" FunctionName ArgList } | + UnaryExpression { (kw<"not"> | kw<"empty"> | LogicOp<"!"> | ArithOp<"-">) !prefix expression } | + BinaryExpression { + expression !mult (ArithOp<"*" | "/" | "%"> | kw<"div"> | kw<"mod">) expression | + expression !add ArithOp<"+" | "-"> expression | + expression !concat ArithOp<"+="> expression | + expression !rel (CompareOp<"<" | ">" | "<=" | ">="> | kw<"lt"> | kw<"gt"> | kw<"le"> | kw<"ge"> | kw<"instanceof">) expression | + expression !equal (CompareOp<"==" | "!="> | kw<"eq"> | kw<"ne">) expression | + expression !and (LogicOp<"&&"> | kw<"and">) expression | + expression !or (LogicOp<"||"> | kw<"or">) expression + } | + ConditionalExpression { expression !ternary LogicOp<"?"> expression LogicOp<":"> expression } | + LambdaExpression { expression !lambda "->" expression } | + AssignmentExpression { expression !assign Is expression } + } + + entry { expression (":" expression)? } + + ArgList { "(" (expression ("," expression)*)? ")" } +} + +@precedence { + member, + call, + prefix @right, + mult @left, + add @left, + concat @left, + rel @left, + equal @left, + and @left, + or @left, + ternary @right, + lambda @right, + assign @right +} + +literal { + Number | + String | + @specialize[@name=BooleanLiteral] | + kw<"null"> +} + +VariableName { identifier } + +PropertyName { identifier } + +FunctionName { identifier } + +Is { "=" } + +kw { @specialize[@name={term}] } + +ArithOp { expr } +CompareOp { expr } +LogicOp { expr } + +// Tried in this order, before the tokens below: a comment before `<%`. +@external tokens jspComment from "./tokens" { JspComment } +@external tokens text from "./tokens" { Text } +@external tokens javaCode from "./tokens" { JavaCode } +@external tokens elContent from "./tokens" { ELContent } +// `fn` of `fn:join(…)`, so that an identifier before a `:` stays a ternary's. +@external tokens namespace from "./tokens" { Namespace } + +@tokens { + space { $[ \t\n\r]+ } + + identifier { $[a-zA-Z_$\u{a1}-\u{10ffff}] $[a-zA-Z0-9_$\u{a1}-\u{10ffff}]* } + + // Directive and attribute names: `include`, `pageEncoding`, `name-given`. + name { $[a-zA-Z_] $[a-zA-Z0-9_\-]* } + + Number { + (@digit+ ("." @digit*)? | "." @digit+) ($[eE] $[+\-]? @digit+)? + } + + String { + '"' (!["\\] | "\\" _)* '"'? | + "'" (!['\\] | "\\" _)* "'"? + } + + AttributeValue { + '"' !["]* '"'? | + "'" ![']* "'"? + } + + @precedence { Number, "." } + + "<%" "<%=" "<%!" "<%@" "%>" "${" "#{" + "(" ")" "[" "]" "{" "}" "." "," ";" ":" "->" +} + +@detectDelim diff --git a/src/editor/core/cm/jsp/jsp.test.ts b/src/editor/core/cm/jsp/jsp.test.ts new file mode 100644 index 000000000..d18ff2b5d --- /dev/null +++ b/src/editor/core/cm/jsp/jsp.test.ts @@ -0,0 +1,252 @@ +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { EditorState } from "@codemirror/state"; +import { ensureSyntaxTree, foldable, matchBrackets } from "@codemirror/language"; +import type { Tree } from "@lezer/common"; +import { buildParserFile } from "@lezer/generator"; +import { highlightTree, tagHighlighter, tags as t } from "@lezer/highlight"; +import { findLanguageDescription, loadSupport } from "../languages"; +import { jsp, jspLanguage } from "./index"; + +const highlighter = tagHighlighter([ + { tag: t.processingInstruction, class: "pi" }, + { tag: t.special(t.brace), class: "el" }, + { tag: t.blockComment, class: "comment" }, + { tag: t.operatorKeyword, class: "opkw" }, + { tag: t.keyword, class: "keyword" }, + { tag: t.bool, class: "bool" }, + { tag: t.namespace, class: "ns" }, + { tag: t.typeName, class: "type" }, + { tag: t.definition(t.variableName), class: "def" }, + { tag: t.function(t.variableName), class: "call" }, + { tag: t.function(t.propertyName), class: "method" }, + { tag: t.variableName, class: "var" }, + { tag: t.propertyName, class: "prop" }, + { tag: t.attributeName, class: "attr" }, + { tag: t.attributeValue, class: "value" }, + { tag: t.string, class: "str" }, + { tag: t.number, class: "num" }, + { tag: t.tagName, class: "tag" }, +]); + +/** `text:class` of the styled tokens of `doc`, from `tree` or a fresh parse. */ +function tokens(doc: string, tree: Tree = jspLanguage.parser.parse(doc)): string[] { + const out: string[] = []; + highlightTree(tree, highlighter, (from, to, cls) => out.push(`${doc.slice(from, to)}:${cls}`)); + return out; +} + +/** The error nodes of `doc`'s tree, with the text they start. */ +function errors(doc: string): string[] { + const out: string[] = []; + jspLanguage.parser.parse(doc).iterate({ + enter: (node) => void (node.type.isError && out.push(doc.slice(node.from, node.from + 8))), + }); + return out; +} + +/** The tree of `state`, parsed to the end. */ +function stateTree(state: EditorState): Tree { + const tree = ensureSyntaxTree(state, state.doc.length, 5000); + if (!tree) throw new Error("parse did not finish"); + return tree; +} + +/** A ~8 KB page with directives, scriptlets around markup, expressions and EL. */ +function page(): string { + let doc = `<%@ page contentType="text/html;charset=UTF-8" %>\n<%@ taglib prefix="c" uri="jakarta.tags.core" %>\n`; + for (let i = 0; i < 40; i++) { + doc += + `<%-- row ${i} --%>\n<% if (rows${i} != null) { %>\n` + + ` <%= rows${i}.size() %>\${fn:length(names${i})}\n` + + `<% } %>\n\n`; + } + return doc; +} + +describe("JSP pages", () => { + it("highlights directives and JSP comments", () => { + expect(tokens(`<%@ page import="java.util.*" %><%-- off --%>`)).toEqual([ + "<%@:pi", + "page:keyword", + "import:attr", + '"java.util.*":value', + "%>:pi", + "<%-- off --%>:comment", + ]); + }); + + it("parses the scriptlets as one Java body, around the markup", () => { + const doc = `<% for (String s : list) { %>
  • <%= s %>
  • <% } %>`; + expect(errors(doc)).toEqual([]); + expect(tokens(doc)).toEqual([ + "<%:pi", + "for:keyword", + "String:type", + "s:def", + "list:var", + "%>:pi", + "li:tag", + "<%=:pi", + "s:var", + "%>:pi", + "li:tag", + "<%:pi", + "%>:pi", + ]); + const state = EditorState.create({ doc, extensions: jsp() }); + stateTree(state); + const open = doc.indexOf("{"); + expect(matchBrackets(state, open, 1)?.end?.from).toBe(doc.lastIndexOf("}")); + }); + + it("keeps markup in a scriptlet's strings out of the page", () => { + const doc = `<% String cell = ""; %>

    x

    `; + expect(errors(doc)).toEqual([]); + expect(tokens(doc)).toContain('"":str'); + expect(tokens(doc).filter((s) => s.endsWith(":tag"))).toEqual(["p:tag", "p:tag"]); + }); + + it("highlights expressions and declarations as Java", () => { + const doc = `<%= name %><%= req.getParameter("q") %><%! private int count = 0; %>`; + expect(errors(doc)).toEqual([]); + expect(tokens(doc)).toEqual([ + "<%=:pi", + "name:var", + "%>:pi", + "<%=:pi", + "req:var", + "getParameter:call", + '"q":str', + "%>:pi", + "<%!:pi", + "private:keyword", + "int:type", + "count:def", + "0:num", + "%>:pi", + ]); + }); + + it("highlights EL expressions", () => { + const doc = `

    \${fn:escapeXml(item.title)} #{a ? b : not empty c.d[0] and x.size() gt 2}

    `; + expect(errors(doc)).toEqual([]); + expect(tokens(doc)).toEqual([ + "p:tag", + "${:el", + "fn:ns", + "escapeXml:call", + "item:var", + "title:prop", + "}:el", + "#{:el", + "a:var", + "b:var", + "not:opkw", + "empty:opkw", + "c:var", + "d:prop", + "0:num", + "and:opkw", + "x:var", + "size:method", + "gt:opkw", + "2:num", + "}:el", + "p:tag", + ]); + }); + + it("ends an EL expression at its own brace, past nested ones and strings", () => { + const doc = `\${ {'k': '}'}.k == true }`; + expect(errors(doc)).toEqual([]); + expect(tokens(doc)).toEqual(["${:el", "'k':str", "'}':str", "k:prop", "true:bool", "}:el", "b:tag"]); + }); + + it("parses markup with elements in attribute values and custom tags", () => { + const doc = + `` + + `

    `; + expect(errors(doc)).toEqual([]); + expect(tokens(doc).slice(0, 8)).toEqual([ + "c:forEach:tag", + "var:attr", + '"i":value', + "items:attr", + '":value', + "${:el", + "items:var", + "}:el", + ]); + }); + + it("parses EL in a script as EL, like the container", () => { + expect(tokens(``)).toContain("user:var"); + }); + + it("keeps escaped elements as text", () => { + expect(tokens(`

    \\\${a} <\\% b %>

    `)).toEqual(["p:tag", "p:tag"]); + }); + + it("comments with JSP comments in the markup and Java ones in scriptlets", () => { + const doc = `

    x

    <% int a; %>`; + const state = EditorState.create({ doc, extensions: jsp() }); + stateTree(state); + const at = (pos: number) => + state.languageDataAt<{ line?: string; block?: { open: string } }>("commentTokens", pos)[0]; + expect(at(4)?.block?.open).toBe("<%--"); + expect(at(doc.indexOf("int") + 1)?.line).toBe("//"); + }); + + it("folds JSP comments and scriptlets", () => { + const doc = `<%--\n a\n--%>\n<%\n int a;\n%>`; + const state = EditorState.create({ doc, extensions: jsp() }); + stateTree(state); + expect(foldable(state, 0, 4)).toEqual({ from: 4, to: doc.indexOf("--%>") }); + const line = state.doc.line(4); + expect(foldable(state, line.from, line.to)).toEqual({ from: line.to, to: doc.lastIndexOf("%>") }); + }); + + it("highlights like a fresh parse after resuming and editing", () => { + const doc = page(); + // Opening parses the first 3000 characters; the rest resumes from that tree. + let state = EditorState.create({ doc, extensions: jsp() }); + expect(tokens(doc, stateTree(state))).toEqual(tokens(doc)); + const at = (text: string, n = 1) => { + let pos = -1; + for (let i = 0; i < n; i++) pos = state.doc.toString().indexOf(text, pos + 1); + return pos; + }; + const edits = [ + () => ({ from: at("rows12.size()") + 4, insert: "x" }), + () => ({ from: at(".size() %>", 15) + 7, insert: "+1" }), + () => ({ from: at("<% } %>", 20), insert: "<% int k = 0; %>\n" }), + () => ({ from: at("<%-- row 7"), to: at("<%-- row 7") + 4 }), + () => ({ from: at("names3"), to: at("names3") + 6, insert: "a.b[0]" }), + () => ({ from: 0, insert: "${top}\n" }), + () => ({ from: at(" { + const dir = import.meta.dirname; + const { parser, terms } = buildParserFile(readFileSync(resolve(dir, "jsp.grammar"), "utf8"), { + fileName: "src/editor/core/cm/jsp/jsp.grammar", + moduleStyle: "es", + typeScript: true, + }); + expect(parser).toBe(readFileSync(resolve(dir, "parser.ts"), "utf8")); + expect(terms).toBe(readFileSync(resolve(dir, "parser.terms.ts"), "utf8")); + }); + + it("is the JSP mode", async () => { + const desc = findLanguageDescription("JSP", "/p/index.jsp"); + expect((await loadSupport(desc!))?.language).toBe(jspLanguage); + }); +}); diff --git a/src/editor/core/cm/jsp/parser.terms.ts b/src/editor/core/cm/jsp/parser.terms.ts new file mode 100644 index 000000000..b2f03afd6 --- /dev/null +++ b/src/editor/core/cm/jsp/parser.terms.ts @@ -0,0 +1,39 @@ +// This file was generated by lezer-generator. You probably shouldn't edit it. +export const + JspComment = 1, + Text = 2, + JavaCode = 3, + ELContent = 4, + Namespace = 5, + Document = 6, + Template = 7, + Directive = 10, + Attribute = 12, + Is = 14, + AttributeValue = 15, + Declaration = 17, + JspExpression = 19, + Scriptlet = 21, + ELExpression = 22, + EL = 27, + Number = 28, + String = 29, + BooleanLiteral = 30, + _null = 31, + VariableName = 32, + PropertyName = 45, + ArgList = 47, + FunctionName = 49, + not = 51, + empty = 52, + div = 57, + mod = 58, + lt = 62, + gt = 63, + le = 64, + ge = 65, + _instanceof = 66, + eq = 68, + ne = 69, + and = 71, + or = 73 diff --git a/src/editor/core/cm/jsp/parser.ts b/src/editor/core/cm/jsp/parser.ts new file mode 100644 index 000000000..ad5275eff --- /dev/null +++ b/src/editor/core/cm/jsp/parser.ts @@ -0,0 +1,24 @@ +// This file was generated by lezer-generator. You probably shouldn't edit it. +import {LRParser} from "@lezer/lr" +import {jspComment, text, javaCode, elContent, namespace} from "./tokens" +const spec_identifier = {__proto__:null,true:60, false:60, null:62, not:102, empty:104, div:114, mod:116, lt:124, gt:126, le:128, ge:130, instanceof:132, eq:136, ne:138, and:142, or:146} +export const parser = LRParser.deserialize({ + version: 14, + states: "1WOqOsOOQ!sQ!rOOO!zQpO'#CfO#POtO'#CmO#XOtO'#CoO#aOtO'#CqO#iOxO'#CrOOOR'#EP'#EPO#qOsO'#CcQOOOOOOOQO'#E['#E[OOQO'#C|'#C|O#xQ!rO'#DPO$PQ!rO'#DTO$WQ!rO'#DVQ&bQ#tOOO&iQpO'#D^OOQ`'#Dc'#DcOOQ`'#Dd'#DdOxQ!rO'#D`OOQO'#EZ'#EZO&nQ!rO'#ETQ&xQpOOOOQO'#Cg'#CgO&}QpO,59QOOOR,59X,59XO'VOpO,59XOOOR,59Z,59ZO'[OpO,59ZOOOR,59],59]O'aOpO,59]OOOR'#Cu'#CuOOOR,59^,59^O#lOpO,59^OOOR-E7}-E7}OOQO,59k,59kO'fQ#tO,59kOOQO,59o,59oO'pQ#tO,59oO'zQ#tO'#E^OOQO,59q,59qO(XQpO,59qOOQ`'#Cj'#CjO(aQ!bO,59sOxQ!rO,59sO(fQ!rO'#D]OOQO,59v,59vOOQ`'#Df'#DfOOQ`'#Di'#DiOOQ`'#Dj'#DjOOQ`'#Dk'#DkOOQ`'#Dq'#DqOOQ`'#Dt'#DtOOQ`'#Dv'#DvOxQ!rO,5:POxQ!rO,5:POxQ!rO,5:POxQ!rO,5:POxQ!rO,5:POxQ!rO,5:POxQ!rO,5:POOQ`'#Dy'#DyOxQ!rO,5:dOxQ!rO,5:gOxQ!rO,5:iO(mQ!bO,59xO(rQ#tO,59zO+[Q#tO,5:oOOQO-E8R-E8ROOQO'#Ci'#CiO+fQpO'#ChOOQO'#EQ'#EQO+qQpO1G.lOOOR1G.l1G.lOOOR1G.s1G.sOOOR1G.u1G.uOOOR1G.w1G.wOOOR1G.x1G.xOxQ!rO'#ERO+yQpO1G/VOOQO1G/V1G/VO,RQpO1G/ZOOQO1G/Z1G/ZOxQ!rO,5:xOxQ!rO'#ESO,ZQpO1G/]OOQO1G/]1G/]OOQO'#DZ'#DZOOQO1G/_1G/_O,cQ#tO1G/_O,jQ#tO,59wOOQO,59w,59wO.}Q#tO1G/kO/bQ#tO1G/kO2[Q#tO1G/kO2cQ#tO1G/kO4vQ#tO1G/kO5WQ#tO1G/kO7[Q#tO1G/kO8UQ#tO1G0OO8]Q#tO1G0RO9VQ#tO1G0TOOQO'#D_'#D_O9pQpO1G/dO9uQpO,59SOOQO-E8O-E8OOOOR7+$W7+$WO9zQ#tO,5:mOOQO-E8P-E8POOQO7+$q7+$qOOQO7+$u7+$uO:XQ#tO1G0dOOQO,5:n,5:nOOQO-E8Q-E8QOOQO7+$w7+$wOOQO7+$y7+$yO:cQpO1G/cOOQO1G/c1G/cOOQ`'#Dz'#DzOxQ!rO7+%jOOQO7+%O7+%OOOQO1G.n1G.nOOQO7+$}7+$}O:kQ#tO< <%@ Directive DirectiveName Attribute AttributeName Is AttributeValue <%! Declaration <%= JspExpression <% Scriptlet ELExpression ${ #{ ELEnd } EL Number String BooleanLiteral null VariableName ) ( ParenthesizedExpression , ] [ ListLiteral { SetOrMapLiteral : MemberExpression . PropertyName CallExpression ArgList FunctionCall FunctionName UnaryExpression not empty LogicOp ArithOp BinaryExpression ArithOp div mod ArithOp ArithOp CompareOp lt gt le ge instanceof CompareOp eq ne LogicOp and LogicOp or ConditionalExpression LogicOp LogicOp LambdaExpression -> AssignmentExpression ;", + maxTerm: 110, + nodeProps: [ + ["openedBy", 8,"<%@ <%! <%= <%",26,"{",33,"(",37,"["], + ["closedBy", -4,9,16,18,20,"%>",34,")",38,"]",40,"}"], + ["group", -16,28,29,30,31,32,35,39,41,43,46,48,50,55,74,77,79,"Expression"] + ], + skippedNodes: [0], + repeatNodeCount: 5, + tokenData: "5S~RuXY#fYZ#f]^#fpq#fqr#wrs$Ust(etu(puv*[vw*iwx*txy.wyz.|z{/R{|/W|}/e}!O/j!O!P/w!P!Q0y!Q![1O![!]1a!]!^1f!^!_1k!_!`2j!`!a2w!a!b3U!c!}3Z!}#O4c#P#Q4h#R#S3Z#T#o3Z#o#p4m#p#q4r#q#r4}$g;'S)b;'S;=`*P<%lO)b~#kS!z~XY#fYZ#f]^#fpq#fU#|P#RQ!_!`$PS$UO#_SR$]V_PmQOr$Urs$rs#O$U#O#P$y#P;'S$U;'S;=`(_<%lO$UR$yO_PmQR%OU_POr$Urs%bs;'S$U;'S;=`'p;=`<%l&O<%lO$UR%iV_PmQOr&Ors&js#O&O#O#P&o#P;'S&O;'S;=`'j<%lO&OQ&TVmQOr&Ors&js#O&O#O#P&o#P;'S&O;'S;=`'j<%lO&OQ&oOmQQ&rRO;'S&O;'S;=`&{;=`O&OQ'QWmQOr&Ors&js#O&O#O#P&o#P;'S&O;'S;=`'j;=`<%l&O<%lO&OQ'mP;=`<%l&OR'uWmQOr&Ors&js#O&O#O#P&o#P;'S&O;'S;=`'j;=`<%l$U<%lO&OR(bP;=`<%l$U~(hP#o#p(k~(pOh~V(uX#PUtu)b!Q![)b!c!})b#R#S)b#T#o)b#o#p*V$g;'S)b;'S;=`*P<%lO)bU)gW#PUtu)b!Q![)b!c!})b#R#S)b#T#o)b$g;'S)b;'S;=`*P<%lO)bU*SP;=`<%l)bP*[OgPT*aP#VS!`!a*dP*iOWP~*lPvw*o~*tO#`~R*{V_PmQOw*twx$rx#O*t#O#P+b#P;'S*t;'S;=`.q<%lO*tR+gU_POw*twx+yx;'S*t;'S;=`.S;=`<%l,g<%lO*tR,QV_PmQOw,gwx&jx#O,g#O#P-R#P;'S,g;'S;=`-|<%lO,gQ,lVmQOw,gwx&jx#O,g#O#P-R#P;'S,g;'S;=`-|<%lO,gQ-URO;'S,g;'S;=`-_;=`O,gQ-dWmQOw,gwx&jx#O,g#O#P-R#P;'S,g;'S;=`-|;=`<%l,g<%lO,gQ.PP;=`<%l,gR.XWmQOw,gwx&jx#O,g#O#P-R#P;'S,g;'S;=`-|;=`<%l*t<%lO,gR.tP;=`<%l*t~.|Or~~/ROq~~/WO#T~~/]P#W~!_!`/`~/eO#X~~/jOt~~/oP#S~!`!a/r~/wO!p~~/|P|~!Q![0P~0URl~!Q![0P!g!h0_#X#Y0_~0bR{|0k}!O0k!Q![0q~0nP!Q![0q~0vPl~!Q![0q~1OO#U~~1TSl~!O!P0P!Q![1O!g!h0_#X#Y0_~1fOz~~1kO!r~~1pQ#YSuv1v!_!`2eP1{RdPqr2U!_!`2Z!b!c2`P2ZO`PP2`ObPP2eOXP~2jO#[~~2oP!|~!_!`2r~2wO#^~~2|P#Z~!_!`3P~3UO#]~~3ZO#b~V3bX#PU!{Ptu)b}!O3}!Q![3Z!c!}3Z#R#S3Z#T#o3Z$g;'S)b;'S;=`*P<%lO)bP4ST!{P}!O3}!Q![3}!c!}3}#R#S3}#T#o3}~4hOv~~4mOu~~4rOx~~4uP#p#q4x~4}O#a~~5SOj~", + tokenizers: [jspComment, text, javaCode, elContent, namespace, 0, 1, 2], + topRules: {"Document":[0,6],"EL":[1,27]}, + specialized: [{term: 93, get: (value: keyof typeof spec_identifier) => spec_identifier[value] || -1}], + tokenPrec: 1217 +}) diff --git a/src/editor/core/cm/jsp/tokens.ts b/src/editor/core/cm/jsp/tokens.ts new file mode 100644 index 000000000..20288eba6 --- /dev/null +++ b/src/editor/core/cm/jsp/tokens.ts @@ -0,0 +1,101 @@ +/** + * External tokens of `jsp.grammar`: the runs the JSP layer leaves to HTML and + * Java, the comments, and the extent of EL expressions. + */ +import { ExternalTokenizer, type InputStream } from "@lezer/lr"; +import { ELContent, JavaCode, JspComment, Namespace, Text } from "./parser.terms"; + +const DOUBLE_QUOTE = 34, + HASH = 35, + DOLLAR = 36, + PERCENT = 37, + SINGLE_QUOTE = 39, + PAREN_L = 40, + DASH = 45, + COLON = 58, + LT = 60, + GT = 62, + BACKSLASH = 92, + UNDERSCORE = 95, + BRACE_L = 123, + BRACE_R = 125; + +/** Whether `<%` starts `offset` code units ahead. */ +function scriptStart(input: InputStream, offset = 0): boolean { + return input.peek(offset) == LT && input.peek(offset + 1) == PERCENT; +} + +/** Whether `${` or `#{` starts `offset` code units ahead. */ +function elStart(input: InputStream, offset = 0): boolean { + const ch = input.peek(offset); + return (ch == DOLLAR || ch == HASH) && input.peek(offset + 1) == BRACE_L; +} + +/** `<%-- … --%>`, to the end of the page when unterminated. */ +export const jspComment = new ExternalTokenizer((input) => { + if (!scriptStart(input) || input.peek(2) != DASH || input.peek(3) != DASH) return; + input.advance(4); + while (input.next >= 0) { + if (input.next == DASH && input.peek(1) == DASH && input.peek(2) == PERCENT && input.peek(3) == GT) { + input.advance(4); + break; + } + input.advance(); + } + input.acceptToken(JspComment); +}); + +/** + * Template text: up to a scripting element or an EL expression. Escaped ones + * (`<\%`, `\${`, `\#{`) are text. + */ +export const text = new ExternalTokenizer((input) => { + const start = input.pos; + while (input.next >= 0 && !scriptStart(input) && !elStart(input)) { + input.advance(input.next == BACKSLASH && elStart(input, 1) ? 2 : 1); + } + if (input.pos > start) input.acceptToken(Text); +}); + +/** The code of a scripting element, up to its `%>` (`%\>` quotes one). */ +export const javaCode = new ExternalTokenizer((input) => { + const start = input.pos; + while (input.next >= 0 && !(input.next == PERCENT && input.peek(1) == GT)) { + input.advance(input.next == PERCENT && input.peek(1) == BACKSLASH && input.peek(2) == GT ? 3 : 1); + } + if (input.pos > start) input.acceptToken(JavaCode); +}); + +/** An EL expression's content: up to the `}` closing it, past nested braces and strings. */ +export const elContent = new ExternalTokenizer((input) => { + const start = input.pos; + let depth = 0; + for (let ch; (ch = input.next) >= 0; input.advance()) { + if (ch == BRACE_R) { + if (!depth) break; + depth--; + } else if (ch == BRACE_L) { + depth++; + } else if (ch == DOUBLE_QUOTE || ch == SINGLE_QUOTE) { + input.advance(); + while (input.next >= 0 && input.next != ch) input.advance(input.next == BACKSLASH ? 2 : 1); + if (input.next < 0) break; + } + } + if (input.pos > start) input.acceptToken(ELContent); +}); + +const isIdentifierStart = (ch: number) => + (ch >= 65 && ch <= 90) || (ch >= 97 && ch <= 122) || ch == UNDERSCORE || ch == DOLLAR || ch >= 0xa1; +const isIdentifier = (ch: number) => isIdentifierStart(ch) || (ch >= 48 && ch <= 57); + +/** An identifier followed by `:name(`, a function's namespace (`fn:join(…)`). */ +export const namespace = new ExternalTokenizer((input, stack) => { + if (!isIdentifierStart(input.next) || !stack.canShift(Namespace)) return; + let i = 1; + while (isIdentifier(input.peek(i))) i++; + const end = i; + if (input.peek(i++) != COLON || !isIdentifierStart(input.peek(i++))) return; + while (isIdentifier(input.peek(i))) i++; + if (input.peek(i) == PAREN_L) input.acceptToken(Namespace, end); +}); diff --git a/src/editor/core/cm/languages.ts b/src/editor/core/cm/languages.ts index f6c01d560..2b8231d76 100644 --- a/src/editor/core/cm/languages.ts +++ b/src/editor/core/cm/languages.ts @@ -102,6 +102,7 @@ const OWN_MODES = [ extensions: ["erb", "rhtml"], load: () => import("./erb").then((m) => m.erb()), }), + LanguageDescription.of({ name: "JSP", load: () => import("./jsp").then((m) => m.jsp()) }), ]; const BATCH_KEYWORDS = new Set([ @@ -222,14 +223,18 @@ const CSS_TAGS: Parameters[0] = { // @lezer/javascript styles `.` as a deref operator, but not `?.`. const JS_TAGS: Parameters[0] = { "?.": t.derefOperator }; +// @lezer/java tags annotations as plain identifiers. +export const JAVA_TAGS: Parameters[0] = { + "MarkerAnnotation/Identifier Annotation/Identifier MarkerAnnotation Annotation": t.annotation, +}; + /** Highlighting the stock grammars lack or colour unlike IntelliJ. */ const EXTRA_TAGS: Record[0]> = { JavaScript: JS_TAGS, TypeScript: JS_TAGS, JSX: JS_TAGS, TSX: JS_TAGS, - // @lezer/java tags annotations as plain identifiers. - Java: { "MarkerAnnotation/Identifier Annotation/Identifier MarkerAnnotation Annotation": t.annotation }, + Java: JAVA_TAGS, CSS: CSS_TAGS, SCSS: CSS_TAGS, Sass: CSS_TAGS, diff --git a/src/ui/icons.test.tsx b/src/ui/icons.test.tsx index 0e1a7063a..8ad1b2e0f 100644 --- a/src/ui/icons.test.tsx +++ b/src/ui/icons.test.tsx @@ -172,6 +172,9 @@ describe("fileIconName", () => { ["main.dart", "file-dart"], ["App.vue", "file-vue"], ["App.svelte", "file-svelte"], + ["index.jsp", "file-jsp"], + ["footer.jspf", "file-jsp"], + ["card.tag", "file-jsp"], ["styles.scss", "file-scss"], ["styles.sass", "file-sass"], ["styles.less", "file-less"], diff --git a/src/ui/icons.tsx b/src/ui/icons.tsx index 047091b3b..51f45b46d 100644 --- a/src/ui/icons.tsx +++ b/src/ui/icons.tsx @@ -76,6 +76,7 @@ const ICONS = { "file-npm": "openidea/fileTypes/npm", "file-vue": "fileTypes/vue", "file-svelte": "openidea/fileTypes/svelte", + "file-jsp": "openidea/fileTypes/jsp", "file-yaml": "fileTypes/yaml", "file-toml": "fileTypes/toml", "file-html": "fileTypes/html", @@ -822,6 +823,7 @@ const EXT_ICONS = table({ "file-tsx": "tsx", "file-vue": "vue", "file-svelte": "svelte", + "file-jsp": "jsp jspx jspf jsph tag tagf tagx", "file-json": "json jsonc json5 jsonl ndjson geojson topojson webmanifest har avsc code-workspace code-snippets map", "file-md": "md mdx markdown mdown mkd mkdn mdwn ronn workbook", "file-rst": "rst", diff --git a/src/ui/openidea/fileTypes/jsp.svg b/src/ui/openidea/fileTypes/jsp.svg new file mode 100644 index 000000000..3e1a529ae --- /dev/null +++ b/src/ui/openidea/fileTypes/jsp.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/ui/openidea/fileTypes/jsp_dark.svg b/src/ui/openidea/fileTypes/jsp_dark.svg new file mode 100644 index 000000000..d84430152 --- /dev/null +++ b/src/ui/openidea/fileTypes/jsp_dark.svg @@ -0,0 +1,4 @@ + + + + From ecdd43a55ae6ea0477d0b851cedf4386de83c489 Mon Sep 17 00:00:00 2001 From: Azeem Hassni Date: Tue, 3 Nov 2026 14:37:40 +0500 Subject: [PATCH 065/106] fix(vfs): poll for changes when the OS file watcher cannot start (#906) Opening a project failed outright when the OS file watcher could not start (inotify limits used up, a file system without change notifications). The watcher now falls back to polling the same non-ignored directories every 2s, per directory unless flat so excluded trees like `node_modules` and `target` are not rescanned, and the project opens with an "External file changes sync may be slow" warning that gives the reason. Directory mtime changes seen only by polling are dropped so folders are not reported as modified. Fixes #891. Co-authored-by: azeemhassni <6503258+azeemhassni@users.noreply.github.com> --- crates/ide-host/src/project.rs | 7 ++ crates/ide-vfs/src/watch.rs | 159 ++++++++++++++++++++++++------ src/platform/locales/text/zh.json | 3 +- 3 files changed, 139 insertions(+), 30 deletions(-) diff --git a/crates/ide-host/src/project.rs b/crates/ide-host/src/project.rs index 1391f6182..e7d26808c 100644 --- a/crates/ide-host/src/project.rs +++ b/crates/ide-host/src/project.rs @@ -365,6 +365,13 @@ impl Host { }), )? }; + if let Some(reason) = watcher.polling() { + sink.emit(IdeEvent::Notify { + level: ide_core::event::NotifyLevel::Warning, + title: "External file changes sync may be slow".into(), + body: Some(reason.into()), + }); + } // Scratch files live outside the project; watch them too so the // Project view and open editors follow changes made there. diff --git a/crates/ide-vfs/src/watch.rs b/crates/ide-vfs/src/watch.rs index 011cab690..d9309d90b 100644 --- a/crates/ide-vfs/src/watch.rs +++ b/crates/ide-vfs/src/watch.rs @@ -5,12 +5,15 @@ //! that skips excluded / git-ignored directories (Linux, where a recursive //! inotify watch would register every directory of `node_modules` and //! `target` and exhaust `max_user_watches`). +//! +//! When the OS watcher cannot start (inotify limits used up, a file system +//! without change notifications), the same directories are polled instead. use crate::FsBatch; use crate::rules::{ExcludeMatcher, GitignoreStack}; use ide_core::{IdeError, IdeEvent, IdeResult, SharedSink}; use notify_debouncer_full::notify::event::{ModifyKind, RenameMode}; -use notify_debouncer_full::notify::{self, EventKind, RecommendedWatcher, RecursiveMode, Watcher as _}; +use notify_debouncer_full::notify::{self, EventKind, PollWatcher, RecommendedWatcher, RecursiveMode, WatcherKind}; use notify_debouncer_full::{DebounceEventResult, DebouncedEvent, Debouncer, NoCache, new_debouncer_opt}; use parking_lot::{Condvar, Mutex, RwLock}; use std::collections::{BTreeMap, BTreeSet, HashSet}; @@ -23,6 +26,8 @@ use std::time::{Duration, Instant}; const DEBOUNCE: Duration = Duration::from_millis(100); /// Upper bound on how long a continuous stream of events is held back. const MAX_DELAY: Duration = Duration::from_millis(1000); +/// How often the polling fallback rescans the watched directories. +const POLL_INTERVAL: Duration = Duration::from_secs(2); /// How directories are registered with the OS. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -43,6 +48,7 @@ impl Default for WatchMode { } type OnChange = Box; +type Emit = Arc; /// Debounced watcher. Emits `IdeEvent::FsChanged` to `sink` and invokes /// `on_change` (used by the index) with the same batch. Stops when dropped; @@ -51,12 +57,14 @@ pub struct Watcher { inner: Inner, excludes: SharedExcludes, rules: Arc, + polling: Option, } type SharedExcludes = Arc>; enum Inner { Recursive(#[allow(dead_code)] Debouncer), + Polling(#[allow(dead_code)] Debouncer), PerDirectory { state: Arc, thread: Option> }, } @@ -76,7 +84,7 @@ impl Watcher { let filter = EventFilter::new(root.clone(), &excludes); let shared = filter.excludes.clone(); let rules = filter.gitignore.clone(); - let emit = move |batch: &FsBatch| { + let emit: Emit = Arc::new(move |batch: &FsBatch| { // VCS-metadata-only batches (`batch.vcs`) carry no worktree paths: // consumers that refresh on any `fsChanged` would reschedule purely // from the backend's own `.git/index` write. @@ -91,13 +99,21 @@ impl Watcher { sink.emit(IdeEvent::ExcludedDirsChanged { dirs: batch.excluded_dirs.iter().map(Into::into).collect() }); } on_change(batch); + }); + let (inner, polling) = match Self::start_native(&root, filter.clone(), emit.clone(), mode) { + Ok(inner) => (inner, None), + // Rather than failing to open the project, keep it in sync a little later. + Err(e) => { + tracing::warn!("{e}; polling {} for changes instead", root.display()); + (Self::start_polling(&root, filter, emit, mode)?, Some(e.message)) + } }; - let inner = match mode { - WatchMode::Recursive => Self::start_recursive(root, filter, emit, RecursiveMode::Recursive), - WatchMode::Flat => Self::start_recursive(root, filter, emit, RecursiveMode::NonRecursive), - WatchMode::PerDirectory => Self::start_per_directory(root, filter, emit), - }?; - Ok(Self { inner, excludes: shared, rules }) + Ok(Self { inner, excludes: shared, rules, polling }) + } + + /// Why changes are polled for instead of reported by the OS; `None` when they are. + pub fn polling(&self) -> Option<&str> { + self.polling.as_deref() } /// Replaces the exclude globs (Settings › Project Structure / File Types @@ -116,21 +132,53 @@ impl Watcher { /// Number of OS-level watches (1 in recursive and flat mode). pub fn watch_count(&self) -> usize { match &self.inner { - Inner::Recursive(_) => 1, + Inner::Recursive(_) | Inner::Polling(_) => 1, Inner::PerDirectory { state, .. } => state.watched.lock().len(), } } - fn start_recursive( - root: PathBuf, + fn start_native(root: &Path, filter: EventFilter, emit: Emit, mode: WatchMode) -> IdeResult { + let config = notify::Config::default(); + match mode { + WatchMode::Recursive => { + Self::start_debounced(root, filter, emit, RecursiveMode::Recursive, config).map(Inner::Recursive) + } + WatchMode::Flat => { + Self::start_debounced(root, filter, emit, RecursiveMode::NonRecursive, config).map(Inner::Recursive) + } + WatchMode::PerDirectory => Self::start_per_directory::(root, filter, emit, config), + } + } + + /// Polls per directory unless flat: a recursive poll would rescan + /// `node_modules` and `target` every round. + fn start_polling(root: &Path, filter: EventFilter, emit: Emit, mode: WatchMode) -> IdeResult { + let config = notify::Config::default().with_poll_interval(POLL_INTERVAL); + match mode { + WatchMode::Flat => { + Self::start_debounced(root, filter, emit, RecursiveMode::NonRecursive, config).map(Inner::Polling) + } + WatchMode::Recursive | WatchMode::PerDirectory => { + Self::start_per_directory::(root, filter, emit, config) + } + } + } + + fn start_debounced( + root: &Path, filter: EventFilter, - emit: impl Fn(&FsBatch) + Send + 'static, + emit: Emit, recursive: RecursiveMode, - ) -> IdeResult { + config: notify::Config, + ) -> IdeResult> { + let polled = W::kind() == WatcherKind::PollWatcher; // No file-id cache: building it walks and stats the whole tree before `watch` returns, // which made opening large projects block for seconds. Renames arrive as remove + create. let handler = move |result: DebounceEventResult| match result { - Ok(events) => { + Ok(mut events) => { + if polled { + events.retain(|e| !is_dir_touch(&e.event)); + } let (batch, _) = filter.batch_detailed(&events); if !batch.is_empty() { emit(&batch); @@ -142,18 +190,19 @@ impl Watcher { } } }; - let mut debouncer = - new_debouncer_opt::<_, RecommendedWatcher, _>(DEBOUNCE, None, handler, NoCache, notify::Config::default()) - .map_err(|e| io_error(format!("cannot start file watcher: {e}")))?; - debouncer.watch(&root, recursive).map_err(|e| io_error(format!("cannot watch {}: {e}", root.display())))?; - Ok(Inner::Recursive(debouncer)) + let mut debouncer = new_debouncer_opt::<_, W, _>(DEBOUNCE, None, handler, NoCache, config) + .map_err(|e| io_error(format!("cannot start file watcher: {e}")))?; + debouncer.watch(root, recursive).map_err(|e| io_error(format!("cannot watch {}: {e}", root.display())))?; + Ok(debouncer) } - fn start_per_directory( - root: PathBuf, + fn start_per_directory( + root: &Path, filter: EventFilter, - emit: impl Fn(&FsBatch) + Send + 'static, + emit: Emit, + config: notify::Config, ) -> IdeResult { + let polled = W::kind() == WatcherKind::PollWatcher; let state = Arc::new(DirState { filter, watcher: Mutex::new(None), @@ -163,10 +212,11 @@ impl Watcher { stop: AtomicBool::new(false), }); let weak: Weak = Arc::downgrade(&state); - let watcher = RecommendedWatcher::new( + let watcher = W::new( move |event: notify::Result| { let Some(state) = weak.upgrade() else { return }; match event { + Ok(event) if polled && is_dir_touch(&event) => {} Ok(event) => { state.queue.lock().push((event, Instant::now())); state.wake.notify_one(); @@ -174,12 +224,12 @@ impl Watcher { Err(e) => tracing::warn!("file watcher: {e}"), } }, - notify::Config::default(), + config, ) .map_err(|e| io_error(format!("cannot start file watcher: {e}")))?; - *state.watcher.lock() = Some(watcher); + *state.watcher.lock() = Some(Box::new(watcher)); let mut created = Vec::new(); - state.watch_tree(&root, false, &mut created); + state.watch_tree(root, false, &mut created); if state.watched.lock().is_empty() { return Err(io_error(format!("cannot watch {}", root.display()))); } @@ -187,7 +237,7 @@ impl Watcher { let state = state.clone(); std::thread::Builder::new() .name("openidea-fs-watch".into()) - .spawn(move || state.run(emit)) + .spawn(move || state.run(&*emit)) .map_err(|e| io_error(format!("cannot start file watcher: {e}")))? }; Ok(Inner::PerDirectory { state, thread: Some(thread) }) @@ -212,11 +262,17 @@ fn io_error(msg: String) -> IdeError { IdeError::new(ide_core::error::ErrorKind::Io, msg) } +/// A directory's own mtime moving as entries come and go: polling sees it, the +/// OS watchers report only the entries (also seen, through that directory's watch). +fn is_dir_touch(event: ¬ify::Event) -> bool { + matches!(event.kind, EventKind::Modify(_)) && event.paths.iter().all(|p| p.is_dir()) +} + /// State of the per-directory strategy, shared with the notify callback /// (weakly) and the debounce thread. struct DirState { filter: EventFilter, - watcher: Mutex>, + watcher: Mutex>>, watched: Mutex>, queue: Mutex>, wake: Condvar, @@ -396,6 +452,7 @@ fn merge(prev: Option, next: Change) -> Change { } } +#[derive(Clone)] pub(crate) struct EventFilter { root: PathBuf, excludes: SharedExcludes, @@ -791,7 +848,7 @@ mod tests { fn watcher_state(w: &Watcher) -> &DirState { match &w.inner { Inner::PerDirectory { state, .. } => state, - Inner::Recursive(_) => panic!("not per-directory"), + Inner::Recursive(_) | Inner::Polling(_) => panic!("not per-directory"), } } @@ -969,4 +1026,48 @@ mod tests { std::thread::sleep(Duration::from_millis(300)); assert!(!all_paths(&batches.lock()).contains(&root.join("sub/deep.txt"))); } + + /// The fallback used when the OS watcher cannot start. + fn polling_watcher(root: &Path, excludes: Vec, batches: Arc>>) -> Watcher { + let filter = EventFilter::new(root.to_path_buf(), &excludes); + let (excludes, rules) = (filter.excludes.clone(), filter.gitignore.clone()); + let emit: Emit = Arc::new(move |b: &FsBatch| batches.lock().push(b.clone())); + let inner = Watcher::start_polling(root, filter, emit, WatchMode::default()).unwrap(); + Watcher { inner, excludes, rules, polling: Some("cannot start file watcher".into()) } + } + + #[test] + fn polling_skips_excluded_dirs_and_follows_new_ones() { + let tmp = tempfile::tempdir().unwrap(); + let root = dunce::canonicalize(tmp.path()).unwrap(); + for d in ["src/a", "node_modules/x"] { + std::fs::create_dir_all(root.join(d)).unwrap(); + } + let batches: Arc>> = Arc::default(); + let watcher = polling_watcher(&root, vec!["**/node_modules".into()], batches.clone()); + // root, src, src/a + assert_eq!(watcher.watch_count(), 3); + + std::fs::write(root.join("node_modules/x/z.js"), "x").unwrap(); + std::fs::write(root.join("src/a/f.rs"), "x").unwrap(); + std::fs::create_dir_all(root.join("src/new/inner")).unwrap(); + std::fs::write(root.join("src/new/inner/g.rs"), "x").unwrap(); + assert!( + wait_for(&batches, |b| { + let paths = all_paths(b); + paths.contains(&root.join("src/a/f.rs")) && paths.contains(&root.join("src/new/inner/g.rs")) + }), + "{:?}", + batches.lock() + ); + let paths = all_paths(&batches.lock()); + assert!(!paths.iter().any(|p| p.starts_with(root.join("node_modules"))), "{paths:?}"); + // Folders whose entries changed aren't reported as modified, as with inotify. + assert!(!paths.contains(&root.join("src")) && !paths.contains(&root.join("src/a")), "{paths:?}"); + assert_eq!(watcher.watch_count(), 5); + + // Later writes inside a new folder are seen through its own poll. + std::fs::write(root.join("src/new/inner/h.rs"), "x").unwrap(); + assert!(wait_for(&batches, |b| all_paths(b).contains(&root.join("src/new/inner/h.rs")))); + } } diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 0522011de..f965fcadc 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -5547,5 +5547,6 @@ "Passed as --settings; empty keeps DJANGO_SETTINGS_MODULE or the default in manage.py": "作为 --settings 传入;留空则沿用 DJANGO_SETTINGS_MODULE 或 manage.py 中的默认值", "e.g. --keepdb --parallel": "例如 --keepdb --parallel", "select files in a git repository to shelve": "请选择 Git 仓库中的文件以搁置更改", - "select files from a single git repository to shelve": "请一次只选择同一个 Git 仓库中的文件以搁置更改" + "select files from a single git repository to shelve": "请一次只选择同一个 Git 仓库中的文件以搁置更改", + "External file changes sync may be slow": "外部文件更改同步可能较慢" } From 291fa9affebe389ec80726d3f59daf1ec2430a00 Mon Sep 17 00:00:00 2001 From: Jaw <53564781+jaw52@users.noreply.github.com> Date: Tue, 3 Nov 2026 17:57:42 +0800 Subject: [PATCH 066/106] feat(window): close other / left / right project tabs from the window-group tab menu (#965) Right-clicking a project tab in a merged window group offers Close Other Tabs, Close Tabs to the Left and Close Tabs to the Right (left/right disabled at the ends); the same commands are in the Command Palette and can take shortcuts. The backend closes the projects one at a time, focusing each so its own unsaved-changes prompt is visible, as Quit does. Cancelling a prompt stops the batch and leaves that project and the rest open; the clicked tab stays and is selected when the batch finishes. Starting Quit clears a pending batch. Closes #942. Co-authored-by: jaw52 <53564781+jaw52@users.noreply.github.com> --- docs/workspaces-and-window-groups.md | 2 +- src-tauri/src/commands/mod.rs | 2 + src-tauri/src/commands/window_group.rs | 14 +++++ src-tauri/src/window_groups.rs | 77 ++++++++++++++++++++++++++ src-tauri/src/windows.rs | 6 +- src/bindings.ts | 4 ++ src/features/app/closeGuard.ts | 3 +- src/features/app/windows.ts | 37 ++++++++++++- src/platform/locales/text/zh.json | 3 + src/workbench/WindowGroupTabs.test.tsx | 27 +++++++++ src/workbench/WindowGroupTabs.tsx | 13 +++++ 11 files changed, 184 insertions(+), 4 deletions(-) diff --git a/docs/workspaces-and-window-groups.md b/docs/workspaces-and-window-groups.md index be9cf6a26..db86800ed 100644 --- a/docs/workspaces-and-window-groups.md +++ b/docs/workspaces-and-window-groups.md @@ -28,7 +28,7 @@ The specialized Maven/Gradle tool-window model still describes the workspace dir Open two project windows, then use **Window → Merge All Project Windows**. The project tabs occupy their own row below the main toolbar. Click a tab or use **Next/Previous Project Tab** to switch; drag tabs to reorder them. The default shortcuts are ⌥⌘PageDown/PageUp on macOS and Ctrl+Alt+PageDown/PageUp on Windows/Linux. -Use the tab's close button to close that project, or **Move Tab to New Window** to detach the current tab. Closing uses the existing unsaved-changes guard. Quit visits project windows in sequence so a hidden project's prompt is visible. Groups restore when **Reopen last project** is enabled. +Use the tab's close button to close that project, or **Move Tab to New Window** to detach the current tab. Right-click a tab for **Close Other Tabs**, **Close Tabs to the Left** and **Close Tabs to the Right**, which close the projects one at a time and keep the clicked tab; cancelling one project's unsaved-changes prompt leaves it and the rest open. The same commands are in the Command Palette and can be assigned shortcuts. Closing uses the existing unsaved-changes guard. Quit visits project windows in sequence so a hidden project's prompt is visible. Groups restore when **Reopen last project** is enabled. The grouping model lives in `ide-workspace`, independently of project ownership. The desktop adapter keeps each member's WebView and backend services alive, presents the selected window at the group's position/size, and hides its peers. This is an application-drawn tab strip, not the macOS native tab strip. Its persistence is application-local in `window-groups.json`. Language servers, terminals, editor buffers and settings remain independent between group members. diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 4a8f3c06c..0a410b345 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -77,6 +77,8 @@ pub fn builder() -> tauri_specta::Builder { window_group::window_group_select, window_group::window_group_detach, window_group::window_group_close, + window_group::window_group_close_tabs, + window_group::window_group_cancel_close, window_group::window_group_reorder, window_group::window_group_restore, app::light_edit_open, diff --git a/src-tauri/src/commands/window_group.rs b/src-tauri/src/commands/window_group.rs index 210797dc2..f6d4d29ae 100644 --- a/src-tauri/src/commands/window_group.rs +++ b/src-tauri/src/commands/window_group.rs @@ -41,6 +41,20 @@ pub fn window_group_close(window: WebviewWindow, label: String) -> IdeResult<()> .map_err(|e| IdeError::internal(e.to_string())) } +/// Closes several project tabs one by one (each asks about its own unsaved changes), then selects `keep`. +#[tauri::command] +#[specta::specta] +pub fn window_group_close_tabs(window: WebviewWindow, keep: String, labels: Vec) -> IdeResult<()> { + groups::close_tabs(window.app_handle(), window.label(), &keep, labels) +} + +/// This window kept its project open (unsaved-changes prompt cancelled); stops a batch close. +#[tauri::command] +#[specta::specta] +pub fn window_group_cancel_close(window: WebviewWindow) { + groups::cancel_close(window.app_handle(), window.label()); +} + #[tauri::command] #[specta::specta] pub async fn window_group_reorder(window: WebviewWindow, labels: Vec) -> IdeResult<()> { diff --git a/src-tauri/src/window_groups.rs b/src-tauri/src/window_groups.rs index fef00fa14..d8c47c2be 100644 --- a/src-tauri/src/window_groups.rs +++ b/src-tauri/src/window_groups.rs @@ -6,6 +6,7 @@ use crate::{ use ide_core::{IdeError, IdeEvent, IdeResult}; use serde::{Deserialize, Serialize}; use specta::Type; +use std::collections::VecDeque; use std::path::PathBuf; use std::sync::atomic::Ordering; use tauri::{AppHandle, Manager, WebviewWindow}; @@ -170,6 +171,82 @@ pub fn destroyed(app: &AppHandle, caller: &str) { }); } changed(app); + let advance = state.tab_closes.lock().as_mut().is_some_and(|batch| { + let closing = batch.pending.front().is_some_and(|l| l == caller); + if closing { + batch.pending.pop_front(); + } + closing + }); + if advance { + close_next(app); + } +} + +/// Tabs left to close; the first one is closing now. +pub struct TabCloses { + keep: String, + pending: VecDeque, +} + +/// Closes `labels` one at a time so each project's unsaved-changes prompt is shown in turn, then selects `keep`. +pub fn close_tabs(app: &AppHandle, caller: &str, keep: &str, labels: Vec) -> IdeResult<()> { + let state = app.state::(); + let members = state + .groups + .lock() + .group(caller) + .map(|g| g.members.clone()) + .ok_or_else(|| IdeError::not_found("This window is not grouped"))?; + if !members.iter().any(|m| m == keep) { + return Err(IdeError::invalid("The window is not in this group")); + } + let pending = members.into_iter().filter(|m| m != keep && labels.contains(m)).collect(); + *state.tab_closes.lock() = Some(TabCloses { keep: keep.to_owned(), pending }); + close_next(app); + Ok(()) +} + +/// The closing tab's project vetoed (its unsaved-changes prompt was cancelled): leave the rest open. +pub fn cancel_close(app: &AppHandle, caller: &str) { + let state = app.state::(); + let mut batch = state.tab_closes.lock(); + if batch.as_ref().is_some_and(|b| b.pending.front().is_some_and(|l| l == caller)) { + *batch = None; + } +} + +fn close_next(app: &AppHandle) { + let state = app.state::(); + loop { + let next = { + let mut batch = state.tab_closes.lock(); + let Some(b) = batch.as_mut() else { return }; + match b.pending.front() { + Some(label) => Ok(label.clone()), + None => Err(batch.take().unwrap().keep), + } + }; + match next { + Ok(label) => { + if let Some(window) = app.get_webview_window(&label) { + windows::focus(&window); + if window.close().is_ok() { + return; + } + } + if let Some(b) = state.tab_closes.lock().as_mut() { + b.pending.pop_front(); + } + } + Err(keep) => { + if let Some(window) = app.get_webview_window(&keep) { + windows::focus(&window); + } + return; + } + } + } } #[derive(Serialize, Deserialize)] diff --git a/src-tauri/src/windows.rs b/src-tauri/src/windows.rs index b9d14c122..d6bc20514 100644 --- a/src-tauri/src/windows.rs +++ b/src-tauri/src/windows.rs @@ -67,6 +67,8 @@ pub struct WindowsState { last_focused: Mutex>, /// Set by Quit while windows close one by one. quitting: AtomicBool, + /// Project tabs closing one by one (Close Other Tabs and friends). + pub tab_closes: Mutex>, /// Relaunch once the quit completes (after installing an update). restart: AtomicBool, } @@ -109,7 +111,9 @@ impl WindowsState { pub fn set_quitting(&self, on: bool) { self.quitting.store(on, Ordering::SeqCst); - if !on { + if on { + *self.tab_closes.lock() = None; + } else { self.restart.store(false, Ordering::SeqCst); } } diff --git a/src/bindings.ts b/src/bindings.ts index 7f3e69b7c..bb162c122 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -23,6 +23,10 @@ export const commands = { windowGroupSelect: (label: string) => typedError(__TAURI_INVOKE("window_group_select", { label })), windowGroupDetach: () => typedError(__TAURI_INVOKE("window_group_detach")), windowGroupClose: (label: string) => typedError(__TAURI_INVOKE("window_group_close", { label })), + /** Closes several project tabs one by one (each asks about its own unsaved changes), then selects `keep`. */ + windowGroupCloseTabs: (keep: string, labels: string[]) => typedError(__TAURI_INVOKE("window_group_close_tabs", { keep, labels })), + /** This window kept its project open (unsaved-changes prompt cancelled); stops a batch close. */ + windowGroupCancelClose: () => __TAURI_INVOKE("window_group_cancel_close"), windowGroupReorder: (labels: string[]) => typedError(__TAURI_INVOKE("window_group_reorder", { labels })), windowGroupRestore: () => typedError(__TAURI_INVOKE("window_group_restore")), /** diff --git a/src/features/app/closeGuard.ts b/src/features/app/closeGuard.ts index 4194a1689..2eedcb92e 100644 --- a/src/features/app/closeGuard.ts +++ b/src/features/app/closeGuard.ts @@ -125,10 +125,11 @@ async function onCloseRequested(deps: CloseDeps): Promise { }, deps, ); - if (!ok && ctx.quitting) await commands.appCancelQuit(); + if (!ok) await (ctx.quitting ? commands.appCancelQuit() : commands.windowGroupCancelClose()); return ok; } catch (e) { maybeEditorService()?.cancelClose?.(); + void commands.windowGroupCancelClose().catch(() => {}); deps.onSaveFailed(e); return false; } finally { diff --git a/src/features/app/windows.ts b/src/features/app/windows.ts index 3b39eaf47..0125e4690 100644 --- a/src/features/app/windows.ts +++ b/src/features/app/windows.ts @@ -23,7 +23,7 @@ import { connectRemote, isRemoteWindow } from "@/platform/remote"; import { holdSplash } from "@/platform/splash"; import { editorService, maybeEditorService, notifications } from "@/workbench/contributions"; import { chooseProjectWindow } from "@/workbench/projectWindow"; -import { cycleWindowTab } from "@/platform/windowGroups"; +import { cycleWindowTab, useWindowGroup } from "@/platform/windowGroups"; import { showWorkspaceDialog } from "./WorkspaceDialog"; export const LAUNCH_EVENT = "openidea://launch"; @@ -222,6 +222,20 @@ export async function consumeLaunch(): Promise { } } +/** Closes the project tabs on `side` of the `arg` tab (default: the current one), keeping that tab. */ +function closeProjectTabs(side: "others" | "left" | "right"): Action["run"] { + return async ({ arg }) => { + const tabs = useWindowGroup.getState().tabs; + const keep = typeof arg === "string" ? arg : tabs.find((tab) => tab.current)?.label; + const index = tabs.findIndex((tab) => tab.label === keep); + if (!keep || index < 0) return; + const labels = tabs + .filter((_, i) => (side === "left" ? i < index : side === "right" ? i > index : i !== index)) + .map((tab) => tab.label); + if (labels.length) await call(commands.windowGroupCloseTabs(keep, labels)); + }; +} + export function windowActions(): Action[] { return [ { @@ -302,6 +316,27 @@ export function windowActions(): Action[] { if (typeof arg === "string") await call(commands.windowGroupClose(arg)); }, }, + { + id: "window.closeOtherTabs", + title: "Close Other Project Tabs", + category: t("app.category.window"), + when: "windowGrouped", + run: closeProjectTabs("others"), + }, + { + id: "window.closeTabsToLeft", + title: "Close Project Tabs to the Left", + category: t("app.category.window"), + when: "windowGrouped", + run: closeProjectTabs("left"), + }, + { + id: "window.closeTabsToRight", + title: "Close Project Tabs to the Right", + category: t("app.category.window"), + when: "windowGrouped", + run: closeProjectTabs("right"), + }, { id: "window.reorderTabs", title: "Reorder Project Tabs", diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index f965fcadc..d7b36d690 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -5008,6 +5008,9 @@ "Window group": "窗口组", "Select Project Tab": "选择项目标签页", "Close Project Tab": "关闭项目标签页", + "Close Other Project Tabs": "关闭其他项目标签页", + "Close Project Tabs to the Left": "关闭左侧项目标签页", + "Close Project Tabs to the Right": "关闭右侧项目标签页", "Reorder Project Tabs": "重新排列项目标签页", "Close {name}": "关闭 {name}", "Tomcat Server": "Tomcat 服务器", diff --git a/src/workbench/WindowGroupTabs.test.tsx b/src/workbench/WindowGroupTabs.test.tsx index 6db78e678..4f83e5cfd 100644 --- a/src/workbench/WindowGroupTabs.test.tsx +++ b/src/workbench/WindowGroupTabs.test.tsx @@ -2,6 +2,7 @@ import { beforeEach, expect, it, vi } from "vitest"; import { fireEvent, render, screen } from "@testing-library/react"; import { executeAction } from "@/platform/actions"; import { useWindowGroup } from "@/platform/windowGroups"; +import { useContextMenuStore } from "@/ui/Menu"; import { WindowGroupTabs } from "./WindowGroupTabs"; vi.mock("@/platform/actions", async (importOriginal) => ({ @@ -37,3 +38,29 @@ it("removes the tab row when the group dissolves", () => { render(); expect(screen.queryByRole("tablist")).not.toBeInTheDocument(); }); + +it("offers closing the other tabs and those on either side of the clicked tab", () => { + useWindowGroup.setState({ + tabs: [ + { label: "main", name: "Frontend", root: "/front", current: true }, + { label: "project-1", name: "Backend", root: "/back", current: false }, + { label: "project-2", name: "Docs", root: "/docs", current: false }, + ], + }); + render(); + const menuFor = (name: string) => { + fireEvent.contextMenu(screen.getByRole("tab", { name })); + const items = useContextMenuStore.getState().menu!.items; + return (label: string) => + items.find((i) => "label" in i && i.label === label) as { disabled?: boolean; run?: () => unknown }; + }; + const first = menuFor("Frontend"); + expect(first("Close Tabs to the Left").disabled).toBe(true); + expect(first("Close Tabs to the Right").disabled).toBe(false); + const middle = menuFor("Backend"); + void middle("Close Other Tabs").run?.(); + expect(executeAction).toHaveBeenLastCalledWith("window.closeOtherTabs", "project-1"); + void middle("Close Tabs to the Left").run?.(); + expect(executeAction).toHaveBeenLastCalledWith("window.closeTabsToLeft", "project-1"); + expect(menuFor("Docs")("Close Tabs to the Right").disabled).toBe(true); +}); diff --git a/src/workbench/WindowGroupTabs.tsx b/src/workbench/WindowGroupTabs.tsx index f10f28e18..49e873e35 100644 --- a/src/workbench/WindowGroupTabs.tsx +++ b/src/workbench/WindowGroupTabs.tsx @@ -61,13 +61,26 @@ export function WindowGroupTabs() { }} onContextMenu={(e) => { e.preventDefault(); + const index = tabs.indexOf(tab); showContextMenu(e, [ { label: "Move Tab to New Window", disabled: !tab.current, run: () => executeAction("window.detachTab"), }, + { kind: "separator" }, { label: "Close Tab", run: () => executeAction("window.closeTab", tab.label) }, + { label: "Close Other Tabs", run: () => executeAction("window.closeOtherTabs", tab.label) }, + { + label: "Close Tabs to the Left", + disabled: index <= 0, + run: () => executeAction("window.closeTabsToLeft", tab.label), + }, + { + label: "Close Tabs to the Right", + disabled: index >= tabs.length - 1, + run: () => executeAction("window.closeTabsToRight", tab.label), + }, ]); }} > From 5ad179a9e6d6a2ac65d3a7d9d82bcb11634e7a43 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Tue, 3 Nov 2026 18:12:43 +0800 Subject: [PATCH 067/106] perf(java): stop configuration indexing from blocking navigation and keep it warm (#1012) Java Go to Definition consults the Spring configuration command only inside @Value / getProperty / getRequiredProperty arguments (tree-sitter check), so ordinary Ctrl+click and Ctrl+hover go straight to JDT instead of waiting on a project-wide index rebuild. The java-usages bundle (1.2.1) keeps its configuration snapshot across editor open/close, builds, saves of non-Java resources and reconciles; it invalidates on Java model changes and unsaved Java edits only. Fixes #998 Fixes #1002 --- .../ide-language/src/syntax/configuration.rs | 82 +++++++++++++++++- crates/ide-language/src/syntax/lines.rs | 10 +++ crates/ide-language/src/syntax/web.rs | 11 +-- crates/ide-lsp/src/jdtls/configuration.rs | 16 +++- crates/ide-lsp/tests/mock_server.rs | 35 +++++++- crates/ide-sdk/src/java.rs | 2 +- plugins/java-usages/CONFIGURATION.md | 13 +-- plugins/java-usages/MANIFEST.MF | 2 +- plugins/java-usages/README.md | 5 +- plugins/java-usages/openidea-java-usages.jar | Bin 82033 -> 83078 bytes .../configuration/ConfigurationCommand.java | 27 ++++-- plugins/java-usages/tests/configuration.py | 29 ++++++- 12 files changed, 203 insertions(+), 29 deletions(-) diff --git a/crates/ide-language/src/syntax/configuration.rs b/crates/ide-language/src/syntax/configuration.rs index 43c1cb5cf..4a1a081f0 100644 --- a/crates/ide-language/src/syntax/configuration.rs +++ b/crates/ide-language/src/syntax/configuration.rs @@ -1,9 +1,9 @@ //! Source ranges for Spring-style configuration keys. This is a syntax index, //! not a YAML evaluator: aliases, merge keys and runtime profile precedence are //! intentionally left to the YAML server / application. -use super::lines::LineIndex; +use super::lines::{LineIndex, byte_at}; use super::{ParsedTree, Syntax, TreeError}; -use ide_core::{LanguageId, Range}; +use ide_core::{LanguageId, Pos, Range}; use tree_sitter::Node; #[derive(Clone, Debug, PartialEq, Eq)] @@ -26,6 +26,40 @@ impl Syntax { } } +/// Spring's property lookups whose first argument names a configuration key. +const PROPERTY_LOOKUPS: [&str; 2] = ["getProperty", "getRequiredProperty"]; + +impl Syntax { + /// Whether `pos` in Java `text` lies where a configuration key can be written: + /// inside the arguments of a `@Value` annotation or of a `getProperty` / + /// `getRequiredProperty` call. Everywhere else Go to Definition is ordinary + /// Java navigation and need not consult the configuration index. A file the + /// grammar cannot parse answers false. + pub fn java_configuration_site(&self, text: &str, pos: Pos, cancelled: &dyn Fn() -> bool) -> bool { + let Some(at) = byte_at(text, pos) else { return false }; + self.with_parsed(&LanguageId::new("java"), text, cancelled, |tree| { + let mut node = tree.tree.root_node().descendant_for_byte_range(at, at); + while let Some(n) = node { + let Some(parent) = n.parent() else { break }; + let callee = parent.child_by_field_name("name").map(|name| &text[name.byte_range()]); + match (n.kind(), parent.kind(), callee) { + ("annotation_argument_list", "annotation", Some(name)) => { + return name.rsplit('.').next() == Some("Value"); + } + ("argument_list", "method_invocation", Some(name)) if PROPERTY_LOOKUPS.contains(&name) => { + return true; + } + // Keys are expressions; nothing above a body or declaration can hold one. + (_, "block" | "class_body" | "interface_body" | "program", _) => return false, + _ => node = Some(parent), + } + } + false + }) + .unwrap_or(false) + } +} + impl ParsedTree<'_> { pub fn configuration_keys(&self, cancelled: &dyn Fn() -> bool) -> Vec { let mut out = Vec::new(); @@ -188,6 +222,50 @@ fn unescape_property(raw: &str) -> Option { #[cfg(test)] mod tests { use super::*; + + #[test] + fn java_configuration_sites() { + let text = r#"package a; +import org.springframework.beans.factory.annotation.Value; +class A { + @Value("${app.port:80}") int port; + @org.springframework.beans.factory.annotation.Value(value = Keys.NAME) String name; + @Values("x") int other; + @Override public String toString() { + String v = env.getProperty("app.host", "h") + env.getRequiredProperty(Keys.USER); + return helper.getValue("😀 app.port") + String.valueOf(port) + env.getProperty("k").trim(); + } +} +"#; + let syntax = Syntax::new(); + let at = |needle: &str, offset: u32| { + let line = text.lines().position(|l| l.contains(needle)).unwrap(); + let byte = text.lines().nth(line).unwrap().find(needle).unwrap(); + let col = text.lines().nth(line).unwrap()[..byte].encode_utf16().count() as u32 + offset; + syntax.java_configuration_site(text, Pos::new(line as u32, col), &|| false) + }; + // @Value arguments (literal, qualified annotation, constant key). + assert!(at("${app.port", 2)); + assert!(at("Keys.NAME", 6)); + // Lookup arguments, including a constant and the default value. + assert!(at("\"app.host\"", 3)); + assert!(at("\"h\"", 1)); + assert!(at("Keys.USER", 1)); + assert!(at("\"k\"", 1)); + // Ordinary Java: names, other annotations, other calls, the lookup's own name and receiver. + assert!(!at("@Value(\"$", 2)); + assert!(!at("int port", 4)); + assert!(!at("@Values", 9)); + assert!(!at("@Override", 2)); + assert!(!at("getProperty(\"app", 3)); + assert!(!at("env.getProperty(\"app", 1)); + assert!(!at("app.port\")", 1)); + assert!(!at("valueOf", 2)); + assert!(!at(".trim", 2)); + // Out of range and non-Java input. + assert!(!syntax.java_configuration_site(text, Pos::new(99, 0), &|| false)); + assert!(!syntax.java_configuration_site("", Pos::new(0, 0), &|| false)); + } #[test] fn yaml_structures_and_utf16() { let text = "app:\n 'time-out': 1\n hosts:\n - url: a\n - {url: b}\n map: {east: {size: 2}}\n---\n\"app.name\": ok\n'😀': {port: 3}\n"; diff --git a/crates/ide-language/src/syntax/lines.rs b/crates/ide-language/src/syntax/lines.rs index 5b8de2062..6fd0b1906 100644 --- a/crates/ide-language/src/syntax/lines.rs +++ b/crates/ide-language/src/syntax/lines.rs @@ -86,6 +86,16 @@ pub(crate) fn utf16_len(s: &str) -> u32 { if s.is_ascii() { s.len() as u32 } else { s.chars().map(|c| c.len_utf16() as u32).sum() } } +/// Byte offset of `pos` (line, UTF-16 column) in `text`; None past the last line. +pub(crate) fn byte_at(text: &str, pos: Pos) -> Option { + let mut start = 0; + for _ in 0..pos.line { + start += text[start..].find('\n')? + 1; + } + let line_end = text[start..].find('\n').map_or(text.len(), |i| start + i); + Some(start + utf16_to_byte(&text[start..line_end], pos.col as usize)) +} + pub(crate) fn utf16_to_byte(s: &str, offset: usize) -> usize { let mut units = 0; for (i, c) in s.char_indices() { diff --git a/crates/ide-language/src/syntax/web.rs b/crates/ide-language/src/syntax/web.rs index 73c9896d4..88a287570 100644 --- a/crates/ide-language/src/syntax/web.rs +++ b/crates/ide-language/src/syntax/web.rs @@ -6,7 +6,7 @@ //! `