From 7bd96a998b05eb95e89560f2d897a68a6ddb6ef0 Mon Sep 17 00:00:00 2001 From: Arnaud Ritti <77437157+arnaud-ritti@users.noreply.github.com> Date: Thu, 5 Nov 2026 22:27:50 +0100 Subject: [PATCH] feat(plugins): when clauses that test whether the project contains a file (#1620) Plugin when clauses can use workspaceContains:, a path relative to the project root (any project of a workspace). Installation refuses empty, absolute and .. paths. The keys are re-checked on plugin changes, project switches, created or removed files and changes in ignored folders; plugins that don't use the key do no IPC. The key regex avoids lookbehind for WebKit. Co-authored-by: arnaud-ritti <77437157+arnaud-ritti@users.noreply.github.com> --- crates/ide-extension/src/when.rs | 29 ++- plugins/README.md | 7 + .../__tests__/workspaceContains.test.ts | 206 ++++++++++++++++++ src/features/plugins/index.ts | 2 + src/features/plugins/workspaceContains.ts | 102 +++++++++ 5 files changed, 345 insertions(+), 1 deletion(-) create mode 100644 src/features/plugins/__tests__/workspaceContains.test.ts create mode 100644 src/features/plugins/workspaceContains.ts diff --git a/crates/ide-extension/src/when.rs b/crates/ide-extension/src/when.rs index 98cd3c931..b6a75d638 100644 --- a/crates/ide-extension/src/when.rs +++ b/crates/ide-extension/src/when.rs @@ -98,7 +98,15 @@ fn parse_unary(t: &[Tok], p: &mut usize, src: &str) -> Result<(), String> { *p += 1; Ok(()) } - Some(Tok::Id(_)) => { + Some(Tok::Id(id)) => { + if let Some(path) = id.strip_prefix("workspaceContains:") + && (path.is_empty() + || path.starts_with('/') + || path.contains('\\') + || path.split('/').any(|s| s == "..")) + { + return Err(format!("workspaceContains: needs a relative path inside the project: {src}")); + } *p += 1; if matches!(t.get(*p), Some(Tok::Op("==" | "!="))) { *p += 1; @@ -133,4 +141,23 @@ mod tests { assert!(validate_when(bad).is_err(), "{bad}"); } } + + #[test] + fn workspace_contains_paths_stay_in_the_project() { + for ok in [ + "workspaceContains:pom.xml", + "workspaceContains:.devcontainer/devcontainer.json", + "!workspaceContains:a/b && editorFocus", + ] { + assert!(validate_when(ok).is_ok(), "{ok}"); + } + for bad in [ + "workspaceContains:", + "workspaceContains:/etc/passwd", + "workspaceContains:../x", + "workspaceContains:a/../../x", + ] { + assert!(validate_when(bad).unwrap_err().contains("workspaceContains"), "{bad}"); + } + } } diff --git a/plugins/README.md b/plugins/README.md index d2f1ab09d..5d5af6511 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -152,6 +152,13 @@ is loaded. Menus, shortcuts and status bar items refer to the plugin's declared commands. A plugin shortcut never takes a keystroke the IDE (or the user) already uses; the user can rebind it in the keymap editor. +A `when` clause (on commands and menus) can test whether the open project +contains a file or folder: `when = "workspaceContains:.devcontainer/devcontainer.json"`. +The path is exact (no globs) and relative to the project root; in a workspace the +key is true when any of its projects contains the path. It updates when +files change. An IDE that doesn't know the key treats it as +false: the plugin still installs and the key reads as false there. + ### Activation Code is instantiated lazily: when one of the plugin's commands runs, or when diff --git a/src/features/plugins/__tests__/workspaceContains.test.ts b/src/features/plugins/__tests__/workspaceContains.test.ts new file mode 100644 index 000000000..72b13b1ab --- /dev/null +++ b/src/features/plugins/__tests__/workspaceContains.test.ts @@ -0,0 +1,206 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { getContext, setContext } from "@/platform/context"; +import { useProject } from "@/platform/project"; +import { usePlugins } from "../store"; +import { + affectsWorkspaceContains, + refreshWorkspaceContains, + startWorkspaceContains, + workspaceContainsKeys, +} from "../workspaceContains"; + +const ipc = vi.hoisted(() => ({ + handlers: {} as Record void>, + files: {} as Record, + readDir: vi.fn(), +})); +vi.mock("@/platform/ipc", () => ({ + call: (p: unknown) => p, + isTauri: () => true, + commands: { fsReadDir: (dir: string) => ipc.readDir(dir) }, + onIdeEvent: (name: string, handler: (e: unknown) => void) => { + ipc.handlers[name] = handler; + return () => { + delete ipc.handlers[name]; + }; + }, +})); + +describe("workspaceContains", () => { + it("collects keys from when clauses", () => { + expect( + workspaceContainsKeys([ + "workspaceContains:a/b.txt && editorFocus", + null, + "!workspaceContains:pom.xml", + "workspaceContains:a/b.txt", + ]), + ).toEqual(["workspaceContains:a/b.txt", "workspaceContains:pom.xml"]); + }); + + it("sets each key from its parent folder's listing", async () => { + const list = vi.fn(async (dir: string) => (dir === "/p/.devcontainer" ? ["devcontainer.json"] : ["src"])); + await refreshWorkspaceContains( + ["/p"], + ["workspaceContains:.devcontainer/devcontainer.json", "workspaceContains:pom.xml"], + list, + ); + expect(getContext()["workspaceContains:.devcontainer/devcontainer.json"]).toBe(true); + expect(getContext()["workspaceContains:pom.xml"]).toBe(false); + expect(list).toHaveBeenCalledWith("/p"); + }); + + it("is false without a project or when the folder can't be read", async () => { + const list = vi.fn(async (): Promise => { + throw new Error("missing"); + }); + await refreshWorkspaceContains(["/p"], ["workspaceContains:x/y"], list); + expect(getContext()["workspaceContains:x/y"]).toBe(false); + setContext("workspaceContains:x/y", true); + list.mockClear(); + await refreshWorkspaceContains([], ["workspaceContains:x/y"], list); + expect(list).not.toHaveBeenCalled(); + expect(getContext()["workspaceContains:x/y"]).toBe(false); + }); + + it("is true when any root contains the path", async () => { + const list = vi.fn(async (dir: string) => (dir === "/b" ? ["pom.xml"] : [])); + await refreshWorkspaceContains(["/a", "/b"], ["workspaceContains:pom.xml"], list); + expect(getContext()["workspaceContains:pom.xml"]).toBe(true); + }); + + it("ignores keys that are absolute or climb out of the project, and a trailing slash", async () => { + const list = vi.fn(async (dir: string) => (dir === "/p" ? [".devcontainer"] : [])); + await refreshWorkspaceContains( + ["/p"], + [ + "workspaceContains:/etc/passwd", + "workspaceContains:../a", + "workspaceContains:a/../../b", + "workspaceContains:.devcontainer/", + ], + list, + ); + expect(list).toHaveBeenCalledTimes(1); + expect(list).toHaveBeenCalledWith("/p"); + expect(getContext()["workspaceContains:/etc/passwd"]).toBe(false); + expect(getContext()["workspaceContains:../a"]).toBe(false); + expect(getContext()["workspaceContains:a/../../b"]).toBe(false); + expect(getContext()["workspaceContains:.devcontainer/"]).toBe(true); + }); + + it("only picks keys at a word boundary", () => { + expect(workspaceContainsKeys(["xworkspaceContains:../a", "a && workspaceContains:b"])).toEqual([ + "workspaceContains:b", + ]); + }); + + it("refreshes only when a watched path or one of its parents changes", () => { + const keys = ["workspaceContains:.devcontainer/devcontainer.json"]; + const affects = (paths: string[], roots = ["/p"]) => affectsWorkspaceContains(paths, roots, keys); + expect(affects(["/p2/x"])).toBe(false); + expect(affects(["/p/src/a.ts"])).toBe(false); + expect(affects(["/p/.devcontainer"])).toBe(true); + expect(affects(["/p/.devcontainer/devcontainer.json"])).toBe(true); + expect(affects(["/q/.devcontainer"], ["/p", "/q"])).toBe(true); + expect(affects(["/p/.devcontainer"], [])).toBe(false); + }); + + it("ignores a stale refresh that resolves after a newer one", async () => { + const key = "workspaceContains:pom.xml"; + let releaseOld: (names: string[]) => void = () => {}; + let releaseNew: (names: string[]) => void = () => {}; + const old = new Promise((resolve) => (releaseOld = resolve)); + const fresh = new Promise((resolve) => (releaseNew = resolve)); + let latest = 0; + const first = refreshWorkspaceContains( + ["/a"], + [key], + () => old, + ( + (t) => () => + t === latest + )(++latest), + ); + const second = refreshWorkspaceContains( + ["/b"], + [key], + () => fresh, + ( + (t) => () => + t === latest + )(++latest), + ); + releaseNew(["pom.xml"]); + await second; + releaseOld([]); + await first; + expect(getContext()[key]).toBe(true); + }); +}); + +describe("startWorkspaceContains", () => { + const key = "workspaceContains:.devcontainer/devcontainer.json"; + const setProject = (root: string) => useProject.setState({ project: { root, workspace: null } as never }); + const keyIs = (value: boolean) => vi.waitFor(() => expect(getContext()[key]).toBe(value)); + const emit = (name: string, event: unknown) => { + const handler = ipc.handlers[name]; + if (!handler) { + throw new Error(`no handler for ${name}`); + } + handler(event); + }; + let stop: () => void; + + beforeEach(() => { + ipc.files = { "/p": ["src"], "/q": [".devcontainer"], "/q/.devcontainer": ["devcontainer.json"] }; + ipc.readDir.mockReset(); + ipc.readDir.mockImplementation(async (dir: string) => (ipc.files[dir] ?? []).map((name) => ({ name }))); + usePlugins.setState({ ui: [{ pluginId: "x", point: "menus", key: "k", decl: { when: key } }] as never }); + setProject("/p"); + setContext(key, undefined); + stop = startWorkspaceContains(); + }); + + afterEach(() => stop()); + + it("re-evaluates when the file is created and deleted", async () => { + await keyIs(false); + ipc.files["/p"] = [".devcontainer"]; + ipc.files["/p/.devcontainer"] = ["devcontainer.json"]; + emit("fsChanged", { created: ["/p/.devcontainer"], modified: [], removed: [] }); + await keyIs(true); + + ipc.files["/p/.devcontainer"] = []; + emit("fsChanged", { created: [], modified: [], removed: ["/p/.devcontainer/devcontainer.json"] }); + await keyIs(false); + }); + + it("re-evaluates when the project changes", async () => { + await keyIs(false); + setProject("/q"); + await keyIs(true); + }); + + it.each([["/p/.devcontainer"], ["/p"]])("re-evaluates when ignored folder %s changes", async (dir) => { + await keyIs(false); + ipc.files["/p/.devcontainer"] = ["devcontainer.json"]; + emit("excludedDirsChanged", { dirs: [dir] }); + await keyIs(true); + }); + + it("ignores unrelated changes", async () => { + await keyIs(false); + ipc.readDir.mockClear(); + emit("fsChanged", { created: ["/p/src/a.ts"], modified: [], removed: [] }); + emit("excludedDirsChanged", { dirs: ["/p/node_modules"] }); + expect(ipc.readDir).not.toHaveBeenCalled(); + }); + + it("stops listening when disposed", async () => { + await keyIs(false); + stop(); + expect(ipc.handlers.fsChanged).toBeUndefined(); + expect(ipc.handlers.excludedDirsChanged).toBeUndefined(); + }); +}); diff --git a/src/features/plugins/index.ts b/src/features/plugins/index.ts index e659b68ec..bcf4c39eb 100644 --- a/src/features/plugins/index.ts +++ b/src/features/plugins/index.ts @@ -33,6 +33,7 @@ import { defineBuiltinUiPoints } from "./points"; import { startViewUi } from "./uiPoints"; import { importIntellijTheme, showInstalledIntellijThemes } from "./intellijImport"; import { IntellijThemesDialogHost } from "./IntellijThemesDialog"; +import { startWorkspaceContains } from "./workspaceContains"; import { installFromDisk, refreshPlugins, reloadPlugin, SETTINGS_PAGE_ID, updateAllPlugins, usePlugins } from "./store"; function registerPluginsPage(): () => void { @@ -144,6 +145,7 @@ export function activate(): () => void { startPluginBridge(), // Plugin custom editors (tab kind) and context menu entries. startViewUi((input) => void editorService().openInput(input)), + startWorkspaceContains(), // Gutter icons from plugins' `line-markers` providers (next to the LSP ones). registerEditorContribution({ id: "plugins.lineMarkers", order: 57, extensions: pluginLineMarkerGutter }), // Dev plugins (`/.openidea/plugins`) depend on the open project. diff --git a/src/features/plugins/workspaceContains.ts b/src/features/plugins/workspaceContains.ts new file mode 100644 index 000000000..91104bdf8 --- /dev/null +++ b/src/features/plugins/workspaceContains.ts @@ -0,0 +1,102 @@ +import { setContext } from "@/platform/context"; +import { call, commands, onIdeEvent } from "@/platform/ipc"; +import { useProject } from "@/platform/project"; +import { usePlugins } from "./store"; + +const PREFIX = "workspaceContains:"; +// A whole identifier: at the start or after a character that can't be part of one. +const KEY = /(?:^|[^\w.\-:/])(workspaceContains:[\w.\-:/]+)/g; + +export function workspaceContainsKeys(whens: readonly (string | null | undefined)[]): string[] { + return [...new Set(whens.flatMap((when) => [...(when ?? "").matchAll(KEY)].map((m) => m[1]!)))]; +} + +function relativePath(key: string): string | null { + const path = key.slice(PREFIX.length).replace(/\/+$/, ""); + if (!path || path.startsWith("/") || path.split("/").includes("..")) { + return null; + } + return path; +} + +export function affectsWorkspaceContains( + changed: readonly string[], + roots: readonly string[], + keys: readonly string[], +): boolean { + const targets = keys.flatMap((key) => { + const path = relativePath(key); + return path === null ? [] : roots.map((root) => `${root}/${path}`); + }); + return changed.some((p) => targets.some((t) => t === p || t.startsWith(`${p}/`))); +} + +export async function refreshWorkspaceContains( + roots: readonly string[], + keys: readonly string[], + list: (dir: string) => Promise, + isCurrent: () => boolean = () => true, +): Promise { + const results = await Promise.all( + keys.map(async (key) => { + const path = relativePath(key); + if (path === null) { + return [key, false] as const; + } + const slash = path.lastIndexOf("/"); + const parent = slash < 0 ? "" : path.slice(0, slash); + const name = path.slice(slash + 1); + const found = await Promise.all( + roots.map(async (root) => { + const names: string[] = await list(parent ? `${root}/${parent}` : root).catch(() => []); + return names.includes(name); + }), + ); + return [key, found.some(Boolean)] as const; + }), + ); + if (!isCurrent()) { + return; + } + for (const [key, value] of results) { + setContext(key, value); + } +} + +const listNames = (dir: string) => call(commands.fsReadDir(dir)).then((entries) => entries.map((e) => e.name)); + +const projectRoots = () => { + const project = useProject.getState().project; + return project?.workspace?.projects ?? (project ? [project.root] : []); +}; + +export function startWorkspaceContains(): () => void { + let latest = 0; + const currentKeys = () => + workspaceContainsKeys(usePlugins.getState().ui.map((u) => (u.decl as { when?: string | null }).when)); + const refresh = () => { + const token = ++latest; + void refreshWorkspaceContains(projectRoots(), currentKeys(), listNames, () => token === latest); + }; + const disposers = [ + usePlugins.subscribe((s, prev) => { + if (s.ui !== prev.ui) refresh(); + }), + useProject.subscribe((s, prev) => { + if ( + s.project?.root !== prev.project?.root || + s.project?.workspace?.projects.join("\n") !== prev.project?.workspace?.projects.join("\n") + ) { + refresh(); + } + }), + onIdeEvent("fsChanged", (e) => { + if (affectsWorkspaceContains([...e.created, ...e.removed], projectRoots(), currentKeys())) refresh(); + }), + onIdeEvent("excludedDirsChanged", (e) => { + if (affectsWorkspaceContains(e.dirs, projectRoots(), currentKeys())) refresh(); + }), + ]; + refresh(); + return () => disposers.forEach((d) => d()); +}