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.
This commit is contained in:
l0ng-ai authored and GitHub committed 2026-11-04 06:06:38 +08:00
1 parent 19615e00b9
commit afea58c1c6
11 files changed
+238 -86

No files matched your search

+95 -30
View File
@@ -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<string[]> {
/** 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<string[]> {
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 (
<IconButton
icon="folder-open"
size="small"
iconSize={14}
title={title}
onClick={() => 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 && (
<Field label="Working directory" error={errors.cwd}>
<Input
mono
aria-label="Working directory"
value={config.cwd ?? ""}
placeholder="Project root"
onChange={(e) => update({ cwd: e.target.value.trim() ? e.target.value : null })}
/>
<div className={styles.inputWithButton}>
<Input
mono
aria-label="Working directory"
value={config.cwd ?? ""}
placeholder="Project root"
onChange={(e) => update({ cwd: e.target.value.trim() ? e.target.value : null })}
/>
<BrowseButton
directory
title="Browse…"
from={root}
onPick={(path) => update({ cwd: projectRelative(root, path) || null })}
/>
</div>
</Field>
)}
<Field
@@ -498,9 +539,9 @@ export function ConfigForm({ config, all, type, errors, template, onChange, modi
iconSize={14}
title="Browse for .env Files"
onClick={() =>
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") && (
<Field label="Redirect input from">
<Input
mono
aria-label="Redirect input from"
value={config.stdinFile ?? ""}
placeholder="File relative to the project root"
onChange={(e) => update({ stdinFile: e.target.value || null })}
/>
<div className={styles.inputWithButton}>
<Input
mono
aria-label="Redirect input from"
value={config.stdinFile ?? ""}
placeholder="File relative to the project root"
onChange={(e) => update({ stdinFile: e.target.value || null })}
/>
<BrowseButton
title="Browse…"
from={root}
onPick={(path) => update({ stdinFile: projectRelative(root, path) || null })}
/>
</div>
</Field>
)}
{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 <OutputField label={field.label} text={field.value?.(params) ?? ""} />;
if (field.control === "select") {
@@ -708,17 +757,33 @@ function TypeField({
/>
);
const suggestions = field.suggestions?.(all, params) ?? [];
const input = (
<Input
mono={field.mono}
aria-label={field.label}
value={value}
invalid={invalid}
placeholder={field.placeholder}
list={suggestions.length ? listId : undefined}
onChange={(e) => onChange(e.target.value)}
/>
);
const browse = typeof field.browse === "function" ? field.browse(params) : field.browse;
return (
<>
<Input
mono={field.mono}
aria-label={field.label}
value={value}
invalid={invalid}
placeholder={field.placeholder}
list={suggestions.length ? listId : undefined}
onChange={(e) => onChange(e.target.value)}
/>
{browse ? (
<div className={styles.inputWithButton}>
{input}
<BrowseButton
directory={browse === "directory"}
title="Browse…"
from={/^(\/|[A-Za-z]:[\\/])/.test(value) ? value : root}
onPick={onChange}
/>
</div>
) : (
input
)}
{suggestions.length > 0 && (
<datalist id={listId}>
{suggestions.map((s) => (
+1
View File
@@ -377,6 +377,7 @@ function EditConfigsDialog() {
onClose={close}
title="Run/Debug Configurations"
width={860}
resizable="both"
className={styles.editDialog}
footerStart={
<span className={styles.footerRun}>
+2 -1
View File
@@ -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;
+79
View File
@@ -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<typeof import("@/platform/ipc")>()),
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(<ConfigForm config={config} all={[]} type={undefined} errors={{}} onChange={onChange} modifyRequest={0} />);
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(<ConfigForm config={config} all={[]} type={undefined} errors={{}} onChange={onChange} modifyRequest={0} />);
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(<ConfigForm config={tomcat} all={[]} type={tomcatType} errors={{}} onChange={onChange} modifyRequest={0} />);
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 })));
});
});
-45
View File
@@ -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<typeof import("@/platform/ipc")>()),
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(<ConfigForm config={config} all={[]} type={undefined} errors={{}} onChange={onChange} modifyRequest={0} />);
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" }));
});
});
+1 -1
View File
@@ -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 },
+8 -1
View File
@@ -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,
+3 -1
View File
@@ -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",
},
+32 -5
View File
@@ -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,
+15 -2
View File
@@ -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" },
+2
View File
@@ -1118,6 +1118,8 @@ export interface RunConfigTypeField {
validate?(value: string, params: Readonly<Record<string, string>>): 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<Record<string, string>>) => "file" | "directory");
}
export interface RunConfigTypeContribution {