Файли
openidea/src/features/run/contextRun.ts
T
l0ng-aiтаClaude Opus 5.5 57d35ecdb4 fix(run): run PHP files with the interpreter chosen in Settings (#1584)
Running a .php file or a #!/usr/bin/env php script used the PATH php: the file producer resolves tools on PATH and the settings pass only replaces a bare php command. A new sdk_php_effective command returns the configured interpreter (like sdk_python_effective) and the producer prefers it; the not-found text points to the PHP settings page.

Fixes #1555

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 23:48:13 +08:00

389 рядки
14 KiB
TypeScript

/**
* Running the context (IDEA's "Run '<name>'" / "Debug '<name>'" / "Modify Run
* Configuration…"): ⌃⇧R / ⌃⇧D, the editor, project view and editor tab
* context menus and the run gutter all go through `produceContext`, then the
* produced configuration is adopted as the selected temporary configuration.
*/
import { activeEditor } from "@/editor/api";
import { executeAction, getAction } from "@/platform/actions";
import { call, commands, isTauri, type RunConfig } from "@/platform/ipc";
import { useProject } from "@/platform/project";
import { validateProjectStructure } from "@/platform/languageSettings";
import { useBuildModel } from "@/platform/buildModel";
import { isVirtualUri } from "@/platform/virtualDocs";
import { showContextMenu, type MenuItem } from "@/ui";
import {
maybeEditorService,
notifications,
runExecutors,
type ContextMenuId,
type MenuGroupContext,
} from "@/workbench/contributions";
import {
neededTools,
OPTIMISTIC,
produceContext,
produceContextChoices,
produceDirectory,
produceFile,
scriptLanguage,
type ContextInput,
type Produced,
hasRelativeImports,
} from "./producers";
import { runWithExecutor } from "./executors";
import { goModulePath, loadGoModules } from "./goModules";
import { isPythonPackage, loadPythonPackages } from "./pythonPackages";
import { remoteDebugBlocked } from "./remote";
import { toolEnv } from "./runtimes";
import { adoptTemporary, loadConfigs, modifyTemporary, startRun, useRun } from "./store";
import { tx } from "@/platform/i18n";
const CONTENT_LIMIT = 400_000;
async function ensureConfigs() {
if (!useRun.getState().loaded) await loadConfigs();
}
/** The focused editor's file, text and caret line. */
/** Project folders outside its root: Project Structure content roots and Gradle modules (as of the last sync). */
export function projectContentRoots(): string[] {
const own = validateProjectStructure(useProject.getState().settings?.projectStructure).contentRoots;
// The build model keeps the folders of modules outside the project absolute.
const gradle = (useBuildModel.getState().model?.gradle ?? [])
.flatMap((g) => g.projects)
.map((p) => p.dir)
.filter((d) => isAbsolute(d));
return [...new Set([...own, ...gradle])];
}
const isAbsolute = (p: string) => p.startsWith("/") || /^[A-Za-z]:[\\/]/.test(p);
/**
* The project side of every run input (editor, gutter, menus, actions): built in one place
* so no entry point runs a project file as a stray one.
*/
export function projectContext(): Pick<ContextInput, "root" | "configs" | "goModule" | "contentRoots"> {
return {
root: useProject.getState().project?.root ?? null,
configs: useRun.getState().configs,
goModule: goModulePath,
contentRoots: projectContentRoots(),
};
}
export function editorInput(): ContextInput | null {
const path = maybeEditorService()?.activePath() ?? activeEditor()?.ctx.path ?? null;
if (!path || isVirtualUri(path)) return null;
const editor = activeEditor();
const same = editor && editor.ctx.path === path;
const state = same ? editor.view.state : null;
return {
...projectContext(),
path,
content: state ? state.doc.sliceString(0, CONTENT_LIMIT) : undefined,
line: state ? state.doc.lineAt(state.selection.main.head).number - 1 : undefined,
};
}
/** Production with the runtimes looked up (launches, Modify Run Configuration…). */
export async function produceResolved(input: ContextInput, dir = false): Promise<Produced | null> {
const configs = useRun.getState().configs;
if (dir || /\.go$/.test(input.path)) await loadGoModules(input.root, configs);
if (dir) return produceDirectory({ ...input, goModule: goModulePath });
if (/\.py$/.test(input.path) && hasRelativeImports(input.content)) await loadPythonPackages(input.path);
const fresh = { ...input, configs, goModule: goModulePath, pythonPackage: isPythonPackage };
const named = produceContext(fresh, OPTIMISTIC);
if (!named) return null;
// Project configurations (tests, cargo, gradle…) need no runtime lookup.
if (named.config && !named.config.id.startsWith("context:file:")) return named;
const lang = scriptLanguage(input.path, input.content);
const env = await toolEnv(
neededTools(fresh),
input.path,
fresh.root,
lang === "python" || lang === "shebang",
lang === "php" || lang === "shebang",
);
return produceContext(fresh, env);
}
function reportMissing(p: Produced) {
if (!p.missing) return;
notifications().notify({
group: "run",
level: "warning",
title: p.missing.title,
body: p.missing.body,
key: "run-context-missing",
});
}
/** Builds with `build` in the Run window; resolves whether it succeeded. */
async function buildFirst(build: RunConfig): Promise<boolean> {
const session = await startRun(build);
if (!session) return false;
return new Promise((resolve) => {
const check = () => {
const s = useRun.getState().sessions.find((x) => x.id === session.id);
if (!s) return true;
if (s.status === "failed") return (resolve(false), true);
if (s.status === "finished") return (resolve(s.exitCode === 0), true);
return false;
};
if (check()) return;
const unsub = useRun.subscribe(() => {
if (check()) unsub();
});
});
}
/** Runs or debugs a production (adopting it as the selected temporary configuration). */
export async function launchProduced(p: Produced, debug: boolean): Promise<void> {
if (p.missing || !p.config) {
reportMissing(p);
return;
}
const config = adoptTemporary(p.config);
if (!debug) {
await startRun(config);
return;
}
if (remoteDebugBlocked(config)) return;
if (p.debug) {
if (!getAction("debug.startConfig")) {
notifications().notify({
group: "run",
level: "warning",
title: "Debugger is not available",
key: "run-no-debugger",
});
return;
}
if (!(await buildFirst(p.debug.build))) return;
await executeAction("debug.startConfig", { config: p.debug.config, origin: config });
return;
}
await executeAction("run.debugConfig", config);
}
/**
* The debug plan of a compiled single-file configuration (kinds `rustc` / `cc`)
* picked from the Run widget: re-produced from the source file in its id.
*/
export async function compiledDebugPlan(config: RunConfig): Promise<Produced | null> {
if (config.kind !== "rustc" && config.kind !== "cc") return null;
const m = /^context:file:(.+?)(?:#(.+))?$/.exec(config.id);
if (!m) return null;
const root = useProject.getState().project?.root ?? null;
const path = m[1]!.startsWith("/") || !root ? m[1]! : `${root}/${m[1]}`;
let content: string | undefined;
try {
content = isTauri() ? await call(commands.fsReadText(path)) : undefined;
} catch {
return null;
}
const input: ContextInput = { ...projectContext(), root, path, content };
const env = await toolEnv(neededTools(input), path, root, false);
if (m[2]) {
// A test: find the target by name again.
const lines = (content ?? "").split("\n");
const line = lines.findIndex((l) =>
new RegExp(`\\bfn\\s+${m[2]!.split("::").pop()}\\b|\\bmod\\s+${m[2]}\\b`).test(l),
);
return line >= 0 ? produceContext({ ...input, line }, env) : null;
}
return produceFile(input, env);
}
/** ⌃⇧R / ⌃⇧D: the innermost context at the caret of the active editor. */
/** Reads the file when the context has no text yet (`#!` lines, `package main`, `fn main`…). */
async function withContent<T extends ContextInput & { dir?: boolean }>(input: T): Promise<T> {
if (input.content !== undefined || input.dir || !isTauri() || isVirtualUri(input.path)) return input;
try {
return { ...input, content: (await call(commands.fsReadText(input.path))).slice(0, CONTENT_LIMIT) };
} catch {
return input;
}
}
export async function runContextAction(debug: boolean, arg?: unknown): Promise<boolean> {
await ensureConfigs();
const target = await withArgContent(arg);
const input = target ?? editorInput();
if (!input) return false;
const dir = target?.dir ?? false;
const choices = dir
? []
: produceContextChoices({ ...input, configs: useRun.getState().configs, goModule: goModulePath }, OPTIMISTIC);
if (choices.length > 1) {
// Several test configurations match equally well: like IDEA, let the user choose.
showContextMenu(chooserPoint(target === null), choiceItems(choices, debug));
return true;
}
const p = await produceResolved(input, dir);
if (!p) return false;
await launchProduced(p, debug);
return true;
}
/** Menu entries for each of several productions: "Run '<name>'" with the configuration it derives from. */
export function choiceItems(choices: readonly Produced[], debug: boolean): MenuItem[] {
return [
{ kind: "header", label: debug ? "Choose configuration to debug" : "Choose configuration to run" },
...choices.map((p): MenuItem => ({
label: debug ? tx`Debug '${p.name}'` : tx`Run '${p.name}'`,
verbatim: true,
icon: debug ? "debug" : "run",
shortcut: p.detail,
run: () => void launchProduced(p, debug),
})),
];
}
/** Below the caret for the editor context, else the middle of the window. */
function chooserPoint(fromEditor: boolean): { x: number; y: number } {
const editor = fromEditor ? activeEditor() : null;
const caret = editor?.view.coordsAtPos(editor.view.state.selection.main.head);
if (caret) return { x: caret.left, y: caret.bottom + 2 };
return { x: Math.round(window.innerWidth / 2 - 120), y: Math.round(window.innerHeight / 3) };
}
/** Runs the context with a run executor (Run with Coverage, Profile…). False when there is nothing to run. */
export async function executeContext(executor: string, arg?: unknown): Promise<boolean> {
await ensureConfigs();
const target = await withArgContent(arg);
const input = target ?? editorInput();
if (!input) return false;
const p = await produceResolved(input, target?.dir ?? false);
if (!p) return false;
if (p.missing || !p.config) {
reportMissing(p);
return true;
}
await runWithExecutor(adoptTemporary(p.config), executor);
return true;
}
/** "Run '<name>' with Coverage", "Profile '<name>'"… for a context the executors can run. */
export function executorItems(config: RunConfig | null | undefined, name: string, context: ContextArg): MenuItem[] {
if (!config) return [];
return runExecutors()
.filter((e) => e.supports(config))
.map((e) => ({
kind: "action",
action: "run.withExecutor",
arg: { executor: e.id, context },
label: e.label(name),
icon: e.icon,
}));
}
export interface ContextArg {
path: string;
line?: number;
dir?: boolean;
content?: string;
}
async function withArgContent(arg: unknown) {
const target = contextArg(arg);
return target ? withContent(target) : null;
}
function contextArg(arg: unknown): (ContextInput & { dir?: boolean }) | null {
if (!arg || typeof arg !== "object" || typeof (arg as ContextArg).path !== "string") return null;
const a = arg as ContextArg;
const editor = activeEditor();
const state = editor && editor.ctx.path === a.path ? editor.view.state : null;
return {
...projectContext(),
path: a.path,
dir: a.dir,
content: a.content ?? state?.doc.sliceString(0, CONTENT_LIMIT) ?? peekContent(a.path),
line: a.line,
};
}
/** "Modify Run Configuration…": the would-be configuration in Edit Configurations. */
export async function modifyContext(arg?: unknown): Promise<void> {
await ensureConfigs();
const target = await withArgContent(arg);
const input = target ?? editorInput();
if (!input) return;
const p = await produceResolved(input, target?.dir ?? false);
if (!p) return;
if (!p.config) {
reportMissing(p);
return;
}
modifyTemporary(p.config);
}
// ---------------------------------------------------------------------------
// Context menus
/** File text read for the project view menu (files not open in an editor). */
const peeked = new Map<string, { at: number; text: string }>();
const PEEK_TTL = 10_000;
function peekContent(path: string): string | undefined {
const hit = peeked.get(path);
return hit && Date.now() - hit.at < PEEK_TTL ? hit.text : undefined;
}
/** Whether the file's runnability depends on its text (`package main`, `fn main`, `#!`…). */
function needsContent(path: string): boolean {
const name = path.slice(path.lastIndexOf("/") + 1);
return /\.(go|rs|java|kt|c|cpp|cc|cxx)$/.test(name) || !name.includes(".");
}
export async function prepareMenu(_menu: ContextMenuId, ctx: MenuGroupContext): Promise<void> {
if (ctx.paths.length !== 1) return;
const path = ctx.paths[0]!;
if (ctx.dirs?.[0] || /\.go$/.test(path))
await loadGoModules(useProject.getState().project?.root ?? null, useRun.getState().configs).catch(() => {});
if (ctx.text !== undefined || ctx.dirs?.[0]) return;
if (!needsContent(path) || peekContent(path) !== undefined || !isTauri() || isVirtualUri(path)) return;
const text = await call(commands.fsReadText(path));
peeked.set(path, { at: Date.now(), text: text.slice(0, CONTENT_LIMIT) });
}
/** The run group of a context menu: Run / Debug '<name>', Modify Run Configuration…. */
export function contextMenuItems(menu: ContextMenuId, ctx: MenuGroupContext): MenuItem[] {
if (ctx.paths.length !== 1) return [];
const path = ctx.paths[0]!;
if (isVirtualUri(path)) return [];
const root = useProject.getState().project?.root ?? null;
if (!root) return [];
const dir = !!ctx.dirs?.[0];
const content = ctx.text ?? peekContent(path);
const line = menu === "editor" ? ctx.line : undefined;
const input: ContextInput = {
...projectContext(),
root,
path,
content: content?.slice(0, CONTENT_LIMIT),
line,
};
const p = dir ? produceDirectory(input) : produceContext(input, OPTIMISTIC);
if (!p) return [];
const arg: ContextArg = { path, line, dir, content: dir ? undefined : input.content };
return [
{ kind: "action", action: "run.runContext", arg, label: tx`Run '${p.name}'`, icon: "run" },
// Like IDEA, Debug only where a debugger can run it.
...(p.debug || p.config?.debugAdapter || p.missing
? ([
{ kind: "action", action: "run.debugContext", arg, label: tx`Debug '${p.name}'`, icon: "debug" },
] as MenuItem[])
: []),
{
label: "More Run/Debug",
submenu: [
...executorItems(p.config, p.name, arg),
{ kind: "action", action: "run.modifyContext", arg, label: "Modify Run Configuration…", icon: "edit" },
],
},
];
}