fix(run): keep Run/Debug Configurations usable in narrow windows (#2485)

The Name field was squeezed and the dialog overflowed in narrow or zoomed windows because the sidebar, label column and header controls had fixed widths. Add a remembered sidebar splitter, shared label columns, and wrapping Name/storage and footer groups; Before launch actions stay together and both panes keep scrolling independently when the dialog is resized.
This commit is contained in:
l0ng-ai
2026-11-10 23:41:15 +08:00
committed by GitHub
parent 27ee6fde61
commit 9153c66849
4 changed files with 625 additions and 328 deletions
+198
View File
@@ -0,0 +1,198 @@
import type { Page } from "@playwright/test";
import { expect, test } from "./fixtures";
async function openConfigurations(page: Page) {
await page.evaluate(async () => {
const path = "/src/features/run/store.ts";
const { useRun } = await import(/* @vite-ignore */ path);
const base = {
kind: "shell",
command: "/Users/developer/projects/acme/services/payments/scripts/start-development-server",
args: ["--watch"],
cwd: "/Users/developer/projects/acme/services/payments",
env: { NODE_ENV: "development" },
detected: false,
isTest: false,
debugAdapter: null,
};
useRun.setState({
configs: [
{
...base,
id: "layout:app",
name: "Payments development server",
beforeLaunch: [{ kind: "run", target: "layout:prepare" }],
},
{ ...base, id: "layout:prepare", name: "Prepare generated sources for the payments development server" },
],
selectedId: "layout:app",
loaded: true,
editorOpen: true,
});
});
await expect(page.getByRole("dialog", { name: "Run/Debug Configurations", exact: true })).toBeVisible();
}
test("run configuration sidebar resizes with mouse and keyboard and remembers the layout", async ({
workbench: page,
}) => {
await openConfigurations(page);
const dialog = page.getByRole("dialog", { name: "Run/Debug Configurations", exact: true });
const handle = dialog.getByRole("separator", { name: "Run configurations", exact: true });
await expect(handle).toBeVisible();
const sidebar = dialog.locator("#run-config-navigation");
const initial = (await sidebar.boundingBox())!.width;
const box = (await handle.boundingBox())!;
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.down();
await page.mouse.move(box.x + 55, box.y + box.height / 2, { steps: 5 });
await page.mouse.up();
await expect.poll(async () => (await sidebar.boundingBox())!.width).toBeGreaterThan(initial + 35);
const resized = (await sidebar.boundingBox())!.width;
await dialog.getByRole("button", { name: "Cancel", exact: true }).click();
await openConfigurations(page);
await expect.poll(async () => (await sidebar.boundingBox())!.width).toBeCloseTo(resized, 0);
await handle.focus();
await page.keyboard.press("ArrowLeft");
await expect.poll(async () => (await sidebar.boundingBox())!.width).toBeLessThan(resized);
});
test("run configuration fields share a control column including the name", async ({ workbench: page }) => {
await openConfigurations(page);
const dialog = page.getByRole("dialog", { name: "Run/Debug Configurations", exact: true });
const fields = ["Name", "Command", "Working directory"].map((name) =>
dialog.getByRole("textbox", { name, exact: true }),
);
for (const field of fields) await expect(field).toBeVisible();
const boxes = await Promise.all(fields.map((field) => field.boundingBox()));
for (const box of boxes) expect(box!.x).toBeCloseTo(boxes[0]!.x, 0);
expect(boxes[0]!.width).toBeGreaterThan(80);
});
test("resizing the configuration dialog grows its panes and keeps the footer visible", async ({ workbench: page }) => {
await openConfigurations(page);
const dialog = page.getByRole("dialog", { name: "Run/Debug Configurations", exact: true });
const content = dialog.locator("#run-config-content");
const initial = (await content.boundingBox())!.height;
const grip = dialog.getByRole("separator", { name: "Resize", exact: true });
const box = (await grip.boundingBox())!;
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.down();
await page.mouse.move(box.x + 80, box.y + 80, { steps: 5 });
await page.mouse.up();
await expect.poll(async () => (await content.boundingBox())!.height).toBeGreaterThan(initial + 50);
const grown = (await grip.boundingBox())!;
await page.mouse.move(grown.x + grown.width / 2, grown.y + grown.height / 2);
await page.mouse.down();
await page.mouse.move(grown.x - 320, grown.y - 400, { steps: 5 });
await page.mouse.up();
await expect(dialog.getByRole("button", { name: "OK", exact: true })).toBeInViewport();
await expect(dialog.getByRole("tree", { name: "Run configurations", exact: true })).toBeVisible();
await expect.poll(async () => (await content.boundingBox())!.height).toBeLessThan(initial);
await expect
.poll(() => dialog.locator("[class*='editForm_']").evaluate((el) => el.scrollHeight - el.clientHeight))
.toBeGreaterThan(0);
});
test("configuration templates keep the form and sidebar usable", async ({ workbench: page }) => {
await openConfigurations(page);
const dialog = page.getByRole("dialog", { name: "Run/Debug Configurations", exact: true });
await dialog.getByRole("button", { name: "Edit configuration templates…", exact: true }).click();
await dialog.getByRole("option", { name: "Shell Script", exact: true }).click();
await expect(dialog.getByRole("textbox", { name: "Script path", exact: true })).toBeVisible();
await expect(dialog.getByRole("separator", { name: "Run configurations", exact: true })).toBeVisible();
await dialog.getByRole("button", { name: "Back to configurations", exact: true }).click();
await expect(dialog.getByRole("textbox", { name: "Name", exact: true })).toHaveValue("Payments development server");
});
test("Java, npm and Python forms fit narrow and zoomed content panes", async ({ workbench: page }) => {
for (const kind of ["application", "npm", "pytest"]) {
await page.evaluate(async (kind) => {
const store = "/src/features/run/store.ts";
const types = "/src/features/run/types.ts";
const { useRun } = await import(/* @vite-ignore */ store);
const { newConfigOfType, typeFor } = await import(/* @vite-ignore */ types);
const config = newConfigOfType(typeFor(kind)!, []);
useRun.setState({ configs: [config], selectedId: config.id, loaded: true, editorOpen: true });
}, kind);
const dialog = page.getByRole("dialog", { name: "Run/Debug Configurations", exact: true });
await expect(dialog).toBeVisible();
for (const zoom of [1, 1.25]) {
await page.evaluate((zoom) => document.documentElement.style.setProperty("zoom", String(zoom)), zoom);
for (const width of [1024, 640, 520]) {
await page.setViewportSize({ width, height: 820 });
await expect
.poll(() => dialog.locator("[class*='editForm_']").evaluate((el) => el.scrollWidth - el.clientWidth), {
message: `${kind}, ${width}px, ${zoom * 100}%`,
})
.toBeLessThanOrEqual(1);
}
}
await dialog.getByRole("button", { name: "Cancel", exact: true }).click();
}
});
test("Tomcat retains its tabbed form and an independently scrolling panel", async ({ workbench: page }) => {
await page.evaluate(async () => {
const store = "/src/features/run/store.ts";
const types = "/src/features/run/types.ts";
const { useRun } = await import(/* @vite-ignore */ store);
const { newConfigOfType, typeFor } = await import(/* @vite-ignore */ types);
const config = newConfigOfType(typeFor("tomcat")!, []);
useRun.setState({ configs: [config], selectedId: config.id, loaded: true, editorOpen: true });
});
const dialog = page.getByRole("dialog", { name: "Run/Debug Configurations", exact: true });
await expect(dialog).toBeVisible();
for (const tab of ["Server", "Deployment", "Logs", "Startup/Connection"]) {
await dialog.getByRole("tab", { name: tab, exact: true }).click();
const panel = dialog.getByRole("tabpanel");
await expect(panel).toBeVisible();
await expect.poll(async () => (await panel.boundingBox())!.height).toBeGreaterThan(100);
}
});
for (const locale of ["en", "zh"]) {
test(`${locale} run forms and footer fit narrow and zoomed windows`, async ({ workbench: page }) => {
await openConfigurations(page);
await page.getByRole("textbox", { name: "Name", exact: true }).fill("");
await page.evaluate(async (locale) => {
const path = "/src/platform/i18n.ts";
await (await import(/* @vite-ignore */ path)).setLocale(locale);
}, locale);
const dialog = page.getByRole("dialog");
for (const zoom of [1, 1.25]) {
await page.evaluate((zoom) => document.documentElement.style.setProperty("zoom", String(zoom)), zoom);
for (const width of [1024, 800, 640, 520]) {
await page.setViewportSize({ width, height: 820 });
await expect
.poll(
() =>
dialog.evaluate((el) => {
const body = el.querySelector<HTMLElement>("[class*='editForm_']")!;
const footer = el.querySelector<HTMLElement>("[data-dialog-footer]")!;
const bounds = footer.getBoundingClientRect();
const buttons = [...footer.querySelectorAll("button")].map((b) => b.getBoundingClientRect());
return {
overflow: body.scrollWidth - body.clientWidth,
footerFits: buttons.every((b) => b.left >= bounds.left - 1 && b.right <= bounds.right + 1),
footerVisible: bounds.bottom <= window.innerHeight + 1,
};
}),
{ message: `${locale}, ${width}px, ${zoom * 100}%` },
)
.toMatchObject({ overflow: expect.any(Number), footerFits: true, footerVisible: true });
await expect
.poll(() => dialog.locator("[class*='editForm_']").evaluate((el) => el.scrollWidth - el.clientWidth))
.toBeLessThanOrEqual(1);
const tasks = dialog.getByRole("list");
await tasks.scrollIntoViewIfNeeded();
const bounds = (await tasks.boundingBox())!;
for (const button of await tasks.getByRole("button").all()) {
const box = (await button.boundingBox())!;
expect(box.x).toBeGreaterThanOrEqual(bounds.x - 1);
expect(box.x + box.width).toBeLessThanOrEqual(bounds.x + bounds.width + 1);
}
}
}
});
}
+46 -40
View File
@@ -407,7 +407,7 @@ export function ConfigForm({ config, all, type, errors, template, onChange, modi
);
return (
<>
<div className={clsx(styles.configFields, config.kind === "tomcat" && styles.configFieldsTomcat)}>
{config.detected && !template && (
<div className={styles.notice}>
<T>Detected from the project. Changes are saved as a user configuration that overrides it.</T>
@@ -416,21 +416,23 @@ export function ConfigForm({ config, all, type, errors, template, onChange, modi
{!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 })}
/>
<div className={styles.nameControl}>
<Input
value={config.name}
invalid={!!errors.name}
aria-label="Name"
onChange={(e) => update({ name: e.target.value })}
/>
<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>
</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" ? (
@@ -704,31 +706,35 @@ export function ConfigForm({ config, all, type, errors, template, onChange, modi
}
/>
) : (
<span className={styles.taskLabel}>{txTemplate(taskLabel(t, all))}</span>
<span className={styles.taskLabel} title={txTemplate(taskLabel(t, all))}>
{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))}
/>
<span className={styles.taskActions}>
<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))}
/>
</span>
</div>
))}
<Button
@@ -785,7 +791,7 @@ export function ConfigForm({ config, all, type, errors, template, onChange, modi
)}
</>
)}
</>
</div>
);
}
+296 -275
View File
@@ -5,7 +5,7 @@ import clsx from "clsx";
import { executeAction } from "@/platform/actions";
import { call, commands, isTauri, type RunConfig, type RunImportSource } from "@/platform/ipc";
import { letterOf } from "@/platform/keymap";
import { Button, Dialog, Icon, IconButton, Input, T } from "@/ui";
import { Button, Dialog, Icon, IconButton, Input, SplitGroup, SplitHandle, SplitPanel, T } from "@/ui";
import {
notifications,
useRunConfigTypes,
@@ -385,6 +385,7 @@ function EditConfigsDialog() {
title="Run/Debug Configurations"
width={860}
resizable="both"
minWidth={640}
className={styles.editDialog}
footerStart={
<span className={styles.footerRun}>
@@ -416,286 +417,306 @@ function EditConfigsDialog() {
]}
>
<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)}>
<T>{r.label}</T>
</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;
const isTomcat = c.kind === "tomcat";
const tomcatNoArtifact =
isTomcat &&
((c.params?.deployment ?? "artifact") === "artifact"
? !c.params?.artifact
: !c.params?.deploymentPath?.trim());
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";
}}
>
<span className={styles.tomcatTreeIconWrap}>
<Icon
name={configIcon(c)}
size={13}
className={clsx(styles.chooserIcon, temporary.includes(c.id) && styles.chooserTemporary)}
/>
{tomcatNoArtifact && (
<Icon name="close" size={9} className={styles.tomcatTreeErrorBadge} label="Invalid" />
)}
</span>
<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 && !isTomcat && (
<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>
{importSources.map((source) => (
<button
key={source}
type="button"
className={clsx(styles.noticeLink, styles.editFooterLink, styles.editImportLink)}
disabled={importing}
onClick={() => void runImport(source, draft)}
>
{importLabel(source)}
</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={clsx(styles.editForm, mode === "configs" && current?.kind === "tomcat" && styles.editFormTomcat)}
<SplitGroup
id="run-configurations"
orientation="horizontal"
storageKey="openidea.run-configurations.layout"
className={styles.editSplit}
>
{mode === "templates" ? (
currentTemplate ? (
<SplitPanel
id="run-config-navigation"
defaultSize="30%"
minSize="160px"
maxSize="45%"
className={styles.editSidebar}
>
{mode === "configs" ? (
<>
<div className={styles.notice}>
<T>New configurations of this type start with these values.</T>
<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>
<ConfigForm
key={`template:${currentTemplate.kind}`}
config={currentTemplate}
all={draft}
type={typeFor(currentTemplate.kind)}
errors={{}}
template
modifyRequest={modifyRequest}
onChange={updateTemplate}
/>
<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)}>
<T>{r.label}</T>
</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;
const isTomcat = c.kind === "tomcat";
const tomcatNoArtifact =
isTomcat &&
((c.params?.deployment ?? "artifact") === "artifact"
? !c.params?.artifact
: !c.params?.deploymentPath?.trim());
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";
}}
>
<span className={styles.tomcatTreeIconWrap}>
<Icon
name={configIcon(c)}
size={13}
className={clsx(styles.chooserIcon, temporary.includes(c.id) && styles.chooserTemporary)}
/>
{tomcatNoArtifact && (
<Icon name="close" size={9} className={styles.tomcatTreeErrorBadge} label="Invalid" />
)}
</span>
<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 && !isTomcat && (
<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>
{importSources.map((source) => (
<button
key={source}
type="button"
className={clsx(styles.noticeLink, styles.editFooterLink, styles.editImportLink)}
disabled={importing}
onClick={() => void runImport(source, draft)}
>
{importLabel(source)}
</button>
))}
</>
) : (
<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 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>
</>
)}
</SplitPanel>
<SplitHandle aria-label={tx("Run configurations")} />
<SplitPanel id="run-config-content" minSize="55%" className={styles.editDetails}>
<div
className={clsx(
styles.editForm,
mode === "configs" && current?.kind === "tomcat" && styles.editFormTomcat,
)}
>
{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>
</SplitPanel>
</SplitGroup>
</div>
{adding && <AddConfigPopup anchor={adding} onPick={add} onClose={() => setAdding(null)} />}
</Dialog>
+85 -13
View File
@@ -466,13 +466,16 @@
height: min(520px, calc(100vh - 200px));
min-height: 0;
}
.editSplit {
flex: 1;
}
.editSidebar {
flex: none;
width: 250px;
min-height: 0;
display: flex;
flex-direction: column;
border-right: 1px solid var(--border-popup);
}
.editDetails {
container: run-config-content / inline-size;
}
.editToolbar {
flex: none;
@@ -513,15 +516,43 @@
.editForm {
flex: 1;
min-width: 0;
min-height: 0;
overflow: auto;
padding: 12px 16px;
display: flex;
flex-direction: column;
gap: 8px;
}
.configFields {
display: grid;
grid-template-columns: fit-content(40%) minmax(0, 1fr);
column-gap: var(--space-3);
row-gap: var(--space-2);
align-content: start;
min-width: 0;
}
.configFields > * {
grid-column: 1 / -1;
min-width: 0;
}
.configFields:not(.configFieldsTomcat) > .field,
.configFields:not(.configFieldsTomcat) > .formHeader,
.configFields:not(.configFieldsTomcat) > .formHeader > .field {
grid-template-columns: subgrid;
}
.formHeader > .field {
grid-column: 1 / -1;
}
.editFormTomcat {
overflow: hidden;
}
.configFieldsTomcat {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.tomcatTreeIconWrap {
position: relative;
display: inline-flex;
@@ -551,9 +582,11 @@
}
.field {
display: grid;
grid-template-columns: 120px 1fr;
align-items: center;
gap: 12px;
grid-template-columns: fit-content(40%) minmax(0, 1fr);
align-items: start;
column-gap: var(--space-3);
row-gap: 4px;
min-width: 0;
}
.fieldTop {
align-items: start;
@@ -564,6 +597,8 @@
.fieldLabel {
color: var(--fg-secondary);
font-size: var(--fs-ui);
padding-top: max(0px, calc((var(--h-field) - 1.4em) / 2));
overflow-wrap: anywhere;
}
.fieldControl {
display: flex;
@@ -571,6 +606,12 @@
gap: 4px;
min-width: 0;
}
.fieldControl > * {
max-width: 100%;
}
.fieldControl > span {
overflow-wrap: anywhere;
}
.fieldError {
color: var(--danger);
font-size: var(--fs-small);
@@ -593,19 +634,24 @@
flex: none;
align-self: flex-start;
margin: 8px 12px;
max-width: calc(100% - 24px);
text-align: left;
overflow-wrap: anywhere;
}
/* Below "Edit configuration templates…". */
.editImportLink {
margin-top: 0;
}
.footerRun {
display: inline-flex;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
gap: var(--space-2);
min-width: 0;
}
.formHeader {
display: grid;
grid-template-columns: 1fr auto;
grid-template-columns: minmax(0, 1fr);
align-items: center;
gap: 16px;
padding-bottom: 10px;
@@ -621,10 +667,22 @@
gap: 10px;
max-width: 440px;
}
.nameControl {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-2) var(--space-4);
min-width: 0;
}
.nameControl > input {
flex: 1 1 160px;
min-width: 0;
}
.storeProjectGroup {
display: inline-flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.formSection {
display: flex;
@@ -635,6 +693,7 @@
flex: 1;
font-weight: 600;
color: var(--fg-strong);
min-width: 0;
}
.formSection .noticeLink {
display: inline-flex;
@@ -683,7 +742,8 @@
align-self: flex-start;
}
.taskRow {
display: flex;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 4px;
min-height: var(--h-row);
@@ -691,9 +751,10 @@
.taskRow > :first-child {
min-width: 0;
}
.taskRow > .taskLabel,
.taskRow > input {
flex: 1;
.taskActions {
display: inline-flex;
align-items: center;
gap: 2px;
}
.taskLabel {
overflow: hidden;
@@ -800,6 +861,17 @@
gap: 6px;
color: var(--danger);
font-size: var(--fs-ui);
min-width: 0;
}
.footerError > svg {
flex: none;
}
@container run-config-content (max-width: 360px) {
.configFields,
.editForm > .field {
grid-template-columns: minmax(0, 1fr);
}
}
/* Editor gutter ▶ run icons */