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.
Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-11-04 06:06:38 +08:00
1 джерело 19615e00b9
коміт afea58c1c6
11 files changed
+238 -86

No files matched your search

+95 -30
Переглянути файл
@@ -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
Переглянути файл
@@ -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
Переглянути файл
@@ -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
Переглянути файл
@@ -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
Переглянути файл
@@ -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
Переглянути файл
@@ -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
Переглянути файл
@@ -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
Переглянути файл
@@ -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
Переглянути файл
@@ -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
Переглянути файл
@@ -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" },