Файли
openidea/src/features/run/ConfigForm.tsx
T
RuyimgByCN e024c13db7 feat(run): Tomcat Server run configuration and application server dialogs (#1963)
Adds a local Tomcat Server run configuration: Server / Deployment / Logs / Startup-Connection tabs, an Application Servers dialog (home, version, base directory, libraries), HTTP/HTTPS/JMX/AJP ports, update and frame-deactivation actions, JRE and VM options, and run/debug with redeploy.

Tomcat HOME and a configured base directory are read-only sources: every launch, build metadata write, sync and update goes to an IDE-owned CATALINA_BASE per configuration, which rejects paths equal to or overlapping either source (symlinks included). With "Deploy applications configured in Tomcat instance" on, the source's webapps and conf/Catalina/<host> descriptors are copied into it; off, the recreated base has none. Controls without a runtime consumer were removed; removing every server profile clears the selection. Verified on macOS (run, port changes, sync/redeploy, servlet breakpoint, source trees unchanged); Windows/Linux not run.

Co-authored-by: RuyimgByCN <[email protected]>
2026-11-08 02:47:33 +08:00

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

Цей файл містить неоднозначні символи Юнікоду
Цей файл містить символи Юнікоду, які можна сплутати з іншими символами. Якщо ви вважаєте, що це зроблено навмисно, можете сміливо ігнорувати це попередження. Щоб показати їх, скористайтеся кнопкою Escape.
/**
* 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<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;
}
/** 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<string[]> {
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 (
<IconButton
icon="folder-open"
size="small"
iconSize={14}
title={title}
onClick={() => 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<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") },
...(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 && (
<div className={styles.notice}>
<T>Detected from the project. Changes are saved as a user configuration that overrides it.</T>
</div>
)}
{!template && (
<div className={clsx(styles.formHeader, config.kind === "tomcat" && styles.formHeaderTomcat)}>
<Field label={config.kind === "tomcat" ? "Name:" : "Name"} error={errors.name}>
<Input
value={config.name}
invalid={!!errors.name}
aria-label="Name"
onChange={(e) => update({ name: e.target.value })}
/>
</Field>
<div className={styles.storeProjectGroup}>
<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>
)}
{config.kind === "tomcat" ? (
<TomcatSettings config={config} onChange={onChange} />
) : (
<>
{!noProcess && (
<Field
label="Run on"
hint={
config.target
? "Debugging is not available on run targets."
: runTargets.length
? undefined
: "Add SSH hosts in Settings › Build, Execution, Deployment › Run Targets."
}
>
<Select
value={config.target ?? ""}
options={targetOptions}
onChange={(target) => onChange(withTarget(config, target))}
aria-label="Run on"
/>
</Field>
)}
<div className={styles.formSection}>
<span className={styles.formSectionTitle}>{tx(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);
const args = parseArgs(e.target.value);
const module =
parseJavaArgs(args).mainClass === parseJavaArgs(config.args).mainClass
? config.module
: undefined;
update({ args, params: {}, module });
}}
/>
</Field>
</>
)}
{!noProcess && (
<>
{!type?.ownsWorkingDirectory && (
<Field label="Working directory" error={errors.cwd}>
<div className={styles.inputWithButton}>
<Input
mono
aria-label="Working directory"
value={config.cwd ?? ""}
placeholder="Project root"
onChange={(e) => update({ cwd: e.target.value.trim() ? e.target.value : null })}
/>
<BrowseButton
directory
title="Browse…"
from={root}
onPick={(path) => update({ cwd: projectRelative(root, path) || null })}
/>
</div>
</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)}
/>
{isTauri() && !isRemoteWindow() && (
<IconButton
icon="folder-open"
size="small"
iconSize={14}
title="Browse for .env Files"
onClick={() =>
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] });
})
}
/>
)}
</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">
<div className={styles.inputWithButton}>
<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 })}
/>
<BrowseButton
title="Browse…"
from={root}
onPick={(path) => update({ stdinFile: projectRelative(root, path) || null })}
/>
</div>
</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={tx`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 root = useProject((s) => s.project?.root ?? null);
const listId = `run-field-${field.key}`;
const [loaded, setLoaded] = useState<ReadonlyArray<{ value: string; label: string; detail?: string }>>([]);
useEffect(() => {
let live = true;
void field
.loadOptions?.()
.then((options) => live && setLoaded(options))
.catch(() => {});
return () => {
live = false;
};
}, [field]);
if (field.control === "output") return <OutputField label={field.label} text={field.value?.(params) ?? ""} />;
if (field.control === "select") {
const options = [...(field.options ?? []), ...loaded];
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={field.rows ?? 4}
onChange={(e) => onChange(e.target.value)}
/>
);
const suggestions = field.suggestions?.(all, params) ?? [];
const input = (
<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)}
/>
);
const browse = typeof field.browse === "function" ? field.browse(params) : field.browse;
return (
<>
{browse ? (
<div className={styles.inputWithButton}>
{input}
<BrowseButton
directory={browse === "directory"}
title="Browse…"
from={/^(\/|[A-Za-z]:[\\/])/.test(value) ? value : root}
onPick={onChange}
/>
</div>
) : (
input
)}
{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>
);
}
/** Candidate rows for a compound's member picker: every config except itself and ones already added. */
export function memberRows(
config: RunConfig,
all: readonly RunConfig[],
members: readonly string[],
query: string,
): Array<{ config: RunConfig; indices: number[] | null }> {
const q = query.trim();
return all
.filter(
(c) =>
c.id !== config.id &&
!members.includes(c.id) &&
!compoundCycle({ ...config, compound: [...members, c.id] }, all),
)
.map((c) => ({ c, indices: q ? fuzzyMatch(c.name, q) : null }))
.filter((x) => !q || x.indices)
.map(({ c, indices }) => ({ config: c, indices }));
}
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 addRef = useRef<HTMLButtonElement>(null);
const [addOpen, setAddOpen] = useState(false);
const hasCandidates = memberRows(config, all, members, "").length > 0;
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>
);
})}
<Button
ref={addRef}
size="small"
variant="ghost"
icon="plus"
aria-haspopup="listbox"
aria-expanded={addOpen}
disabled={!hasCandidates}
onClick={() => setAddOpen(true)}
>
<T>Add configuration…</T>
</Button>
{addOpen && (
<MemberPicker
anchor={addRef}
config={config}
all={all}
members={members}
onAdd={(id) => onChange([...members, id])}
onClose={() => setAddOpen(false)}
/>
)}
</div>
</Field>
);
}
const MEMBER_ROW = 24;
/** "Add configuration" picker for a compound: stays open while configs are picked, so several can be added. */
function MemberPicker({
anchor,
config,
all,
members,
onAdd,
onClose,
}: {
anchor: RefObject<HTMLButtonElement | null>;
config: RunConfig;
all: readonly RunConfig[];
members: readonly string[];
onAdd: (id: string) => void;
onClose: () => void;
}) {
const [query, setQuery] = useState("");
const rows = useMemo(() => memberRows(config, all, members, query), [config, all, members, query]);
const [active, setActive] = useState(0);
const clamped = Math.min(active, rows.length - 1);
const pick = (row: { config: RunConfig } | undefined) => {
if (!row) return;
onAdd(row.config.id);
setQuery("");
setActive(0);
};
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault();
pick(rows[clamped]);
return;
}
const next = listNavigate(e.key, { items: rows, active: clamped, wrap: true });
if (next !== null) {
e.preventDefault();
setActive(next);
}
};
return (
<Popup
open
anchor={anchor}
onClose={onClose}
placement="bottom-start"
aria-label="Add configuration"
className={styles.chooser}
trapFocus
>
<div className={styles.chooserSearch}>
<SearchField
value={query}
onChange={(v) => {
setQuery(v);
setActive(0);
}}
placeholder="Search configurations"
aria-label="Search configurations"
data-autofocus=""
onKeyDown={onKeyDown}
variant="bare"
/>
</div>
<div style={{ height: Math.max(MEMBER_ROW, Math.min(rows.length * MEMBER_ROW, 10 * MEMBER_ROW)) }}>
<List
aria-label="Configurations"
className={styles.chooserList}
items={rows}
getKey={(r) => r.config.id}
activeIndex={clamped}
onActiveChange={setActive}
focusable={false}
hoverSelects
rowHeight={MEMBER_ROW}
onActivate={(r) => pick(r)}
empty={
<span className={styles.chooserMuted}>
<T>Nothing found</T>
</span>
}
renderItem={(r) => (
<span className={styles.chooserRow}>
<Icon name={configIcon(r.config)} size={13} className={styles.chooserIcon} />
<span className={styles.chooserName}>
{r.indices?.length ? <HighlightedText text={r.config.name} indices={r.indices} /> : r.config.name}
</span>
</span>
)}
/>
</div>
</Popup>
);
}
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>
);
}