feat(plugins): when clauses that test whether the project contains a file (#1620)

Plugin when clauses can use workspaceContains:<path>, 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 <[email protected]>
This commit is contained in:
Arnaud Ritti authored and GitHub committed 2026-11-06 05:27:50 +08:00
1 parent 4830f42168
commit 7bd96a998b
5 files changed
+345 -1

No files matched your search

+28 -1
View File
@@ -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}");
}
}
}
+7
View File
@@ -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
@@ -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<string, (e: unknown) => void>,
files: {} as Record<string, string[]>,
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<string[]> => {
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<string[]>((resolve) => (releaseOld = resolve));
const fresh = new Promise<string[]>((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();
});
});
+2
View File
@@ -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 (`<project>/.openidea/plugins`) depend on the open project.
+102
View File
@@ -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<string[]>,
isCurrent: () => boolean = () => true,
): Promise<void> {
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());
}