Файли
openidea/src/features/run/ConfigForm.tsx
T
l0ng-ai e150101244 feat(debugger): Remote debug run configurations (Remote JVM, Go, Python, Node.js) (#50)
Root cause: run configurations could only launch a process; there was no "attach to a debugger that is already listening" type, and debugServer only connected to 127.0.0.1.
Fix (IDEA / GoLand / PyCharm): debug-only Remote JVM Debug (attach / listen, live JDWP agent args per JDK version), Go Remote (dlv --headless, substitutePath), Python Remote Debug (debugpy --listen, pathMappings) and Attach to Node.js (inspector, localRoot/remoteRoot); debugServer accepts host:port and [v6]:port; JVM listen mode via a cancellable relay so java-debug can attach.
Verified: ide-dap tests (host:port, relay handshake, cancel), run/debugger vitest, in-app on macOS: Python Remote Debug attached to a debugpy server, paused at app.py:3 with variables. Remote JVM/Go/Node not exercised in-app.

Claude-Session: https://claude.ai/code/session_01VUYsdXA2nkF5v6zsC93dpn
2026-10-27 10:38:38 +08:00

759 рядки
25 KiB
TypeScript

/**
* The right side of Run/Debug Configurations: the type's own fields, the
* common ones, the options added through "Modify options" (⌥M) and the
* Before launch list.
*/
import { useRef, useState, type ReactNode } from "react";
import clsx from "clsx";
import type { BeforeLaunchTask, RunConfig } from "@/platform/ipc";
import { useWorkflowSettings } from "@/platform/workflowSettings";
import { useTx, useTxTemplate } from "@/platform/i18n";
import { Button, Checkbox, Icon, IconButton, Input, Menu, Select, T, type MenuItem, type SelectOption } from "@/ui";
import { useContributions, type RunConfigTypeContribution } from "@/workbench/contributions";
import { TASK_LABELS, taskLabel, type BeforeLaunchKind } from "./beforeLaunch";
import {
commandLine,
compoundCycle,
configIcon,
formatArgs,
formatEnv,
kindLabel,
parseArgs,
parseEnv,
type ConfigErrors,
} from "./configs";
import { EnvDialog } from "./EnvDialog";
import { COMPOUND, formParams, terminalApplies, withParams } from "./types";
import styles from "./Run.module.css";
/**
* Debug adapters with a real launch template (`debugger/launch.ts`, `debugger/java.ts`),
* verified against small programs. Others (netcoredbg, dart, php-debug, …) only got
* generic launch arguments and are left out until they have one; a configuration that
* already names one keeps it.
*/
const ADAPTERS: ReadonlyArray<{ id: string; label: string }> = [
{ id: "java-debug", label: "java-debug (Java)" },
{ id: "debugpy", label: "debugpy (Python)" },
{ id: "js-debug", label: "js-debug (Node.js / TypeScript)" },
{ id: "delve", label: "Delve (Go)" },
{ id: "codelldb", label: "CodeLLDB (Rust, C, C++)" },
{ id: "lldb-dap", label: "lldb-dap (C, C++, Swift)" },
];
/** Options "Modify options" adds to the form: value fields (shown once added) and flags (chips). */
export type OptionId =
| "allowParallel"
| "stdinFile"
| "envFiles"
| "outputFile"
| "showOnStdout"
| "showOnStderr"
| "beforeLaunch"
| "openToolWindow"
| "focusToolWindow"
| "isTest"
| "emulateTerminal";
const VALUE_OPTIONS: ReadonlySet<OptionId> = new Set(["stdinFile", "envFiles", "outputFile", "beforeLaunch"]);
const OPTION_LABELS: Record<OptionId, string> = {
allowParallel: "Allow multiple instances",
stdinFile: "Redirect input",
envFiles: 'Paths to ".env" files',
outputFile: "Save console output to file",
showOnStdout: "Show console when a message is printed to stdout",
showOnStderr: "Show console when a message is printed to stderr",
beforeLaunch: "Add before launch task",
openToolWindow: "Open run/debug tool window when started",
focusToolWindow: "Focus run/debug tool window when started",
isTest: "Show results in the test runner",
emulateTerminal: "Emulate terminal in output console",
};
const OPTION_GROUPS: ReadonlyArray<{ label: string; options: OptionId[]; process: boolean }> = [
{ label: "Operating System", options: ["allowParallel", "stdinFile", "envFiles", "emulateTerminal"], process: true },
{ label: "Logs", options: ["outputFile", "showOnStdout", "showOnStderr"], process: true },
{ label: "Test", options: ["isTest"], process: true },
{ label: "Before Launch", options: ["beforeLaunch", "openToolWindow", "focusToolWindow"], process: false },
];
/** Whether the option currently has a (non-default) value. */
export function optionSet(config: RunConfig, id: OptionId, openByDefault: boolean): boolean {
switch (id) {
case "allowParallel":
return !!config.allowParallel;
case "stdinFile":
return !!config.stdinFile;
case "envFiles":
return !!config.envFiles?.length;
case "outputFile":
return !!config.outputFile;
case "showOnStdout":
return !!config.showOnStdout;
case "showOnStderr":
return !!config.showOnStderr;
case "beforeLaunch":
return !!config.beforeLaunch?.length;
case "openToolWindow":
return config.openToolWindow ?? openByDefault;
case "focusToolWindow":
return !!config.focusToolWindow;
case "isTest":
return config.isTest;
case "emulateTerminal":
return !!config.emulateTerminal;
}
}
/** The patch that turns option `id` on or off. */
export function optionPatch(id: OptionId, on: boolean): Partial<RunConfig> {
switch (id) {
case "allowParallel":
return { allowParallel: on };
case "stdinFile":
return { stdinFile: null };
case "envFiles":
return { envFiles: [] };
case "outputFile":
return { outputFile: null };
case "beforeLaunch":
return { beforeLaunch: [] };
case "showOnStdout":
return { showOnStdout: on };
case "showOnStderr":
return { showOnStderr: on };
case "openToolWindow":
return { openToolWindow: on };
case "focusToolWindow":
return { focusToolWindow: on };
case "isTest":
return on ? { isTest: true, emulateTerminal: false } : { isTest: false };
case "emulateTerminal":
return { emulateTerminal: on };
}
}
export interface ConfigFormProps {
config: RunConfig;
/** Every configuration of the dialog (compound members, before launch targets, name checks). */
all: readonly RunConfig[];
type: RunConfigTypeContribution | undefined;
errors: ConfigErrors;
/** Editing a configuration template (no name, storage or compound members). */
template?: boolean;
onChange: (next: RunConfig) => void;
/** Bumped by ⌥M to open the Modify options menu. */
modifyRequest: number;
}
export function ConfigForm({ config, all, type, errors, template, onChange, modifyRequest }: ConfigFormProps) {
const openByDefault = useWorkflowSettings((s) => s.run.showOnStart);
const externalTools = useWorkflowSettings((s) => s.externalTools);
const toolWindows = useContributions((s) => s.toolWindows);
const tx = useTx();
const txTemplate = useTxTemplate();
const [shown, setShown] = useState<ReadonlySet<OptionId>>(() => new Set());
const [argsText, setArgsText] = useState<string | null>(null);
const [envText, setEnvText] = useState<string | null>(null);
const [envFilesText, setEnvFilesText] = useState<string | null>(null);
const [envOpen, setEnvOpen] = useState(false);
const [modifyOpen, setModifyOpen] = useState(false);
const [lastRequest, setLastRequest] = useState(modifyRequest);
const [taskMenu, setTaskMenu] = useState(false);
const modifyRef = useRef<HTMLButtonElement>(null);
const taskRef = useRef<HTMLButtonElement>(null);
if (modifyRequest !== lastRequest) {
setLastRequest(modifyRequest);
setModifyOpen(true);
}
const compound = config.kind === COMPOUND;
// Compound and debug-only types (Remote JVM Debug) start no process of their own.
const noProcess = compound || !!type?.debugOnly;
const params = compound ? {} : formParams(config, type);
const update = (patch: Partial<RunConfig>) => onChange({ ...config, ...patch });
const setParam = (key: string, value: string) => {
const next = { ...(params ?? {}) };
if (value) next[key] = value;
else delete next[key];
onChange(withParams(config, next, type));
};
const isShown = (id: OptionId) => shown.has(id) || optionSet(config, id, openByDefault);
const toggleOption = (id: OptionId) => {
const on = !isShown(id);
if (VALUE_OPTIONS.has(id)) {
setShown((s) => {
const next = new Set(s);
if (on) next.add(id);
else next.delete(id);
return next;
});
if (!on) update(optionPatch(id, false));
return;
}
update(optionPatch(id, on));
};
const terminal = terminalApplies(config, type);
// A terminal has no separate input file: the two options exclude each other.
const blocked = (id: OptionId) =>
(id === "emulateTerminal" && isShown("stdinFile")) || (id === "stdinFile" && !!config.emulateTerminal);
const menuItems: MenuItem[] = OPTION_GROUPS.filter((g) => !noProcess || !g.process).flatMap((g): MenuItem[] => [
{ kind: "header", label: g.label },
...g.options
.filter(
(id) =>
(!compound || id === "beforeLaunch") && (id !== "emulateTerminal" || terminal || !!config.emulateTerminal),
)
.map((id): MenuItem => ({
label: OPTION_LABELS[id],
checked: isShown(id),
disabled: !isShown(id) && blocked(id),
run: () => toggleOption(id),
})),
]);
const tasks = config.beforeLaunch ?? [];
const setTasks = (next: BeforeLaunchTask[]) => update({ beforeLaunch: next });
const addTask = (kind: BeforeLaunchKind, target = "") => setTasks([...tasks, { kind, target }]);
const runnable = all.filter((c) => c.id !== config.id && c.kind !== COMPOUND);
const taskItems: MenuItem[] = [
{
label: TASK_LABELS.run,
disabled: !runnable.length,
submenu: runnable.map((c) => ({
label: c.name,
verbatim: true,
icon: configIcon(c),
run: () => addTask("run", c.id),
})),
},
{ label: TASK_LABELS.build, icon: "build-project", run: () => addTask("build") },
{
label: TASK_LABELS.tool,
disabled: !externalTools.length,
submenu: externalTools.map((t) => ({ label: t.name, verbatim: true, run: () => addTask("tool", t.id) })),
},
{ label: TASK_LABELS.command, icon: "terminal", run: () => addTask("command") },
{
label: TASK_LABELS.toolWindow,
submenu: [...toolWindows.values()]
.sort((a, b) => a.title.localeCompare(b.title))
.map((w) => ({ label: w.title, icon: w.icon, run: () => addTask("toolWindow", w.id) })),
},
];
const moveTask = (i: number, d: -1 | 1) => {
const j = i + d;
if (j < 0 || j >= tasks.length) return;
const next = [...tasks];
[next[i], next[j]] = [next[j]!, next[i]!];
setTasks(next);
};
const adapterOptions: SelectOption[] = [
{ value: "", label: "None" },
...ADAPTERS.map((a) => ({ value: a.id, label: a.label })),
];
if (config.debugAdapter && !ADAPTERS.some((a) => a.id === config.debugAdapter))
adapterOptions.push({ value: config.debugAdapter, label: txTemplate(`${config.debugAdapter} (no launch template)`) });
const chips = (
[
"allowParallel",
"emulateTerminal",
"showOnStdout",
"showOnStderr",
"openToolWindow",
"focusToolWindow",
"isTest",
] as const
).filter(
(id) => (!noProcess || id === "openToolWindow" || id === "focusToolWindow") && optionSet(config, id, openByDefault),
);
return (
<>
{config.detected && !template && (
<div className={styles.notice}>
<T>Detected from the project. Changes are saved as a user configuration that overrides it.</T>
</div>
)}
{!template && (
<div className={styles.formHeader}>
<Field label="Name" error={errors.name}>
<Input
value={config.name}
invalid={!!errors.name}
aria-label="Name"
onChange={(e) => update({ name: e.target.value })}
/>
</Field>
<Checkbox
checked={!config.local}
onChange={(shared) => update({ local: !shared })}
label="Store as project file"
aria-label="Store as project file (.openidea/run.toml); otherwise in the workspace (.openidea/run.local.toml)"
/>
</div>
)}
<div className={styles.formSection}>
<span className={styles.formSectionTitle}>{tx(type?.label ?? kindLabel(config.kind))}</span>
<button
ref={modifyRef}
type="button"
className={styles.noticeLink}
aria-haspopup="menu"
aria-expanded={modifyOpen}
onClick={() => setModifyOpen((v) => !v)}
>
<T>Modify options</T> <Icon name="chevron-down" size={12} />
</button>
<span className={styles.chooserMuted}>⌥M</span>
<Menu
open={modifyOpen}
anchor={modifyRef}
items={menuItems}
placement="bottom-end"
onClose={() => setModifyOpen(false)}
/>
</div>
{compound ? (
template ? null : (
<CompoundMembers
config={config}
all={all}
error={errors.compound}
onChange={(compound) => update({ compound })}
/>
)
) : params && type ? (
type.fields
.filter((f) => f.visible?.(params) ?? true)
.map((f) => (
<Field
key={f.key}
label={f.label}
error={errors[f.key]}
hint={f.hint}
top={f.control === "textarea" || f.control === "output"}
>
<TypeField
field={f}
value={params[f.key] ?? ""}
params={params}
all={all}
invalid={!!errors[f.key]}
onChange={(v) => setParam(f.key, v)}
/>
</Field>
))
) : (
<>
{type && (
<div className={styles.notice}>
<T>This command line doesn't fit the type's form; it is edited as a command.</T>
</div>
)}
<Field label="Command" error={errors.command}>
<Input
mono
value={config.command}
invalid={!!errors.command}
aria-label="Command"
placeholder="e.g. ./gradlew or cargo"
onChange={(e) => update({ command: e.target.value, params: {} })}
/>
</Field>
<Field label="Arguments">
<Input
mono
aria-label="Arguments"
value={argsText ?? formatArgs(config.args)}
placeholder="Space separated; quote with ' or &quot;"
onChange={(e) => {
setArgsText(e.target.value);
update({ args: parseArgs(e.target.value), params: {} });
}}
/>
</Field>
</>
)}
{!noProcess && (
<>
{!type?.ownsWorkingDirectory && (
<Field label="Working directory" error={errors.cwd}>
<Input
mono
aria-label="Working directory"
value={config.cwd ?? ""}
placeholder="Project root"
onChange={(e) => update({ cwd: e.target.value.trim() ? e.target.value : null })}
/>
</Field>
)}
<Field
label="Environment variables"
error={errors.env}
hint="Separate variables with semicolon: VAR=value; VAR1=value1"
>
<div className={styles.inputWithButton}>
<Input
mono
aria-label="Environment variables"
value={envText ?? formatEnv(config.env)}
invalid={!!errors.env}
onChange={(e) => {
setEnvText(e.target.value);
update({ env: Object.fromEntries(parseEnv(e.target.value).map((r) => [r.key, r.value])) });
}}
onBlur={() => setEnvText(null)}
/>
<IconButton
icon="list"
size="small"
iconSize={14}
title="Edit Environment Variables"
onClick={() => setEnvOpen(true)}
/>
</div>
{config.includeSystemEnv === false && (
<span className={styles.chooserMuted}>
<T>System environment variables are not passed</T>
</span>
)}
</Field>
{isShown("envFiles") && (
<Field label='Paths to ".env" files' hint="Relative to the project root; separate with semicolon">
<Input
mono
aria-label='Paths to ".env" files'
value={envFilesText ?? (config.envFiles ?? []).join(";")}
placeholder=".env"
onChange={(e) => {
setEnvFilesText(e.target.value);
update({
envFiles: e.target.value
.split(";")
.map((p) => p.trim())
.filter(Boolean),
});
}}
onBlur={() => setEnvFilesText(null)}
/>
</Field>
)}
{isShown("stdinFile") && (
<Field label="Redirect input from">
<Input
mono
aria-label="Redirect input from"
value={config.stdinFile ?? ""}
placeholder="File relative to the project root"
onChange={(e) => update({ stdinFile: e.target.value || null })}
/>
</Field>
)}
{isShown("outputFile") && (
<Field label="Save console output to file">
<Input
mono
aria-label="Save console output to file"
value={config.outputFile ?? ""}
placeholder="File relative to the project root"
onChange={(e) => update({ outputFile: e.target.value || null })}
/>
</Field>
)}
<Field label="Debug adapter">
<Select
value={config.debugAdapter ?? ""}
options={adapterOptions}
onChange={(v) => update({ debugAdapter: v || null })}
aria-label="Debug adapter"
/>
</Field>
</>
)}
{isShown("beforeLaunch") && (
<Field label="Before launch" error={errors.beforeLaunch} top>
<div className={styles.taskList} role="list" aria-label={tx("Before launch tasks")}>
{tasks.map((t, i) => (
<div key={i} role="listitem" className={styles.taskRow}>
{t.kind === "command" ? (
<Input
mono
aria-label={txTemplate(`Before launch command ${i + 1}`)}
value={t.target ?? ""}
placeholder="Shell command, e.g. make generate"
onChange={(e) => setTasks(tasks.map((x, j) => (j === i ? { ...x, target: e.target.value } : x)))}
/>
) : (
<span className={styles.taskLabel}>{txTemplate(taskLabel(t, all))}</span>
)}
<IconButton
icon="arrow-up"
size="small"
iconSize={12}
title="Move Up"
disabled={i === 0}
onClick={() => moveTask(i, -1)}
/>
<IconButton
icon="arrow-down"
size="small"
iconSize={12}
title="Move Down"
disabled={i === tasks.length - 1}
onClick={() => moveTask(i, 1)}
/>
<IconButton
icon="minus"
size="small"
iconSize={12}
title="Remove Task"
onClick={() => setTasks(tasks.filter((_, j) => j !== i))}
/>
</div>
))}
<Button
ref={taskRef}
size="small"
variant="ghost"
icon="plus"
aria-haspopup="menu"
onClick={() => setTaskMenu(true)}
>
<T>Add task</T>
</Button>
<Menu open={taskMenu} anchor={taskRef} items={taskItems} onClose={() => setTaskMenu(false)} />
</div>
</Field>
)}
{chips.length > 0 && (
<div className={styles.chips}>
{chips.map((id) => (
<span key={id} className={styles.chip}>
<T>{OPTION_LABELS[id]}</T>
<IconButton
icon="close"
size="small"
iconSize={10}
title={`Remove '${OPTION_LABELS[id]}'`}
onClick={() => update(optionPatch(id, false))}
/>
</span>
))}
</div>
)}
{!noProcess && !template && (
<div className={styles.editPreview}>
<span className={styles.chooserMuted}>
<T>Command line</T>
</span>
<code>{commandLine(config) || "—"}</code>
</div>
)}
{envOpen && (
<EnvDialog
env={config.env}
includeSystemEnv={config.includeSystemEnv !== false}
onClose={() => setEnvOpen(false)}
onOk={(env, includeSystemEnv) => {
setEnvText(null);
update({ env, includeSystemEnv });
}}
/>
)}
</>
);
}
function TypeField({
field,
value,
params,
all,
invalid,
onChange,
}: {
field: RunConfigTypeContribution["fields"][number];
value: string;
params: Readonly<Record<string, string>>;
all: readonly RunConfig[];
invalid: boolean;
onChange: (value: string) => void;
}) {
const tx = useTx();
const listId = `run-field-${field.key}`;
if (field.control === "output") return <OutputField label={field.label} text={field.value?.(params) ?? ""} />;
if (field.control === "select") {
const options = [...(field.options ?? [])];
if (value && !options.some((o) => o.value === value)) options.push({ value, label: value });
return (
<Select
value={value || (options[0]?.value ?? "")}
options={options}
onChange={onChange}
aria-label={field.label}
/>
);
}
if (field.control === "checkbox")
return <Checkbox checked={value === "true"} onChange={(on) => onChange(on ? "true" : "")} label={field.label} />;
if (field.control === "textarea")
return (
<textarea
className={clsx(styles.textarea, invalid && styles.textareaInvalid)}
aria-label={tx(field.label)}
spellCheck={false}
value={value}
placeholder={tx(field.placeholder)}
rows={4}
onChange={(e) => onChange(e.target.value)}
/>
);
const suggestions = field.suggestions?.(all, params) ?? [];
return (
<>
<Input
mono={field.mono}
aria-label={field.label}
value={value}
invalid={invalid}
placeholder={field.placeholder}
list={suggestions.length ? listId : undefined}
onChange={(e) => onChange(e.target.value)}
/>
{suggestions.length > 0 && (
<datalist id={listId}>
{suggestions.map((s) => (
<option key={s} value={s} />
))}
</datalist>
)}
</>
);
}
/** Read-only text the form computes (e.g. the JDWP agent for the remote JVM), with Copy. */
function OutputField({ label, text }: { label: string; text: string }) {
const tx = useTx();
const [copied, setCopied] = useState(false);
const copy = () => {
void navigator.clipboard
?.writeText(text)
.then(() => setCopied(true))
.catch(() => undefined);
};
return (
<div className={styles.inputWithButton}>
<textarea
className={styles.textarea}
aria-label={tx(label)}
readOnly
spellCheck={false}
value={text}
rows={Math.min(4, Math.max(1, Math.ceil(text.length / 72)))}
onFocus={(e) => e.currentTarget.select()}
/>
<IconButton
icon={copied ? "check" : "copy"}
size="small"
iconSize={14}
title={copied ? "Copied" : "Copy"}
onClick={copy}
onBlur={() => setCopied(false)}
/>
</div>
);
}
function CompoundMembers({
config,
all,
error,
onChange,
}: {
config: RunConfig;
all: readonly RunConfig[];
error?: string;
onChange: (ids: string[]) => void;
}) {
const tx = useTx();
const members = config.compound ?? [];
const candidates = all.filter(
(c) =>
c.id !== config.id && !members.includes(c.id) && !compoundCycle({ ...config, compound: [...members, c.id] }, all),
);
return (
<Field label="Configurations" error={error} top>
<div className={styles.taskList} role="list" aria-label={tx("Configurations of the compound")}>
{members.map((id) => {
const c = all.find((x) => x.id === id);
return (
<div key={id} role="listitem" className={styles.taskRow}>
<Icon name={c ? configIcon(c) : "warning"} size={13} className={styles.chooserIcon} />
<span className={styles.taskLabel}>{c?.name ?? id}</span>
<IconButton
icon="minus"
size="small"
iconSize={12}
title="Remove"
onClick={() => onChange(members.filter((m) => m !== id))}
/>
</div>
);
})}
<Select
value={null}
placeholder="Add configuration…"
options={candidates.map((c) => ({ value: c.id, label: c.name }))}
onChange={(id) => onChange([...members, id])}
disabled={!candidates.length}
aria-label="Add configuration"
/>
</div>
</Field>
);
}
export function Field({
label,
error,
hint,
top,
children,
}: {
label: string;
error?: string;
hint?: string;
top?: boolean;
children: ReactNode;
}) {
const txTemplate = useTxTemplate();
return (
<div className={clsx(styles.field, top && styles.fieldTop)}>
<span className={styles.fieldLabel}>{txTemplate(label)}</span>
<div className={styles.fieldControl}>
{children}
{error ? (
<span className={styles.fieldError} role="alert">
{txTemplate(error)}
</span>
) : (
hint && <span className={styles.chooserMuted}>{txTemplate(hint)}</span>
)}
</div>
</div>
);
}