mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-08 17:02:17 +00:00
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:
1 parent
4830f42168
commit
7bd96a998b
5 files changed
+345
-1
No files matched your search
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
Reference in new issue
Block a user