mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-08 17:02:17 +00:00
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:
1 parent
19615e00b9
commit
afea58c1c6
11 files changed
+238
-86
No files matched your search
@@ -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) => (
|
||||
|
||||
@@ -377,6 +377,7 @@ function EditConfigsDialog() {
|
||||
onClose={close}
|
||||
title="Run/Debug Configurations"
|
||||
width={860}
|
||||
resizable="both"
|
||||
className={styles.editDialog}
|
||||
footerStart={
|
||||
<span className={styles.footerRun}>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 })));
|
||||
});
|
||||
});
|
||||
@@ -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" }));
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user