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 {