From afea58c1c6accff5c86e8e2b4ba4573c520fe1bd Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Wed, 4 Nov 2026 06:06:38 +0800 Subject: [PATCH] feat(run): browse buttons on path fields and a resizable Run/Debug Configurations dialog (#1108) Run configuration types mark path fields (browse: file or directory, or chosen from the other params) and the form puts a folder button next to them; Working directory and Redirect input get one too, storing project paths relative to the root. Covers Tomcat, Shell Script, Executable, Godot, npm, Gradle, Maven, Go, Ruby and Docker path fields. The dialog is resizable and its content grows with it. --- src/features/run/ConfigForm.tsx | 125 +++++++++++++++++++------ src/features/run/EditConfigsDialog.tsx | 1 + src/features/run/Run.module.css | 3 +- src/features/run/browsePaths.test.tsx | 79 ++++++++++++++++ src/features/run/envFiles.test.tsx | 45 --------- src/features/run/goTypes.ts | 2 +- src/features/run/rubyTypes.ts | 9 +- src/features/run/tomcat.ts | 4 +- src/features/run/types.ts | 37 +++++++- src/features/services/runTypes.ts | 17 +++- src/workbench/contributions.ts | 2 + 11 files changed, 238 insertions(+), 86 deletions(-) create mode 100644 src/features/run/browsePaths.test.tsx delete mode 100644 src/features/run/envFiles.test.tsx diff --git a/src/features/run/ConfigForm.tsx b/src/features/run/ConfigForm.tsx index f7ecf0fe7..159b04ed1 100644 --- a/src/features/run/ConfigForm.tsx +++ b/src/features/run/ConfigForm.tsx @@ -171,12 +171,45 @@ export interface ConfigFormProps { modifyRequest: number; } -/** Picks `.env` files; paths inside the project are returned relative to its root. */ -async function chooseEnvFiles(root: string | null, title: string): Promise { +/** Picks files or a directory in the local file dialog, opened at `from`; empty when cancelled. */ +async function choosePaths(opts: { directory?: boolean; multiple?: boolean; from: string | null }): Promise { const { open } = await import("@tauri-apps/plugin-dialog"); - const picked = await open({ multiple: true, title, defaultPath: root ?? undefined }); - const paths = picked === null ? [] : Array.isArray(picked) ? picked : [picked]; - return paths.map((p) => (root ? relativePath(root, p) : p)); + const picked = await open({ + directory: !!opts.directory, + multiple: !!opts.multiple, + defaultPath: opts.from ?? undefined, + }); + return picked === null ? [] : Array.isArray(picked) ? picked : [picked]; +} + +/** `path` relative to the project root when inside it; the root itself is "". */ +function projectRelative(root: string | null, path: string): string { + if (!root) return path; + return path === root ? "" : relativePath(root, path); +} + +/** Browse button next to a path field; local windows only (the dialog shows this machine's files). */ +function BrowseButton({ + directory, + title, + from, + onPick, +}: { + directory?: boolean; + title: string; + from: string | null; + onPick: (path: string) => void; +}) { + if (!isTauri() || isRemoteWindow()) return null; + return ( + void choosePaths({ directory, from }).then(([path]) => path !== undefined && onPick(path))} + /> + ); } export function ConfigForm({ config, all, type, errors, template, onChange, modifyRequest }: ConfigFormProps) { @@ -458,13 +491,21 @@ export function ConfigForm({ config, all, type, errors, template, onChange, modi <> {!type?.ownsWorkingDirectory && ( - update({ cwd: e.target.value.trim() ? e.target.value : null })} - /> +
+ update({ cwd: e.target.value.trim() ? e.target.value : null })} + /> + update({ cwd: projectRelative(root, path) || null })} + /> +
)} - void chooseEnvFiles(root, tx("Browse for .env Files")).then((files) => { + void choosePaths({ multiple: true, from: root }).then((picked) => { const current = config.envFiles ?? []; - const added = files.filter((f) => !current.includes(f)); + const added = picked.map((p) => projectRelative(root, p)).filter((f) => !current.includes(f)); if (added.length) update({ envFiles: [...current, ...added] }); }) } @@ -535,13 +576,20 @@ export function ConfigForm({ config, all, type, errors, template, onChange, modi )} {isShown("stdinFile") && ( - update({ stdinFile: e.target.value || null })} - /> +
+ update({ stdinFile: e.target.value || null })} + /> + update({ stdinFile: projectRelative(root, path) || null })} + /> +
)} {isShown("outputFile") && ( @@ -679,6 +727,7 @@ function TypeField({ onChange: (value: string) => void; }) { const tx = useTx(); + const root = useProject((s) => s.project?.root ?? null); const listId = `run-field-${field.key}`; if (field.control === "output") return ; if (field.control === "select") { @@ -708,17 +757,33 @@ function TypeField({ /> ); const suggestions = field.suggestions?.(all, params) ?? []; + const input = ( + onChange(e.target.value)} + /> + ); + const browse = typeof field.browse === "function" ? field.browse(params) : field.browse; return ( <> - onChange(e.target.value)} - /> + {browse ? ( +
+ {input} + +
+ ) : ( + input + )} {suggestions.length > 0 && ( {suggestions.map((s) => ( diff --git a/src/features/run/EditConfigsDialog.tsx b/src/features/run/EditConfigsDialog.tsx index 56223ab15..153081698 100644 --- a/src/features/run/EditConfigsDialog.tsx +++ b/src/features/run/EditConfigsDialog.tsx @@ -377,6 +377,7 @@ function EditConfigsDialog() { onClose={close} title="Run/Debug Configurations" width={860} + resizable="both" className={styles.editDialog} footerStart={ diff --git a/src/features/run/Run.module.css b/src/features/run/Run.module.css index 2a3ec6690..e278f9193 100644 --- a/src/features/run/Run.module.css +++ b/src/features/run/Run.module.css @@ -401,7 +401,8 @@ } .editBody { display: flex; - height: min(520px, calc(100vh - 200px)); + height: 100%; + min-height: min(520px, calc(100vh - 200px)); } .editSidebar { flex: none; diff --git a/src/features/run/browsePaths.test.tsx b/src/features/run/browsePaths.test.tsx new file mode 100644 index 000000000..0885c26f6 --- /dev/null +++ b/src/features/run/browsePaths.test.tsx @@ -0,0 +1,79 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; + +const open = vi.hoisted(() => vi.fn()); +vi.mock("@tauri-apps/plugin-dialog", () => ({ open })); +vi.mock("@/platform/ipc", async (importOriginal) => ({ + ...(await importOriginal()), + isTauri: () => true, +})); + +import type { RunConfig } from "@/platform/ipc"; +import { useProject } from "@/platform/project"; +import { ConfigForm } from "./ConfigForm"; +import { tomcatType } from "./tomcat"; +import { newConfigOfType } from "./types"; + +const config: RunConfig = { + id: "app", + name: "app", + kind: "shell", + command: "python3", + args: [], + cwd: null, + env: {}, + detected: false, + isTest: false, + debugAdapter: null, + envFiles: [".env"], +}; + +afterEach(cleanup); + +describe("Environment variables", () => { + it("adds the .env files chosen in the file dialog, relative to the project root", async () => { + useProject.setState({ project: { root: "/work/app" } as never }); + open.mockResolvedValue(["/work/app/.env", "/work/app/config/dev.env", "/elsewhere/shared.env"]); + const onChange = vi.fn(); + render(); + fireEvent.click(screen.getByRole("button", { name: "Browse for .env Files" })); + await waitFor(() => + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ envFiles: [".env", "config/dev.env", "/elsewhere/shared.env"] }), + ), + ); + expect(open).toHaveBeenCalledWith(expect.objectContaining({ multiple: true, defaultPath: "/work/app" })); + }); +}); + +describe("path fields", () => { + it("pick the working directory relative to the project root", async () => { + useProject.setState({ project: { root: "/work/app" } as never }); + open.mockResolvedValue("/work/app/server"); + const onChange = vi.fn(); + render(); + fireEvent.click(screen.getAllByRole("button", { name: "Browse…" })[0]!); + await waitFor(() => expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ cwd: "server" }))); + expect(open).toHaveBeenLastCalledWith(expect.objectContaining({ directory: true, multiple: false })); + }); + + it("pick a file or a directory for a configuration type's path fields", async () => { + useProject.setState({ project: { root: "/work/app" } as never }); + const tomcat = { ...newConfigOfType(tomcatType, []), params: { deployment: "war" } }; + const onChange = vi.fn(); + render(); + const home = screen.getByRole("textbox", { name: "Tomcat installation directory" }); + open.mockResolvedValue("/opt/tomcat"); + fireEvent.click(home.parentElement!.querySelector("button")!); + await waitFor(() => + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ params: expect.objectContaining({ tomcatHome: "/opt/tomcat" }) }), + ), + ); + expect(open).toHaveBeenLastCalledWith(expect.objectContaining({ directory: true })); + const war = screen.getByRole("textbox", { name: "Deployment path" }); + open.mockResolvedValue("/work/app/build/app.war"); + fireEvent.click(war.parentElement!.querySelector("button")!); + await waitFor(() => expect(open).toHaveBeenLastCalledWith(expect.objectContaining({ directory: false }))); + }); +}); diff --git a/src/features/run/envFiles.test.tsx b/src/features/run/envFiles.test.tsx deleted file mode 100644 index 45a47e7c1..000000000 --- a/src/features/run/envFiles.test.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; -import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; - -const open = vi.hoisted(() => vi.fn()); -vi.mock("@tauri-apps/plugin-dialog", () => ({ open })); -vi.mock("@/platform/ipc", async (importOriginal) => ({ - ...(await importOriginal()), - isTauri: () => true, -})); - -import type { RunConfig } from "@/platform/ipc"; -import { useProject } from "@/platform/project"; -import { ConfigForm } from "./ConfigForm"; - -const config: RunConfig = { - id: "app", - name: "app", - kind: "shell", - command: "python3", - args: [], - cwd: null, - env: {}, - detected: false, - isTest: false, - debugAdapter: null, - envFiles: [".env"], -}; - -afterEach(cleanup); - -describe("Environment variables", () => { - it("adds the .env files chosen in the file dialog, relative to the project root", async () => { - useProject.setState({ project: { root: "/work/app" } as never }); - open.mockResolvedValue(["/work/app/.env", "/work/app/config/dev.env", "/elsewhere/shared.env"]); - const onChange = vi.fn(); - render(); - fireEvent.click(screen.getByRole("button", { name: "Browse for .env Files" })); - await waitFor(() => - expect(onChange).toHaveBeenCalledWith( - expect.objectContaining({ envFiles: [".env", "config/dev.env", "/elsewhere/shared.env"] }), - ), - ); - expect(open).toHaveBeenCalledWith(expect.objectContaining({ multiple: true, defaultPath: "/work/app" })); - }); -}); diff --git a/src/features/run/goTypes.ts b/src/features/run/goTypes.ts index 5f035f8da..f86c8208f 100644 --- a/src/features/run/goTypes.ts +++ b/src/features/run/goTypes.ts @@ -22,7 +22,7 @@ const fields: RunConfigTypeContribution["fields"] = [ validate: (value, p) => p.goKind === "test" && p.scope === "file" && value.includes("|") ? "Select one Go test file" : null, }, - { key: "outputDir", label: "Output directory", mono: true, visible: (p) => p.goKind !== "test" }, + { key: "outputDir", label: "Output directory", mono: true, browse: "directory", visible: (p) => p.goKind !== "test" }, { key: "runAfterBuild", label: "Run after build", control: "checkbox", visible: (p) => p.goKind !== "test" }, { key: "pattern", label: "Pattern", mono: true, visible: (p) => p.goKind === "test" }, { key: "toolArgs", label: "Go tool arguments", mono: true }, diff --git a/src/features/run/rubyTypes.ts b/src/features/run/rubyTypes.ts index f7f3580d8..842845c80 100644 --- a/src/features/run/rubyTypes.ts +++ b/src/features/run/rubyTypes.ts @@ -92,7 +92,14 @@ export const rubyScriptType: RunConfigTypeContribution = { description: "Run a Ruby script", defaults: {}, fields: [ - { key: "script", label: "Ruby script", mono: true, required: true, placeholder: "e.g. bin/rails or lib/app.rb" }, + { + key: "script", + label: "Ruby script", + mono: true, + required: true, + browse: "file", + placeholder: "e.g. bin/rails or lib/app.rb", + }, { key: "scriptArgs", label: "Script arguments", mono: true, placeholder: "e.g. server -p 3000" }, { key: "rubyArgs", label: "Ruby arguments", mono: true, placeholder: "e.g. -Ilib -w" }, bundlerField, diff --git a/src/features/run/tomcat.ts b/src/features/run/tomcat.ts index 7676b7787..d4bfca2e0 100644 --- a/src/features/run/tomcat.ts +++ b/src/features/run/tomcat.ts @@ -20,7 +20,7 @@ export const tomcatType: RunConfigTypeContribution = { }, fields: [ ...updatePolicyFields(true), - { key: "tomcatHome", label: "Tomcat installation directory", required: true, mono: true }, + { key: "tomcatHome", label: "Tomcat installation directory", required: true, mono: true, browse: "directory" }, { key: "deployment", label: "Deployment", @@ -35,6 +35,7 @@ export const tomcatType: RunConfigTypeContribution = { key: "deploymentPath", label: "Deployment path", mono: true, + browse: (p) => (p.deployment === "war" ? "file" : "directory"), required: true, visible: (p) => p.deployment !== "artifact", }, @@ -59,6 +60,7 @@ export const tomcatType: RunConfigTypeContribution = { key: "jre", label: "JRE", mono: true, + browse: "directory", placeholder: "Project JDK", hint: "Java installation directory; empty uses the project JDK", }, diff --git a/src/features/run/types.ts b/src/features/run/types.ts index 3ebdf7492..ec6845ee7 100644 --- a/src/features/run/types.ts +++ b/src/features/run/types.ts @@ -56,12 +56,20 @@ const shell: RunConfigTypeContribution = { { value: "command", label: "Command line" }, ], }, - { key: "path", label: "Script path", mono: true, required: true, visible: (p) => p.mode === "file" }, + { + key: "path", + label: "Script path", + mono: true, + required: true, + browse: "file", + visible: (p) => p.mode === "file", + }, { key: "options", label: "Script options", mono: true, visible: (p) => p.mode === "file" }, { key: "interpreter", label: "Interpreter path", mono: true, + browse: "file", placeholder: "Run the script directly", visible: (p) => p.mode === "file", }, @@ -116,7 +124,14 @@ const program: RunConfigTypeContribution = { icon: "file-binary", description: "Run an executable with arguments", fields: [ - { key: "program", label: "Executable", mono: true, required: true, placeholder: "Path or command on PATH" }, + { + key: "program", + label: "Executable", + mono: true, + required: true, + browse: "file", + placeholder: "Path or command on PATH", + }, { key: "arguments", label: "Program arguments", mono: true }, ], commandLine: (p) => ({ command: p.program ?? "", args: parseArgs(p.arguments ?? "") }), @@ -139,7 +154,13 @@ const godot: RunConfigTypeContribution = { ownsWorkingDirectory: true, debugAdapter: "godot", fields: [ - { key: "projectDir", label: "Project directory", mono: true, placeholder: "Project root (holds project.godot)" }, + { + key: "projectDir", + label: "Project directory", + mono: true, + browse: "directory", + placeholder: "Project root (holds project.godot)", + }, { key: "scene", label: "Scene", @@ -188,7 +209,13 @@ const npm: RunConfigTypeContribution = { defaults: { packageManager: "npm", command: "run" }, ownsWorkingDirectory: true, fields: [ - { key: "packageDir", label: "package.json directory", mono: true, placeholder: "Project root" }, + { + key: "packageDir", + label: "package.json directory", + mono: true, + browse: "directory", + placeholder: "Project root", + }, { key: "command", label: "Command", control: "select", options: NPM_COMMANDS.map((v) => ({ value: v, label: v })) }, { key: "script", @@ -249,7 +276,7 @@ function buildToolType( defaults: wrapper.byDefault ? { wrapper: "true" } : {}, ownsWorkingDirectory: true, fields: [ - { key: "projectDir", label: tx`${label} project`, mono: true, placeholder: "Project root" }, + { key: "projectDir", label: tx`${label} project`, mono: true, browse: "directory", placeholder: "Project root" }, { key: "tasks", label: tasks.label, diff --git a/src/features/services/runTypes.ts b/src/features/services/runTypes.ts index 25413b5d5..f54acbd06 100644 --- a/src/features/services/runTypes.ts +++ b/src/features/services/runTypes.ts @@ -115,8 +115,21 @@ export const dockerfileType: RunConfigTypeContribution = { ownsWorkingDirectory: true, fields: [ connectionField, - { key: "dockerfile", label: "Dockerfile", mono: true, required: true, placeholder: "Relative to the project root" }, - { key: "context", label: "Context folder", mono: true, placeholder: "The Dockerfile's folder" }, + { + key: "dockerfile", + label: "Dockerfile", + mono: true, + required: true, + browse: "file", + placeholder: "Relative to the project root", + }, + { + key: "context", + label: "Context folder", + mono: true, + browse: "directory", + placeholder: "The Dockerfile's folder", + }, { key: "tag", label: "Image tag", mono: true, placeholder: "Derived from the context folder" }, { key: "target", label: "Build stage", mono: true, placeholder: "The last stage" }, { key: "buildArgs", label: "Build args", mono: true, placeholder: "KEY=value; OTHER=value" }, diff --git a/src/workbench/contributions.ts b/src/workbench/contributions.ts index 4c1161958..3cedcf512 100644 --- a/src/workbench/contributions.ts +++ b/src/workbench/contributions.ts @@ -1118,6 +1118,8 @@ export interface RunConfigTypeField { validate?(value: string, params: Readonly>): string | null; /** Shown under the control. */ hint?: string; + /** A text field holding a path: a button next to it picks a file or a directory (absolute path). */ + browse?: "file" | "directory" | ((params: Readonly>) => "file" | "directory"); } export interface RunConfigTypeContribution {