/** * 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 { parseJavaArgs } from "@/platform/javaLaunch"; import { useEffect, useMemo, useRef, useState, type KeyboardEvent, type ReactNode, type RefObject } from "react"; import clsx from "clsx"; import { isTauri, type BeforeLaunchTask, type RunConfig } from "@/platform/ipc"; import { useWorkflowSettings } from "@/platform/workflowSettings"; import { relativePath, useProject } from "@/platform/project"; import { isRemoteWindow } from "@/platform/remote"; import { useTx, useTxTemplate } from "@/platform/i18n"; import { Button, Checkbox, HighlightedText, Icon, IconButton, Input, List, listNavigate, Menu, Popup, SearchField, Select, T, fuzzyMatch, 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 { withTarget } from "./remote"; import { COMPOUND, formParams, terminalApplies, withParams } from "./types"; import { TomcatSettings } from "./TomcatSettings"; import styles from "./Run.module.css"; /** * Debug adapters with a real launch template (`debugger/launch.ts`, `debugger/java.ts`), * verified against small programs. Others (dart, …) 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: "netcoredbg", label: "netcoredbg (.NET)" }, { id: "java-debug", label: "java-debug (Java)" }, { id: "kotlin-debug-adapter", label: "kotlin-debug-adapter (Kotlin)" }, { 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)" }, { id: "php-debug", label: "php-debug (PHP)" }, ]; /** 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 = new Set(["stdinFile", "envFiles", "outputFile", "beforeLaunch"]); const OPTION_LABELS: Record = { 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 { 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; } /** Picks files or a directory in the local file dialog, opened at `from`; empty when cancelled. */ async function choosePaths(opts: { directory?: boolean; multiple?: boolean; from: string | null }): Promise { const { open } = await import("@tauri-apps/plugin-dialog"); const picked = await open({ directory: !!opts.directory, multiple: !!opts.multiple, defaultPath: opts.from ?? undefined, }); return picked === null ? [] : Array.isArray(picked) ? picked : [picked]; } /** `path` relative to the project root when inside it; the root itself is "". */ function projectRelative(root: string | null, path: string): string { if (!root) return path; return path === root ? "" : relativePath(root, path); } /** Browse button next to a path field; local windows only (the dialog shows this machine's files). */ function BrowseButton({ directory, title, from, onPick, }: { directory?: boolean; title: string; from: string | null; onPick: (path: string) => void; }) { if (!isTauri() || isRemoteWindow()) return null; return ( void choosePaths({ directory, from }).then(([path]) => path !== undefined && onPick(path))} /> ); } 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 runTargets = useWorkflowSettings((s) => s.runTargets); const toolWindows = useContributions((s) => s.toolWindows); const tx = useTx(); const txTemplate = useTxTemplate(); const root = useProject((s) => s.project?.root ?? null); const [shown, setShown] = useState>(() => new Set()); const [argsText, setArgsText] = useState(null); const [envText, setEnvText] = useState(null); const [envFilesText, setEnvFilesText] = useState(null); const [envOpen, setEnvOpen] = useState(false); const [modifyOpen, setModifyOpen] = useState(false); const [lastRequest, setLastRequest] = useState(modifyRequest); const [taskMenu, setTaskMenu] = useState(false); const modifyRef = useRef(null); const taskRef = useRef(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) => 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") }, ...(config.kind === "dotnet" ? [{ label: TASK_LABELS.dotnetBuild, icon: "build-project", run: () => addTask("dotnetBuild") }] : []), { 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 targetOptions: SelectOption[] = [ { value: "", label: "Local machine" }, ...runTargets.map((t) => ({ value: t.id, label: t.name.trim() || t.id })), ]; if (config.target && !runTargets.some((t) => t.id === config.target)) targetOptions.push({ value: config.target, label: txTemplate(`${config.target} (missing)`) }); 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 && (
Detected from the project. Changes are saved as a user configuration that overrides it.
)} {!template && (
update({ name: e.target.value })} />
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)" />
)} {config.kind === "tomcat" ? ( ) : ( <> {!noProcess && ( update({ command: e.target.value, params: {} })} /> { setArgsText(e.target.value); const args = parseArgs(e.target.value); const module = parseJavaArgs(args).mainClass === parseJavaArgs(config.args).mainClass ? config.module : undefined; update({ args, params: {}, module }); }} /> )} {!noProcess && ( <> {!type?.ownsWorkingDirectory && (
update({ cwd: e.target.value.trim() ? e.target.value : null })} /> update({ cwd: projectRelative(root, path) || null })} />
)}
{ setEnvText(e.target.value); update({ env: Object.fromEntries(parseEnv(e.target.value).map((r) => [r.key, r.value])) }); }} onBlur={() => setEnvText(null)} /> setEnvOpen(true)} /> {isTauri() && !isRemoteWindow() && ( void choosePaths({ multiple: true, from: root }).then((picked) => { const current = config.envFiles ?? []; const added = picked.map((p) => projectRelative(root, p)).filter((f) => !current.includes(f)); if (added.length) update({ envFiles: [...current, ...added] }); }) } /> )}
{config.includeSystemEnv === false && ( System environment variables are not passed )}
{isShown("envFiles") && ( { setEnvFilesText(e.target.value); update({ envFiles: e.target.value .split(";") .map((p) => p.trim()) .filter(Boolean), }); }} onBlur={() => setEnvFilesText(null)} /> )} {isShown("stdinFile") && (
update({ stdinFile: e.target.value || null })} /> update({ stdinFile: projectRelative(root, path) || null })} />
)} {isShown("outputFile") && ( update({ outputFile: e.target.value || null })} /> )} setTasks(tasks.map((x, j) => (j === i ? { ...x, target: e.target.value } : x))) } /> ) : ( {txTemplate(taskLabel(t, all))} )} moveTask(i, -1)} /> moveTask(i, 1)} /> setTasks(tasks.filter((_, j) => j !== i))} /> ))} setTaskMenu(false)} /> )} {chips.length > 0 && (
{chips.map((id) => ( {OPTION_LABELS[id]} update(optionPatch(id, false))} /> ))}
)} {!noProcess && !template && (
Command line {commandLine(config) || "—"}
)} {envOpen && ( 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>; all: readonly RunConfig[]; invalid: boolean; onChange: (value: string) => void; }) { const tx = useTx(); const root = useProject((s) => s.project?.root ?? null); const listId = `run-field-${field.key}`; const [loaded, setLoaded] = useState>([]); useEffect(() => { let live = true; void field .loadOptions?.() .then((options) => live && setLoaded(options)) .catch(() => {}); return () => { live = false; }; }, [field]); if (field.control === "output") return ; if (field.control === "select") { const options = [...(field.options ?? []), ...loaded]; if (value && !options.some((o) => o.value === value)) options.push({ value, label: value }); return (