Файли
openidea/src/features/project-tree/contextMenu.ts
T
l0ng-ai 1e00ea5ee2 feat: Third pass aligning core experience with IntelliJ IDEA
Project & files
- Project view modes (Project Files / Open Files / All Changed Files /
  Scratches); External Libraries for Python, JDK (src.zip), Node, Cargo, Go,
  Maven/Gradle jars; read-only jar/zip browsing
- ⌘N New popup with type search and more file kinds; New Project, Rename
  Project, Reload All from Disk, Export/Import Settings, Power Save Mode
- File type associations, extra content roots and exclude patterns (indexed
  and watched without reopening the project)

Editor & navigation
- Find bar: search scope (comments/strings), Preserve Case, Exclude, multiline,
  history; Find in Files: Module scope, custom pattern scopes, multiline
- Search Everywhere filters, Assign Shortcut, settings results; named bookmark
  lists, file/folder bookmarks; Structure filters and Show Inherited; Recent
  Locations timestamps and breadcrumbs; Code Vision author
- Completion options, editor tab placement/layout, inline completion (LSP 3.18)
- Spell checking (Typo inspection, ide-spell) and Inspections settings with
  custom profiles; Code Style schemes, formatter markers, Do not format

Run, VCS, services, database
- Typed run configurations (Shell, Program, npm, Gradle, Maven, Docker,
  Compound), Modify options, Before launch, env dialog, Current File,
  Emulate terminal (PTY)
- Git log graph/view options, Go to Hash/Branch/Tag, date filter keeps the
  graph; Pull/Merge/Rebase dialogs, Manage Remotes, Worktrees
- Generic Services host (Docker connections/registries, run sessions); TODO
  scope/changes tabs with changelists; Coverage report import
- Data sources: wire-compatible vendors, URL import, SSL certs, Options,
  Schemas and Advanced tabs, color and comment

Workbench
- Run Anything, Customize Main Toolbar, named tool window layouts, menu
  completeness, Code Cleanup, macros, "Don't Show Again", editor banners
- Chinese translations for the new UI

Claude-Session: https://claude.ai/code/session_01SaPPsVPuk6ZTNsoUCNCbMv
2026-10-25 21:24:00 +08:00

402 рядки
15 KiB
TypeScript
Неформатований Анотація Історія

Цей файл містить неоднозначні символи Юнікоду
Цей файл містить символи Юнікоду, які можна сплутати з іншими символами. Якщо ви вважаєте, що це зроблено навмисно, можете сміливо ігнорувати це попередження. Щоб показати їх, скористайтеся кнопкою Escape.
/** Context menu and "New" popup of the project view. */
import { executeAction, getAction } from "@/platform/actions";
import { formatBinding, hostBinding, isMacHost, shortcutLabel } from "@/platform/keymap";
import { relativePath, useProject } from "@/platform/project";
import { currentDetectedRoots, currentRoots } from "@/platform/projectRoots";
import type { MenuItem } from "@/ui";
import { menuGroupItems, toolWindowService, type MenuGroupContext } from "@/workbench/contributions";
import type { FolderRole } from "@/platform/languageSettings";
import { directoryRole, markDirectory } from "./markDirectory";
import type { ProjectNode } from "./model";
import { packageKindAt } from "./packages";
import {
beginNew,
beginNewPackage,
canPaste,
copyPaths,
copySelection,
delegate,
deleteSelection,
findFileUsages,
folderOf,
openMoveDialog,
openRenameDialog,
paste,
reloadFromDisk,
revealInFinder,
vcsArg,
} from "./operations";
import { isSameOrInside } from "./paths";
/** Keys handled by the project view itself (the default keymap has no entries for them). */
export const LOCAL_KEYS: Record<string, string> = {
"project.newMenu": "Meta+N",
"project.rename": "Shift+F6",
"project.move": "F6",
"project.delete": "Delete",
"project.copy": "Meta+C",
"project.cut": "Meta+X",
"project.paste": "Meta+V",
"project.copyAbsolutePath": "Meta+Shift+C",
"project.open": "F4",
};
export const keyLabel = (id: string) =>
shortcutLabel(id) ?? (LOCAL_KEYS[id] ? formatBinding(hostBinding(LOCAL_KEYS[id])) : undefined);
/**
* New › items for `parent`. `outsideTree` (⌘N in the editor): File asks for the
* name in a dialog, and the inline Directory / Package editors bring up the project view.
*/
export function newMenuItems(parent: string | null, { outsideTree = false } = {}): MenuItem[] {
const root = useProject.getState().project?.root;
const rel = parent && root ? (parent === root ? "" : relativePath(root, parent)) : null;
const pkg = rel === null ? null : packageKindAt(rel, currentRoots(), currentDetectedRoots());
const inline = (start: () => Promise<void>) => () => {
if (outsideTree) showProjectView();
void start();
};
const emptyFile = outsideTree && parent && getAction("templates.new.file");
const dir: MenuItem = {
id: "new.dir",
label: "Directory",
icon: "folder",
run: inline(() => beginNew("dir", parent)),
};
return [
emptyFile
? {
id: "new.file",
label: "File",
icon: "file",
run: () => void executeAction(emptyFile.id, { directory: parent }),
}
: { id: "new.file", label: "File", icon: "file", run: inline(() => beginNew("file", parent)) },
// IDEA offers "Package" instead of "Directory" inside a Java / Kotlin source root.
pkg === "jvm"
? { id: "new.package", label: "Package", icon: "package", run: inline(() => beginNewPackage("jvm", parent)) }
: dir,
...(pkg === "python"
? [
{
id: "new.pythonPackage",
label: "Python Package",
icon: "package",
run: inline(() => beginNewPackage("python", parent)),
},
]
: []),
...contributedNewItems(parent),
];
}
function showProjectView() {
try {
toolWindowService().show("project", false);
} catch {
/* tool window service not available (tests) */
}
}
/** File kinds contributed to the `new` menu group (templates, HTTP Request, Scratch File…). */
function contributedNewItems(parent: string | null): MenuItem[] {
const items = menuGroupItems("new", parent ? { paths: [parent], dirs: [true] } : { paths: [] });
return items.length ? [{ kind: "separator" }, ...items] : [];
}
/** An item bound to another feature's action (resolved by the menu: hidden when missing, disabled when off). */
function act(action: string, arg?: unknown, label?: string): MenuItem | null {
return getAction(action) ? { kind: "action", action, arg, label } : null;
}
/** Runs another feature's action directly (the project view decides availability), with its shortcut. */
function delegated(id: string, arg: unknown, label: string, extra: { icon?: string; shortcut?: string } = {}): MenuItem | null {
if (!getAction(id)) return null;
return { id, label, ...extra, shortcut: extra.shortcut ?? shortcutLabel(id), run: () => void delegate(id, arg) };
}
type Entry = MenuItem | null | false | undefined;
/** A submenu of the present entries, or nothing when none is left (never an empty submenu). */
function submenu(id: string, label: string, entries: Entry[], icon?: string): MenuItem | null {
const items = entries.filter((i): i is MenuItem => !!i);
if (!items.some((i) => i.kind !== "separator" && i.kind !== "header")) return null;
return { id, label, icon, submenu: items };
}
/**
* IDEA's ProjectViewPopupMenu: New / clipboard / find / Refactor / edit /
* Run group / Open In / Local History / Git / Compare / Mark Directory as.
* Items that cannot apply to the selection are hidden (or disabled, like IDEA's
* clipboard group), so the project root and Scratches get a shorter menu.
*/
export function contextMenuItems(nodes: ProjectNode[]): MenuItem[] {
if (nodes.some((n) => n.readOnly)) return libraryMenuItems(nodes.filter((n) => !n.group));
const lead = nodes[0] ?? null;
const single = nodes.length === 1 ? lead : null;
const nonRoot = nodes.filter((n) => !n.root);
const folder = folderOf(lead);
const files = nodes.filter((n) => !n.dir);
const singleFile = single && !single.dir ? single : null;
const project = useProject.getState().project;
// Scratches and other contributed roots live outside the project: no VCS, roots or LSP usages.
const inProject = !!project && nodes.every((n) => isSameOrInside(n.path, project.root));
const items: MenuItem[] = [];
const push = (...list: Entry[]) => list.forEach((i) => i && items.push(i));
push({ id: "new", label: "New", submenu: newMenuItems(folder) }, { kind: "separator" });
// Cut / Copy / Copy Path/Reference… / Paste
push(
{
id: "cut",
label: "Cut",
icon: "cut",
shortcut: keyLabel("project.cut"),
disabled: !nonRoot.length,
run: () => copySelection(true),
},
{
id: "copy",
label: "Copy",
icon: "copy",
shortcut: keyLabel("project.copy"),
disabled: !nonRoot.length,
run: () => copySelection(false),
},
{
id: "copyPath",
label: "Copy Path/Reference…",
submenu: [
{
id: "abs",
label: "Absolute Path",
shortcut: keyLabel("project.copyAbsolutePath"),
run: () => void copyPaths("absolute", nodes),
},
{ id: "name", label: "File Name", run: () => void copyPaths("name", nodes) },
{ id: "rel", label: "Path From Content Root", run: () => void copyPaths("relative", nodes) },
],
},
{
id: "paste",
label: "Paste",
icon: "paste",
shortcut: keyLabel("project.paste"),
disabled: !canPaste(),
run: () => void paste(folder),
},
{ kind: "separator" },
);
// Find Usages / Find in Files… / Replace in Files…
push(
inProject &&
singleFile && {
id: "findUsages",
label: "Find Usages",
shortcut: keyLabel("project.findUsages"),
run: () => void findFileUsages(singleFile.path),
},
!!folder && delegated("find.inPath", { directory: folder }, "Find in Files…", { icon: "search" }),
!!folder && delegated("find.replaceInPath", { directory: folder }, "Replace in Files…"),
{ kind: "separator" },
);
// Refactor ›
push(
submenu("refactor", "Refactor", [
single &&
!single.root && {
id: "rename",
label: "Rename…",
shortcut: keyLabel("project.rename"),
run: () => void openRenameDialog(single),
},
nonRoot.length > 0 && {
id: "move",
label: nonRoot.length === 1 ? (nonRoot[0]!.dir ? "Move Directory…" : "Move File…") : "Move…",
shortcut: keyLabel("project.move"),
run: () => openMoveDialog(nonRoot),
},
nonRoot.length > 0 && { kind: "separator" },
nonRoot.length > 0 && {
id: "safeDelete",
label: "Safe Delete…",
shortcut: formatBinding(isMacHost() ? "Meta+Backspace" : "Delete"),
run: () => void deleteSelection(nodes),
},
]),
{ kind: "separator" },
);
// Reformat Code / Optimize Imports / Delete…
push(
singleFile &&
getAction("code.reformatFile") && {
id: "reformat",
label: "Reformat Code",
shortcut: keyLabel("project.reformatCode"),
run: () => void delegate("code.reformatFile", { path: singleFile.path }),
},
singleFile &&
getAction("code.optimizeImportsFile") && {
id: "optimizeImports",
label: "Optimize Imports",
shortcut: keyLabel("project.optimizeImports"),
run: () => void delegate("code.optimizeImportsFile", { path: singleFile.path }),
},
{
id: "delete",
label: "Delete…",
shortcut: keyLabel("project.delete"),
disabled: !nonRoot.length,
run: () => void deleteSelection(nodes),
},
{ kind: "separator" },
);
// Contributed groups (Run / Debug '…', More Run/Debug ›), each followed by a separator.
push(...menuGroupItems("projectView", menuGroupContext(nodes)));
// Open In ›
push(
submenu("openIn", "Open In", [
{ id: "finder", label: revealLabel(), icon: "external-link", run: () => void revealInFinder(nodes) },
folder && getAction("terminal.openAt")
? { id: "terminal", label: "Terminal", icon: "terminal", run: () => void delegate("terminal.openAt", folder) }
: null,
{ kind: "separator" },
...menuGroupItems("openIn", menuGroupContext(nodes)),
]),
{ kind: "separator" },
);
// Local History › / Reload from Disk
push(
single &&
submenu("history", "Local History", [
delegated("vcs.localHistory", single.path, "Show History"),
delegated("localHistory.putLabel", undefined, "Put Label…"),
]),
{
id: "reload",
label: "Reload from Disk",
icon: "refresh",
shortcut: keyLabel("project.reloadFromDisk"),
run: () => void reloadFromDisk(nodes),
},
{ kind: "separator" },
);
// Git ›
if (inProject && project?.isGit) {
const arg = vcsArg(nodes);
const dirs = nodes.some((n) => n.dir);
push(
submenu(
"git",
"Git",
[
act("vcs.commit", undefined, dirs || nodes.length > 1 ? "Commit…" : "Commit File…"),
act("vcs.addToVcs", arg, "Add"),
{ kind: "separator" },
singleFile && act("vcs.annotate", singleFile.path, "Annotate with Git Blame"),
act("vcs.showDiff", arg, "Show Diff"),
singleFile && act("vcs.compareWithBranch", { path: singleFile.path }, "Compare with Branch…"),
singleFile && act("vcs.showHistory", [singleFile.path], "Show History"),
act("vcs.rollback", arg, "Rollback…"),
{ kind: "separator" },
act("vcs.push", undefined, "Push…"),
act("vcs.update", undefined, "Pull…"),
act("vcs.fetch", undefined, "Fetch"),
{ kind: "separator" },
act("vcs.branches", undefined, "Branches…"),
act("vcs.newBranch", undefined, "New Branch…"),
act("vcs.newTag", undefined, "New Tag…"),
act("vcs.resetHead", undefined, "Reset HEAD…"),
{ kind: "separator" },
act("vcs.stash", undefined, "Stash Changes…"),
act("vcs.unstash", undefined, "Unstash Changes…"),
],
"branch",
),
{ kind: "separator" },
);
}
// IDEA: Compare With… (one file) / Compare Files (two files).
if (files.length === 2 && nodes.length === 2)
push(delegated("diff.compareFiles", files.map((f) => f.path), "Compare Files", { icon: "diff" }));
else if (singleFile)
push(delegated("diff.compareWith", singleFile.path, "Compare With…", { icon: "diff", shortcut: keyLabel("project.compareWith") }));
if (inProject && single?.dir && !single.root && !single.placeholder) {
const current = directoryRole(single.path);
const roles: Array<[FolderRole, string, string]> = [
["source", "Sources Root", "folder-source"],
["test", "Test Sources Root", "folder-test"],
["resources", "Resources Root", "folder-resources"],
["testResources", "Test Resources Root", "folder-test-resources"],
["excluded", "Excluded", "folder-excluded"],
];
push({
id: "markAs",
label: "Mark Directory as",
submenu: [
...roles
.filter(([role]) => role !== current)
.map(([role, label, icon]) => ({ id: role, label, icon, run: () => void markDirectory(single.path, role) })),
...(current
? [
{
id: "unmark",
label:
current === "excluded" ? "Cancel Exclusion" : `Unmark as ${roles.find(([r]) => r === current)![1]}`,
run: () => void markDirectory(single.path, null),
},
]
: []),
],
});
}
return items;
}
/** Library sources (External Libraries) are read-only: copy, find and reveal only. */
function libraryMenuItems(nodes: ProjectNode[]): MenuItem[] {
if (!nodes.length) return [];
const single = nodes.length === 1 ? nodes[0]! : null;
const folder = single?.dir ? single.path : null;
const items: Entry[] = [
{ id: "copy", label: "Copy", icon: "copy", shortcut: keyLabel("project.copy"), run: () => copySelection(false) },
{
id: "copyPath",
label: "Copy Path/Reference…",
submenu: [
{ id: "abs", label: "Absolute Path", shortcut: keyLabel("project.copyAbsolutePath"), run: () => void copyPaths("absolute", nodes) },
{ id: "name", label: "File Name", run: () => void copyPaths("name", nodes) },
],
},
{ kind: "separator" },
!!folder && delegated("find.inPath", { directory: folder }, "Find in Files…", { icon: "search" }),
!!folder && { kind: "separator" },
submenu("openIn", "Open In", [{ id: "finder", label: revealLabel(), icon: "external-link", run: () => void revealInFinder(nodes) }]),
single && !single.dir && delegated("diff.compareWith", single.path, "Compare With…", { icon: "diff", shortcut: keyLabel("project.compareWith") }),
];
return items.filter((i): i is MenuItem => !!i);
}
export function revealLabel() {
const ua = typeof navigator !== "undefined" ? navigator.userAgent : "";
if (/Win/i.test(ua)) return "Explorer";
if (/Linux/i.test(ua)) return "Files";
return "Finder";
}
/** What contributed menu groups see: the selected (non-placeholder) paths. */
export function menuGroupContext(nodes: ProjectNode[]): MenuGroupContext {
const real = nodes.filter((n) => !n.placeholder);
return { paths: real.map((n) => n.path), dirs: real.map((n) => n.dir) };
}