mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-06 16:08:05 +00:00
Detected run configurations (Spring Boot and Java applications, npm scripts, Maven/Gradle tasks, pytest, Cargo, Go, …) could not be removed: the − button was disabled because a removed detection would be found again. As in IntelliJ IDEA, they can now be removed and stay removed: the removed id is stored in the workspace-local run.local.toml (suppressed-ids) and detection skips it; saving a configuration with that id again lifts the suppression. Applies to the Run/Debug Configurations − button, the Delete key in its tree, and Delete in the run configuration chooser. Removing a saved override still restores the detected configuration; temporary configurations are unchanged. Refs #271
651 lines
25 KiB
TypeScript
651 lines
25 KiB
TypeScript
import { useEffect, useMemo, useRef, useState, type DragEvent, type KeyboardEvent } from "react";
|
|
import clsx from "clsx";
|
|
import { executeAction } from "@/platform/actions";
|
|
import { call, commands, isTauri, type RunConfig } from "@/platform/ipc";
|
|
import { letterOf } from "@/platform/keymap";
|
|
import { useWorkflowSettings } from "@/platform/workflowSettings";
|
|
import { Button, Dialog, Icon, IconButton, Input, T } from "@/ui";
|
|
import {
|
|
notifications,
|
|
useRunConfigTypes,
|
|
runConfigTypes,
|
|
type RunConfigTypeContribution,
|
|
} from "@/workbench/contributions";
|
|
import { AddConfigPopup } from "./AddConfigPopup";
|
|
import { ConfigForm, Field } from "./ConfigForm";
|
|
import {
|
|
commandLine,
|
|
configIcon,
|
|
duplicateConfig,
|
|
groupConfigs,
|
|
hasErrors,
|
|
kindLabel,
|
|
removeConfig,
|
|
upsertConfig,
|
|
userConfigs,
|
|
validateConfig,
|
|
type ConfigErrors,
|
|
} from "./configs";
|
|
import { targetName } from "./remote";
|
|
import { saveUserConfigs, selectConfig, useRun } from "./store";
|
|
import { blankTemplate, COMPOUND, missingParams, newConfigOfType, templateId, typeFor } from "./types";
|
|
import styles from "./Run.module.css";
|
|
import { tx } from "@/platform/i18n";
|
|
|
|
export function EditConfigsOverlay() {
|
|
const open = useRun((s) => s.editorOpen);
|
|
if (!open) return null;
|
|
return <EditConfigsDialog />;
|
|
}
|
|
|
|
type Selection =
|
|
| { type: "config"; id: string }
|
|
| { type: "folder"; kind: string; name: string }
|
|
| { type: "template"; kind: string };
|
|
|
|
export type TreeRow =
|
|
| { type: "group"; key: string; kind: string; label: string; icon: string }
|
|
| { type: "folder"; key: string; kind: string; name: string }
|
|
| { type: "config"; key: string; config: RunConfig; depth: 1 | 2 };
|
|
|
|
/** Sidebar rows: type groups, their folders (first), then loose configurations; collapsed nodes hide their children. */
|
|
export function treeRows(
|
|
configs: readonly RunConfig[],
|
|
extraFolders: ReadonlyArray<{ kind: string; name: string }> = [],
|
|
collapsed: ReadonlySet<string> = new Set(),
|
|
): TreeRow[] {
|
|
const rows: TreeRow[] = [];
|
|
const groups = groupConfigs(configs);
|
|
for (const f of extraFolders)
|
|
if (!groups.some((g) => g.kind === f.kind)) groups.push({ kind: f.kind, label: kindLabel(f.kind), configs: [] });
|
|
for (const g of groups) {
|
|
const groupKey = `g:${g.kind}`;
|
|
rows.push({
|
|
type: "group",
|
|
key: groupKey,
|
|
kind: g.kind,
|
|
label: g.label,
|
|
icon: configIcon({ kind: g.kind, isTest: false }),
|
|
});
|
|
if (collapsed.has(groupKey)) continue;
|
|
const folders: string[] = [];
|
|
for (const c of g.configs) if (c.folder && !folders.includes(c.folder)) folders.push(c.folder);
|
|
for (const f of extraFolders) if (f.kind === g.kind && !folders.includes(f.name)) folders.push(f.name);
|
|
for (const name of folders) {
|
|
const folderKey = `f:${g.kind}:${name}`;
|
|
rows.push({ type: "folder", key: folderKey, kind: g.kind, name });
|
|
if (collapsed.has(folderKey)) continue;
|
|
for (const c of g.configs)
|
|
if (c.folder === name) rows.push({ type: "config", key: `c:${c.id}`, config: c, depth: 2 });
|
|
}
|
|
for (const c of g.configs) if (!c.folder) rows.push({ type: "config", key: `c:${c.id}`, config: c, depth: 1 });
|
|
}
|
|
return rows;
|
|
}
|
|
|
|
/** Moves `id` before / after its neighbour among the configurations of the same type and folder. */
|
|
export function moveConfig(configs: readonly RunConfig[], id: string, delta: -1 | 1): RunConfig[] {
|
|
const current = configs.find((c) => c.id === id);
|
|
if (!current) return [...configs];
|
|
const siblings = configs.filter((c) => c.kind === current.kind && (c.folder ?? "") === (current.folder ?? ""));
|
|
const at = siblings.indexOf(current);
|
|
const other = siblings[at + delta];
|
|
if (!other) return [...configs];
|
|
const out = [...configs];
|
|
const i = out.indexOf(current);
|
|
const j = out.indexOf(other);
|
|
[out[i], out[j]] = [out[j]!, out[i]!];
|
|
return out;
|
|
}
|
|
|
|
function uniqueFolder(
|
|
kind: string,
|
|
configs: readonly RunConfig[],
|
|
extra: ReadonlyArray<{ kind: string; name: string }>,
|
|
): string {
|
|
const taken = new Set([
|
|
...configs.filter((c) => c.kind === kind).map((c) => c.folder),
|
|
...extra.filter((f) => f.kind === kind).map((f) => f.name),
|
|
]);
|
|
if (!taken.has("New Folder")) return "New Folder";
|
|
for (let i = 2; ; i++) if (!taken.has(`New Folder (${i})`)) return `New Folder (${i})`;
|
|
}
|
|
|
|
const isMac = () => typeof navigator !== "undefined" && /mac/i.test(navigator.platform);
|
|
|
|
function EditConfigsDialog() {
|
|
const initialConfigs = useRun((s) => s.configs);
|
|
const temporary = useRun((s) => s.temporary);
|
|
const target = useRun((s) => s.editTarget ?? s.selectedId);
|
|
const types = useRunConfigTypes((s) => s.types);
|
|
const [draft, setDraft] = useState<RunConfig[]>(() => initialConfigs.map((c) => structuredClone(c)));
|
|
const [selection, setSelection] = useState<Selection | null>(() => {
|
|
const id = initialConfigs.some((c) => c.id === target) ? target : initialConfigs[0]?.id;
|
|
return id ? { type: "config", id } : null;
|
|
});
|
|
const [mode, setMode] = useState<"configs" | "templates">("configs");
|
|
const [templates, setTemplates] = useState<RunConfig[] | null>(() => (isTauri() ? null : []));
|
|
const [templatesDirty, setTemplatesDirty] = useState(false);
|
|
const [extraFolders, setExtraFolders] = useState<Array<{ kind: string; name: string }>>([]);
|
|
const [collapsed, setCollapsed] = useState<ReadonlySet<string>>(() => new Set());
|
|
// "Modify Run Configuration…": the would-be configuration is saved on OK even when unedited.
|
|
const [dirty, setDirty] = useState(() => useRun.getState().editDirty);
|
|
const [removedDetectedIds, setRemovedDetectedIds] = useState<string[]>([]);
|
|
const [saving, setSaving] = useState(false);
|
|
const runTargets = useWorkflowSettings((s) => s.runTargets);
|
|
const [adding, setAdding] = useState<{ x: number; y: number } | null>(null);
|
|
const [modifyRequest, setModifyRequest] = useState(0);
|
|
const addRef = useRef<HTMLButtonElement>(null);
|
|
const dragged = useRef<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
if (!isTauri()) return;
|
|
let live = true;
|
|
call(commands.runTemplates())
|
|
.then((t) => live && setTemplates(t))
|
|
.catch(() => live && setTemplates([]));
|
|
return () => {
|
|
live = false;
|
|
};
|
|
}, []);
|
|
|
|
const close = () => useRun.setState({ editorOpen: false, editTarget: null });
|
|
const current = selection?.type === "config" ? (draft.find((c) => c.id === selection.id) ?? null) : null;
|
|
const templateFor = (kind: string): RunConfig | null => {
|
|
const t = typeFor(kind);
|
|
return templates?.find((x) => x.kind === kind) ?? (t ? blankTemplate(t) : null);
|
|
};
|
|
const currentTemplate = selection?.type === "template" ? templateFor(selection.kind) : null;
|
|
|
|
const errorsById = useMemo(() => {
|
|
const out: Record<string, ConfigErrors> = {};
|
|
for (const c of draft) {
|
|
if (c.detected) continue;
|
|
const e: ConfigErrors = { ...validateConfig(c, draft), ...missingParams(c) };
|
|
if (Object.keys(missingParams(c)).length) delete e.command;
|
|
if (hasErrors(e)) out[c.id] = e;
|
|
}
|
|
return out;
|
|
}, [draft]);
|
|
const invalid = Object.keys(errorsById).length > 0;
|
|
const errors = (current && errorsById[current.id]) ?? {};
|
|
|
|
const replace = (next: RunConfig) => {
|
|
setDraft((d) => upsertConfig(d, next));
|
|
setDirty(true);
|
|
};
|
|
|
|
const add = (type: RunConfigTypeContribution) => {
|
|
const folder = selection?.type === "folder" && selection.kind === type.id ? selection.name : null;
|
|
const c = newConfigOfType(type, draft, templateFor(type.id), folder);
|
|
setDraft((d) => [...d, c]);
|
|
setSelection({ type: "config", id: c.id });
|
|
setMode("configs");
|
|
setDirty(true);
|
|
};
|
|
|
|
const openAdd = (anchor?: HTMLElement | null) => {
|
|
const r = (anchor ?? addRef.current)?.getBoundingClientRect();
|
|
setAdding(r ? { x: r.left, y: r.bottom + 2 } : { x: window.innerWidth / 2 - 120, y: 120 });
|
|
};
|
|
|
|
const duplicate = () => {
|
|
if (!current) return;
|
|
const c = duplicateConfig(current, draft);
|
|
setDraft((d) => [...d, c]);
|
|
setSelection({ type: "config", id: c.id });
|
|
setDirty(true);
|
|
};
|
|
|
|
const remove = () => {
|
|
if (selection?.type === "folder") {
|
|
// Removing a folder keeps its configurations (they move up a level), as in IDEA.
|
|
setDraft((d) =>
|
|
d.map((c) =>
|
|
c.kind === selection.kind && c.folder === selection.name ? { ...c, folder: null, detected: false } : c,
|
|
),
|
|
);
|
|
setExtraFolders((f) => f.filter((x) => !(x.kind === selection.kind && x.name === selection.name)));
|
|
setSelection(null);
|
|
setDirty(true);
|
|
return;
|
|
}
|
|
if (!current) return;
|
|
if (current.detected && !temporary.includes(current.id)) {
|
|
setRemovedDetectedIds((ids) => [...ids, current.id]);
|
|
}
|
|
const index = draft.findIndex((c) => c.id === current.id);
|
|
const next = removeConfig(draft, current.id);
|
|
setDraft(next);
|
|
const neighbour = next[Math.min(index, next.length - 1)];
|
|
setSelection(neighbour ? { type: "config", id: neighbour.id } : null);
|
|
setDirty(true);
|
|
};
|
|
|
|
const newFolder = () => {
|
|
const kind = current?.kind ?? (selection?.type === "folder" ? selection.kind : null);
|
|
if (!kind) return;
|
|
const name = uniqueFolder(kind, draft, extraFolders);
|
|
setExtraFolders((f) => [...f, { kind, name }]);
|
|
// IDEA moves the selected configuration into the new folder.
|
|
if (current) replace({ ...current, folder: name });
|
|
setSelection({ type: "folder", kind, name });
|
|
};
|
|
|
|
const renameFolder = (kind: string, from: string, to: string) => {
|
|
setDraft((d) =>
|
|
d.map((c) => (c.kind === kind && c.folder === from ? { ...c, folder: to || null, detected: false } : c)),
|
|
);
|
|
setExtraFolders((f) => f.map((x) => (x.kind === kind && x.name === from ? { kind, name: to } : x)));
|
|
setSelection({ type: "folder", kind, name: to });
|
|
setDirty(true);
|
|
};
|
|
|
|
const move = (delta: -1 | 1) => {
|
|
if (!current) return;
|
|
setDraft((d) => moveConfig(d, current.id, delta));
|
|
setDirty(true);
|
|
};
|
|
|
|
const updateTemplate = (next: RunConfig) => {
|
|
setTemplates((list) => [
|
|
...(list ?? []).filter((t) => t.kind !== next.kind),
|
|
{ ...next, id: templateId(next.kind), detected: false },
|
|
]);
|
|
setTemplatesDirty(true);
|
|
};
|
|
|
|
const save = async (andClose: boolean): Promise<boolean> => {
|
|
if (invalid) {
|
|
const firstBad = Object.keys(errorsById)[0];
|
|
if (firstBad) {
|
|
setMode("configs");
|
|
setSelection({ type: "config", id: firstBad });
|
|
}
|
|
return false;
|
|
}
|
|
setSaving(true);
|
|
try {
|
|
if (dirty) await saveUserConfigs(userConfigs(draft), removedDetectedIds);
|
|
if (templatesDirty && templates) await call(commands.runSaveTemplates(templates));
|
|
setRemovedDetectedIds([]);
|
|
setDirty(false);
|
|
setTemplatesDirty(false);
|
|
if (andClose) {
|
|
if (current) selectConfig(current.id);
|
|
close();
|
|
}
|
|
return true;
|
|
} catch (e) {
|
|
notifications().notify({
|
|
group: "run",
|
|
level: "error",
|
|
title: "Cannot save run configurations",
|
|
body: e instanceof Error ? e.message : String(e),
|
|
});
|
|
return false;
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
/** The dialog's Run / Debug: apply, close and launch the selected configuration. */
|
|
const launch = async (debug: boolean) => {
|
|
if (!current || !(await save(true))) return;
|
|
void executeAction(debug ? "run.debugConfig" : "run.runConfig", current.id);
|
|
};
|
|
|
|
const rows = treeRows(draft, extraFolders, collapsed);
|
|
const toggle = (key: string) =>
|
|
setCollapsed((s) => {
|
|
const next = new Set(s);
|
|
if (next.has(key)) next.delete(key);
|
|
else next.add(key);
|
|
return next;
|
|
});
|
|
|
|
const onDrop = (e: DragEvent, kind: string, folder: string | null) => {
|
|
const id = dragged.current;
|
|
dragged.current = null;
|
|
const c = draft.find((x) => x.id === id);
|
|
if (!c || c.kind !== kind) return;
|
|
e.preventDefault();
|
|
replace({ ...c, folder });
|
|
};
|
|
|
|
// IDEA: ⌘N / Alt+Insert adds a configuration, ⌥M opens "Modify options".
|
|
const onKeyDown = (e: KeyboardEvent) => {
|
|
const add =
|
|
(letterOf(e) === "N" && !e.altKey && (isMac() ? e.metaKey || e.ctrlKey : e.ctrlKey)) ||
|
|
(e.altKey && e.key === "Insert");
|
|
if (add && mode === "configs") {
|
|
e.preventDefault();
|
|
openAdd();
|
|
return;
|
|
}
|
|
if (e.altKey && !e.metaKey && !e.ctrlKey && e.code === "KeyM" && (current || currentTemplate)) {
|
|
e.preventDefault();
|
|
setModifyRequest((n) => n + 1);
|
|
}
|
|
};
|
|
|
|
const templateTypes = runConfigTypes(types).filter((t) => t.id !== COMPOUND);
|
|
const canRemove = selection?.type === "folder" || !!current;
|
|
|
|
return (
|
|
<Dialog
|
|
open
|
|
onClose={close}
|
|
title="Run/Debug Configurations"
|
|
width={860}
|
|
className={styles.editDialog}
|
|
footerStart={
|
|
<span className={styles.footerRun}>
|
|
<Button
|
|
icon="run"
|
|
disabled={!current || saving || invalid || mode !== "configs"}
|
|
onClick={() => void launch(false)}
|
|
>
|
|
<T>Run</T>
|
|
</Button>
|
|
<Button
|
|
icon="debug"
|
|
disabled={!current || saving || invalid || mode !== "configs"}
|
|
onClick={() => void launch(true)}
|
|
>
|
|
<T>Debug</T>
|
|
</Button>
|
|
{invalid && (
|
|
<span className={styles.footerError}>
|
|
<Icon name="error" size={13} /> <T>Fix the highlighted fields</T>
|
|
</span>
|
|
)}
|
|
</span>
|
|
}
|
|
buttons={[
|
|
{ label: "OK", role: "default", disabled: saving || invalid, onClick: () => void save(true) },
|
|
{ label: "Cancel", role: "cancel" },
|
|
{ label: "Apply", disabled: !(dirty || templatesDirty) || saving || invalid, onClick: () => void save(false) },
|
|
]}
|
|
>
|
|
<div className={styles.editBody} onKeyDown={onKeyDown}>
|
|
<div className={styles.editSidebar}>
|
|
{mode === "configs" ? (
|
|
<>
|
|
<div className={styles.editToolbar} role="toolbar" aria-label={tx("Configurations")}>
|
|
<IconButton
|
|
ref={addRef}
|
|
icon="plus"
|
|
size="small"
|
|
iconSize={14}
|
|
title="Add New Configuration"
|
|
onClick={() => openAdd()}
|
|
/>
|
|
<IconButton
|
|
icon="minus"
|
|
size="small"
|
|
iconSize={14}
|
|
title={
|
|
selection?.type === "folder"
|
|
? "Remove Folder"
|
|
: current && !current.detected && !current.id.startsWith("user:")
|
|
? "Remove Override (restore detected)"
|
|
: "Remove Configuration"
|
|
}
|
|
disabled={!canRemove}
|
|
onClick={remove}
|
|
/>
|
|
<IconButton
|
|
icon="copy"
|
|
size="small"
|
|
iconSize={13}
|
|
title="Copy Configuration"
|
|
disabled={!current}
|
|
onClick={duplicate}
|
|
/>
|
|
<IconButton
|
|
icon="folder"
|
|
size="small"
|
|
iconSize={13}
|
|
title="Create New Folder"
|
|
disabled={!current && selection?.type !== "folder"}
|
|
onClick={newFolder}
|
|
/>
|
|
<IconButton
|
|
icon="arrow-up"
|
|
size="small"
|
|
iconSize={13}
|
|
title="Move Up"
|
|
disabled={!current}
|
|
onClick={() => move(-1)}
|
|
/>
|
|
<IconButton
|
|
icon="arrow-down"
|
|
size="small"
|
|
iconSize={13}
|
|
title="Move Down"
|
|
disabled={!current}
|
|
onClick={() => move(1)}
|
|
/>
|
|
</div>
|
|
<div
|
|
className={styles.editList}
|
|
role="tree"
|
|
tabIndex={0}
|
|
aria-label={tx("Run configurations")}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Delete" && !e.altKey && !e.ctrlKey && !e.metaKey && !e.shiftKey && canRemove) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
remove();
|
|
}
|
|
}}
|
|
>
|
|
{rows.map((r) => {
|
|
if (r.type === "group")
|
|
return (
|
|
<button
|
|
key={r.key}
|
|
type="button"
|
|
role="treeitem"
|
|
aria-expanded={!collapsed.has(r.key)}
|
|
className={styles.editItem}
|
|
style={{ paddingLeft: 6 }}
|
|
onClick={() => toggle(r.key)}
|
|
onDragOver={(e) => e.preventDefault()}
|
|
onDrop={(e) => onDrop(e, r.kind, null)}
|
|
>
|
|
<Icon name={collapsed.has(r.key) ? "chevron-right" : "chevron-down"} size={12} />
|
|
<Icon name={r.icon} size={13} className={styles.chooserIcon} />
|
|
<span className={clsx(styles.chooserName, styles.editGroupName)}>{r.label}</span>
|
|
</button>
|
|
);
|
|
if (r.type === "folder") {
|
|
const sel = selection?.type === "folder" && selection.kind === r.kind && selection.name === r.name;
|
|
return (
|
|
<button
|
|
key={r.key}
|
|
type="button"
|
|
role="treeitem"
|
|
aria-selected={sel}
|
|
aria-expanded={!collapsed.has(r.key)}
|
|
className={clsx(styles.editItem, sel && styles.editItemSelected)}
|
|
style={{ paddingLeft: 22 }}
|
|
onClick={() => setSelection({ type: "folder", kind: r.kind, name: r.name })}
|
|
onDoubleClick={() => toggle(r.key)}
|
|
onDragOver={(e) => e.preventDefault()}
|
|
onDrop={(e) => onDrop(e, r.kind, r.name)}
|
|
>
|
|
<Icon name={collapsed.has(r.key) ? "chevron-right" : "chevron-down"} size={12} />
|
|
<Icon name="folder" size={13} className={styles.chooserIcon} />
|
|
<span className={styles.chooserName}>{r.name}</span>
|
|
</button>
|
|
);
|
|
}
|
|
const c = r.config;
|
|
return (
|
|
<button
|
|
key={r.key}
|
|
type="button"
|
|
role="treeitem"
|
|
aria-selected={current?.id === c.id}
|
|
className={clsx(styles.editItem, current?.id === c.id && styles.editItemSelected)}
|
|
style={{ paddingLeft: r.depth === 2 ? 50 : 34 }}
|
|
onClick={() => setSelection({ type: "config", id: c.id })}
|
|
title={commandLine(c)}
|
|
draggable
|
|
onDragStart={(e) => {
|
|
dragged.current = c.id;
|
|
e.dataTransfer.effectAllowed = "move";
|
|
}}
|
|
>
|
|
<Icon
|
|
name={configIcon(c)}
|
|
size={13}
|
|
className={clsx(styles.chooserIcon, temporary.includes(c.id) && styles.chooserTemporary)}
|
|
/>
|
|
<span className={styles.chooserName}>{c.name || <T>Unnamed</T>}</span>
|
|
{c.target && <span className={styles.chooserMuted}>@ {targetName(c, runTargets)}</span>}
|
|
{errorsById[c.id] && <Icon name="error" size={12} className={styles.failed} label="Invalid" />}
|
|
{!c.detected && <span className={styles.chooserMuted}>{c.local ? "local" : "user"}</span>}
|
|
</button>
|
|
);
|
|
})}
|
|
{draft.length === 0 && (
|
|
<div className={styles.editEmpty}>
|
|
<T>No run configurations added.</T>
|
|
<span>
|
|
<button type="button" className={styles.noticeLink} onClick={(e) => openAdd(e.currentTarget)}>
|
|
<T>Add new…</T>
|
|
</button>{" "}
|
|
<span className={styles.chooserMuted}>{isMac() ? "⌘N" : "Alt+Insert"}</span>
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<button
|
|
type="button"
|
|
className={clsx(styles.noticeLink, styles.editFooterLink)}
|
|
onClick={() => {
|
|
setMode("templates");
|
|
const first = templateTypes[0];
|
|
setSelection(first ? { type: "template", kind: first.id } : null);
|
|
}}
|
|
>
|
|
<T>Edit configuration templates…</T>
|
|
</button>
|
|
</>
|
|
) : (
|
|
<>
|
|
<div className={styles.editToolbar}>
|
|
<span className={styles.formSectionTitle}>
|
|
<T>Templates</T>
|
|
</span>
|
|
</div>
|
|
<div className={styles.editList} role="listbox" aria-label={tx("Configuration templates")}>
|
|
{templateTypes.map((t) => {
|
|
const sel = selection?.type === "template" && selection.kind === t.id;
|
|
return (
|
|
<button
|
|
key={t.id}
|
|
type="button"
|
|
role="option"
|
|
aria-selected={sel}
|
|
className={clsx(styles.editItem, sel && styles.editItemSelected)}
|
|
onClick={() => setSelection({ type: "template", kind: t.id })}
|
|
>
|
|
<Icon name={t.icon} size={13} className={styles.chooserIcon} />
|
|
<span className={styles.chooserName}>{t.label}</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
<button
|
|
type="button"
|
|
className={clsx(styles.noticeLink, styles.editFooterLink)}
|
|
onClick={() => {
|
|
setMode("configs");
|
|
setSelection(draft[0] ? { type: "config", id: draft[0].id } : null);
|
|
}}
|
|
>
|
|
<T>Back to configurations</T>
|
|
</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
<div className={styles.editForm}>
|
|
{mode === "templates" ? (
|
|
currentTemplate ? (
|
|
<>
|
|
<div className={styles.notice}>
|
|
<T>New configurations of this type start with these values.</T>
|
|
</div>
|
|
<ConfigForm
|
|
key={`template:${currentTemplate.kind}`}
|
|
config={currentTemplate}
|
|
all={draft}
|
|
type={typeFor(currentTemplate.kind)}
|
|
errors={{}}
|
|
template
|
|
modifyRequest={modifyRequest}
|
|
onChange={updateTemplate}
|
|
/>
|
|
</>
|
|
) : (
|
|
<div className={styles.editEmpty}>
|
|
<T>Select a configuration type</T>
|
|
</div>
|
|
)
|
|
) : current ? (
|
|
<ConfigForm
|
|
key={current.id}
|
|
config={current}
|
|
all={draft}
|
|
type={typeFor(current.kind)}
|
|
errors={errors}
|
|
modifyRequest={modifyRequest}
|
|
onChange={replace}
|
|
/>
|
|
) : selection?.type === "folder" ? (
|
|
<FolderName
|
|
key={`${selection.kind}:${selection.name}`}
|
|
name={selection.name}
|
|
onRename={(to) => renameFolder(selection.kind, selection.name, to)}
|
|
/>
|
|
) : (
|
|
<div className={styles.editEmpty}>
|
|
<button type="button" className={styles.noticeLink} onClick={(e) => openAdd(e.currentTarget)}>
|
|
<T>Add new run configuration…</T>
|
|
</button>
|
|
<T>or select run configuration to edit</T>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
{adding && <AddConfigPopup anchor={adding} onPick={add} onClose={() => setAdding(null)} />}
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
/** Renames on every edit that leaves a name (an emptied field keeps the old one). */
|
|
function FolderName({ name, onRename }: { name: string; onRename: (to: string) => void }) {
|
|
const [text, setText] = useState(name);
|
|
return (
|
|
<Field label="Folder name" error={text.trim() ? undefined : "Name is required"}>
|
|
<Input
|
|
aria-label="Folder name"
|
|
value={text}
|
|
autoFocus
|
|
onChange={(e) => setText(e.target.value)}
|
|
onBlur={() => text.trim() && text.trim() !== name && onRename(text.trim())}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter" && text.trim() && text.trim() !== name) {
|
|
e.preventDefault();
|
|
onRename(text.trim());
|
|
}
|
|
}}
|
|
/>
|
|
</Field>
|
|
);
|
|
}
|