Files
openidea/src/features/project-tree/contextMenu.test.ts
T
l0ng-ai 2c9a6470ff feat(project): add project trust and restricted execution mode (#517)
Projects now open with a user-owned trust decision. The prompt offers Preview in Restricted Mode, Don't Open and Trust Project, with an optional trusted parent folder; preview is the default focus so Enter never grants trust.

Restricted projects keep browsing, editing, saving, search and highlighting. Project-controlled execution is denied by default in the backend: run, debug, build, tools, language servers, plugins and Git commands that could honour repository configuration. Trusting a project enables those paths without reopening it, and decisions can be revoked in Settings.

Terminals stay available when the user opens one, without project shell settings, environment or virtualenv activation. A shell prompt that runs git in the folder can still honour the repository's core.fsmonitor.

Fixes #462
2026-10-31 20:58:45 +08:00

229 lines
7.6 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { registerActions } from "@/platform/actions";
import { useProject } from "@/platform/project";
import { resolveMenuItems, type MenuItem } from "@/ui";
import { registerMenuGroup } from "@/workbench/contributions";
import { contextMenuItems, revealLabel } from "./contextMenu";
import type { ProjectNode } from "./model";
import { useProjectTree } from "./store";
const ROOT = "/p";
const SCRATCHES = "/config/scratches";
const node = (path: string, dir = false, root = false): ProjectNode => ({
key: path,
path,
chain: [path],
label: path.slice(path.lastIndexOf("/") + 1),
dir,
root,
entry: null,
ignored: false,
excluded: false,
symlink: false,
});
/** Labels as the menu shows them ("-" for separators). */
const labels = (items: MenuItem[]) => resolveMenuItems(items).map((r) => (r.kind === "separator" ? "-" : r.label));
const sub = (items: MenuItem[], label: string) => {
const item = resolveMenuItems(items).find((r) => r.label === label);
return item?.submenu ? labels(item.submenu) : undefined;
};
const OTHER_ACTIONS = [
"find.inPath",
"find.replaceInPath",
"code.reformatFile",
"code.optimizeImportsFile",
"terminal.openAt",
"vcs.localHistory",
"localHistory.putLabel",
"diff.compareWith",
"diff.compareFiles",
"vcs.commit",
"vcs.addToVcs",
"vcs.annotate",
"vcs.showDiff",
"vcs.rollback",
"vcs.push",
];
let disposers: Array<() => void> = [];
beforeEach(() => {
useProjectTree.setState({ root: ROOT });
useProject.setState({
project: { name: "p", root: ROOT, trusted: true, isGit: true, settings: {} } as never,
settings: {} as never,
});
disposers = [
registerActions(OTHER_ACTIONS.map((id) => ({ id, title: id, run: () => undefined }))),
registerMenuGroup({
id: "test.run",
menus: ["projectView"],
items: (_menu, ctx) =>
ctx.paths.length === 1 && !ctx.dirs?.[0] ? [{ label: "Run 'a'", icon: "run", run: () => undefined }] : [],
}),
];
});
afterEach(() => {
disposers.forEach((d) => d());
useProject.setState({ project: null, settings: null });
useProjectTree.setState({ root: null });
});
describe("project view context menu", () => {
it("follows IDEA's order for a file", () => {
const items = contextMenuItems([node(`${ROOT}/src/a.py`)]);
expect(labels(items)).toEqual([
"New",
"-",
"Cut",
"Copy",
"Copy Path/Reference…",
"Paste",
"-",
"Jump to Source",
"Find Usages",
"Find in Files…",
"Replace in Files…",
"-",
"Refactor",
"-",
"Reformat Code",
"Optimize Imports",
"Delete…",
"-",
"Run 'a'",
"-",
"Open In",
"-",
"Local History",
"Reload from Disk",
"-",
"Git",
"-",
"Compare With…",
]);
expect(sub(items, "Refactor")).toEqual(["Rename…", "Move File…", "Copy File…", "-", "Safe Delete…"]);
expect(sub(items, "Open In")).toEqual([revealLabel(), "Terminal"]);
expect(sub(items, "Git")?.slice(0, 4)).toEqual(["Commit File…", "Add", "-", "Annotate with Git Blame"]);
});
it("offers both ignore destinations for files, directories and multi-selection, separately from IDE exclusion", () => {
const dispose = registerActions(
["vcs.ignoreShared", "vcs.ignoreLocal"].map((id) => ({ id, title: id, run: () => undefined })),
);
try {
for (const nodes of [[node("/p/a")], [node("/p/dir", true)], [node("/p/a"), node("/p/dir", true)]]) {
const git = resolveMenuItems(contextMenuItems(nodes)).find((item) => item.label === "Git")!;
expect(sub(git.submenu!, "Add to .gitignore")).toEqual([".gitignore", ".git/info/exclude"]);
}
const root = resolveMenuItems(contextMenuItems([node("/p", true, true)])).find((item) => item.label === "Git")!;
expect(labels(root.submenu!)).not.toContain("Add to .gitignore");
const folder = contextMenuItems([node("/p/dir", true)]);
expect(sub(folder, "Mark Directory as")).toContain("Excluded");
} finally {
dispose();
}
});
it("offers Show History for files, directories, multi-selection and the project root", () => {
const dispose = registerActions([{ id: "vcs.showHistory", title: "Show History", run: () => undefined }]);
try {
for (const nodes of [
[node("/p/a.py")],
[node("/p/src", true)],
[node("/p/a.py"), node("/p/src", true)],
[node("/p", true, true)],
]) {
expect(sub(contextMenuItems(nodes), "Git")).toContain("Show History");
}
} finally {
dispose();
}
});
it("shows Mark Directory as for folders, without file-only items", () => {
const l = labels(contextMenuItems([node(`${ROOT}/src`, true)]));
expect(l).toContain("Mark Directory as");
expect(sub(contextMenuItems([node(`${ROOT}/src`, true)]), "Refactor")).toContain("Move Directory…");
for (const fileOnly of ["Find Usages", "Reformat Code", "Optimize Imports", "Compare With…", "Run 'a'"])
expect(l).not.toContain(fileOnly);
});
it("keeps the project root menu short and disables what cannot apply", () => {
const items = contextMenuItems([node(ROOT, true, true)]);
const l = labels(items);
expect(l).not.toContain("Refactor");
expect(l).not.toContain("Mark Directory as");
const resolved = resolveMenuItems(items);
for (const label of ["Cut", "Copy", "Delete…"])
expect(resolved.find((r) => r.label === label)?.disabled).toBe(true);
});
it("drops project-only groups for scratch files", () => {
const l = labels(contextMenuItems([node(`${SCRATCHES}/s.py`)]));
expect(l).not.toContain("Git");
expect(l).not.toContain("Find Usages");
expect(l).toContain("Refactor");
expect(l).toContain("Reload from Disk");
});
it("offers Compare Files for two files", () => {
const l = labels(contextMenuItems([node(`${ROOT}/a.py`), node(`${ROOT}/b.py`)]));
expect(l).toContain("Compare Files");
expect(l).not.toContain("Compare With…");
});
it("offers Compare With… in a non-git project too", () => {
useProject.setState({
project: { name: "p", root: ROOT, trusted: true, isGit: false, settings: {} } as never,
settings: {} as never,
});
const l = labels(contextMenuItems([node(`${ROOT}/a.py`)]));
expect(l).not.toContain("Git");
expect(l).toContain("Compare With…");
expect(labels(contextMenuItems([node(`${ROOT}/a.py`), node(`${ROOT}/b.py`)]))).toContain("Compare Files");
});
it("never shows an empty submenu", () => {
disposers.forEach((d) => d());
disposers = [];
useProject.setState({
project: { name: "p", root: ROOT, trusted: true, isGit: true, settings: {} } as never,
settings: {} as never,
});
const l = labels(contextMenuItems([node(`${ROOT}/a.py`)]));
expect(l).not.toContain("Git");
expect(l).not.toContain("Local History");
expect(l).not.toContain("Find in Files…");
});
});
it("offers workspace removal for every member root", () => {
useProject.setState({
project: {
root: "/workspace",
name: "A",
settings: {},
trusted: true,
isGit: false,
workspace: { version: 1, name: "A", projects: ["/a", "/b"] },
},
});
const removal = {
kind: "action",
action: "workspace.removeProject",
arg: "/b",
};
expect(contextMenuItems([node("/b", true, true)])).toContainEqual(removal);
expect(contextMenuItems([node("/a", true, true)])).toContainEqual(
expect.objectContaining({ action: "workspace.removeProject" }),
);
expect(contextMenuItems([node("/b/src", true)])).not.toContainEqual(
expect.objectContaining({ action: "workspace.removeProject" }),
);
});