From 9153c66849ea516c3a84f79d5daeea4a958ffecb Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Tue, 10 Nov 2026 23:41:15 +0800 Subject: [PATCH] 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. --- e2e/runConfigLayout.spec.ts | 198 +++++++++ src/features/run/ConfigForm.tsx | 86 ++-- src/features/run/EditConfigsDialog.tsx | 571 +++++++++++++------------ src/features/run/Run.module.css | 98 ++++- 4 files changed, 625 insertions(+), 328 deletions(-) create mode 100644 e2e/runConfigLayout.spec.ts diff --git a/e2e/runConfigLayout.spec.ts b/e2e/runConfigLayout.spec.ts new file mode 100644 index 000000000..9c7ff7f70 --- /dev/null +++ b/e2e/runConfigLayout.spec.ts @@ -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("[class*='editForm_']")!; + const footer = el.querySelector("[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); + } + } + } + }); +} diff --git a/src/features/run/ConfigForm.tsx b/src/features/run/ConfigForm.tsx index 984c9e2ad..c400d9167 100644 --- a/src/features/run/ConfigForm.tsx +++ b/src/features/run/ConfigForm.tsx @@ -407,7 +407,7 @@ export function ConfigForm({ config, all, type, errors, template, onChange, modi ); return ( - <> +
{config.detected && !template && (
Detected from the project. Changes are saved as a user configuration that overrides it. @@ -416,21 +416,23 @@ export function ConfigForm({ config, all, type, errors, template, onChange, modi {!template && (
- update({ name: e.target.value })} - /> +
+ update({ name: e.target.value })} + /> +
+ update({ local: !shared })} + label="Store as project file" + aria-label="Store as project file (.openidea/run.toml); otherwise in the workspace (.openidea/run.local.toml)" + /> +
+
-
- update({ local: !shared })} - label="Store as project file" - aria-label="Store as project file (.openidea/run.toml); otherwise in the workspace (.openidea/run.local.toml)" - /> -
)} {config.kind === "tomcat" ? ( @@ -704,31 +706,35 @@ export function ConfigForm({ config, all, type, errors, template, onChange, modi } /> ) : ( - {txTemplate(taskLabel(t, all))} + + {txTemplate(taskLabel(t, all))} + )} - moveTask(i, -1)} - /> - moveTask(i, 1)} - /> - setTasks(tasks.filter((_, j) => j !== i))} - /> + + moveTask(i, -1)} + /> + moveTask(i, 1)} + /> + setTasks(tasks.filter((_, j) => j !== i))} + /> +
))}
); } diff --git a/src/features/run/EditConfigsDialog.tsx b/src/features/run/EditConfigsDialog.tsx index 3b80a514f..6af849e4b 100644 --- a/src/features/run/EditConfigsDialog.tsx +++ b/src/features/run/EditConfigsDialog.tsx @@ -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={ @@ -416,286 +417,306 @@ function EditConfigsDialog() { ]} >
-
- {mode === "configs" ? ( - <> -
- openAdd()} - /> - - - - move(-1)} - /> - move(1)} - /> -
-
{ - 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 ( - - ); - if (r.type === "folder") { - const sel = selection?.type === "folder" && selection.kind === r.kind && selection.name === r.name; - return ( - - ); - } - 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 ( - - ); - })} - {draft.length === 0 && ( -
- No run configurations added. - - {" "} - {isMac() ? "⌘N" : "Alt+Insert"} - -
- )} -
- - {importSources.map((source) => ( - - ))} - - ) : ( - <> -
- - Templates - -
-
- {templateTypes.map((t) => { - const sel = selection?.type === "template" && selection.kind === t.id; - return ( - - ); - })} -
- - - )} -
-
- {mode === "templates" ? ( - currentTemplate ? ( + + {mode === "configs" ? ( <> -
- New configurations of this type start with these values. +
+ openAdd()} + /> + + + + move(-1)} + /> + move(1)} + />
- +
{ + 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 ( + + ); + if (r.type === "folder") { + const sel = + selection?.type === "folder" && selection.kind === r.kind && selection.name === r.name; + return ( + + ); + } + 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 ( + + ); + })} + {draft.length === 0 && ( +
+ No run configurations added. + + {" "} + {isMac() ? "⌘N" : "Alt+Insert"} + +
+ )} +
+ + {importSources.map((source) => ( + + ))} ) : ( -
- Select a configuration type -
- ) - ) : current ? ( - - ) : selection?.type === "folder" ? ( - renameFolder(selection.kind, selection.name, to)} - /> - ) : ( -
- - or select run configuration to edit + <> +
+ + Templates + +
+
+ {templateTypes.map((t) => { + const sel = selection?.type === "template" && selection.kind === t.id; + return ( + + ); + })} +
+ + + )} + + + +
+ {mode === "templates" ? ( + currentTemplate ? ( + <> +
+ New configurations of this type start with these values. +
+ + + ) : ( +
+ Select a configuration type +
+ ) + ) : current ? ( + + ) : selection?.type === "folder" ? ( + renameFolder(selection.kind, selection.name, to)} + /> + ) : ( +
+ + or select run configuration to edit +
+ )}
- )} -
+ +
{adding && setAdding(null)} />} diff --git a/src/features/run/Run.module.css b/src/features/run/Run.module.css index 0288b59fb..06c897e5c 100644 --- a/src/features/run/Run.module.css +++ b/src/features/run/Run.module.css @@ -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 */