mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-14 16:07:52 +00:00
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:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 */
|
||||
|
||||
Reference in New Issue
Block a user