mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-08 08:52:12 +00:00
feat(deployment): browse for the folders of a mapping (#1078)
Mapping paths had to be typed. The local path and deployment path cells now have a folder button: the local one picks a folder inside the project, the deployment one opens the server's folder chooser at its root path, and both fill in the path relative to their root (the deployment button needs an absolute root path). Fixes #799
This commit is contained in:
1 parent
555226a9bf
commit
6eb5584beb
5 files changed
+91
-10
No files matched your search
@@ -123,8 +123,15 @@
|
||||
.mappingTable tbody tr.selectedRow {
|
||||
background: var(--bg-tree-selected);
|
||||
}
|
||||
.cellField {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
.cellInput {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Remote folder chooser. */
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useEffect, useId, useState, type KeyboardEvent } from "react";
|
||||
import clsx from "clsx";
|
||||
import { call, commands, IpcError, type FsConnection, type RemoteEntry } from "@/platform/ipc";
|
||||
import { useTx, tx, txTemplate } from "@/platform/i18n";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { deploymentConnection, type DeploymentServerValid } from "@/platform/remoteSettings";
|
||||
import { Badge, Button, Checkbox, Dialog, Icon, IconButton, Input, Select, Spinner, T, Tabs } from "@/ui";
|
||||
import { GlobListEditor, Row } from "../components/Form";
|
||||
@@ -15,6 +16,7 @@ import { useSetting } from "../components/context";
|
||||
import {
|
||||
addMapping,
|
||||
fixDefaultServer,
|
||||
folderWithin,
|
||||
formatExcludes,
|
||||
newServer,
|
||||
parseExcludes,
|
||||
@@ -22,6 +24,7 @@ import {
|
||||
removeMapping,
|
||||
updateMapping,
|
||||
} from "./deploymentModel";
|
||||
import { choosePath } from "../languages/api";
|
||||
import { BUILD_GROUP } from "./execution";
|
||||
import type { PageSpec } from "./spec";
|
||||
import { sshTestRemedy } from "./sshConnection";
|
||||
@@ -373,6 +376,8 @@ function ConnectionTab({
|
||||
);
|
||||
}
|
||||
|
||||
const isAbsolute = (p: string) => p.startsWith("/") || /^[A-Za-z]:[\\/]/.test(p);
|
||||
|
||||
const MAPPING_COLUMNS = [
|
||||
{ key: "local", title: "Local path", placeholder: "Project root" },
|
||||
{ key: "deployment", title: "Deployment path", placeholder: "Root path" },
|
||||
@@ -388,7 +393,22 @@ function MappingsTab({
|
||||
}) {
|
||||
const tx = useTx();
|
||||
const [selected, setSelected] = useState<number | null>(null);
|
||||
const [browsing, setBrowsing] = useState<number | null>(null);
|
||||
const projectRoot = useProject((s) => s.project?.root ?? null);
|
||||
const mappings = server.mappings;
|
||||
const remoteRoot = server.rootPath.trim().replace(/\/+$/, "");
|
||||
// Deployment paths are relative to the root path, so the remote chooser needs an absolute one.
|
||||
const canBrowseRemote = isAbsolute(remoteRoot) && (server.kind !== "sftp" || !!server.sshConfig);
|
||||
const set = (i: number, patch: Partial<(typeof mappings)[number]>) =>
|
||||
update({ mappings: updateMapping(mappings, i, patch) });
|
||||
const chooseLocal = async (i: number) => {
|
||||
if (!projectRoot) return;
|
||||
const current = mappings[i]?.local.trim() ?? "";
|
||||
const start = isAbsolute(current) ? current : current ? `${projectRoot}/${current}` : projectRoot;
|
||||
const picked = await choosePath({ directory: true, title: "Choose Folder", defaultPath: start });
|
||||
const rel = picked === null ? null : folderWithin(projectRoot, picked);
|
||||
if (rel !== null) set(i, { local: rel });
|
||||
};
|
||||
return (
|
||||
<div className={styles.panel}>
|
||||
<div className={workflow.tableBox}>
|
||||
@@ -440,15 +460,35 @@ function MappingsTab({
|
||||
>
|
||||
{MAPPING_COLUMNS.map((c) => (
|
||||
<td key={c.key}>
|
||||
<Input
|
||||
mono
|
||||
variant="bare"
|
||||
className={styles.cellInput}
|
||||
value={m[c.key]}
|
||||
placeholder={tx(c.placeholder)}
|
||||
aria-label={`${tx(c.title)} ${i + 1}`}
|
||||
onChange={(e) => update({ mappings: updateMapping(mappings, i, { [c.key]: e.target.value }) })}
|
||||
/>
|
||||
<span className={styles.cellField}>
|
||||
<Input
|
||||
mono
|
||||
variant="bare"
|
||||
className={styles.cellInput}
|
||||
value={m[c.key]}
|
||||
placeholder={tx(c.placeholder)}
|
||||
aria-label={`${tx(c.title)} ${i + 1}`}
|
||||
onChange={(e) => set(i, { [c.key]: e.target.value })}
|
||||
/>
|
||||
{c.key === "local" && (
|
||||
<IconButton
|
||||
icon="folder-open"
|
||||
size="small"
|
||||
title="Browse…"
|
||||
disabled={!projectRoot}
|
||||
onClick={() => void chooseLocal(i)}
|
||||
/>
|
||||
)}
|
||||
{c.key === "deployment" && (
|
||||
<IconButton
|
||||
icon="folder-open"
|
||||
size="small"
|
||||
title={canBrowseRemote ? "Browse…" : "Set an absolute root path on the Connection tab first"}
|
||||
disabled={!canBrowseRemote}
|
||||
onClick={() => setBrowsing(i)}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
@@ -463,6 +503,18 @@ function MappingsTab({
|
||||
}
|
||||
</T>
|
||||
</div>
|
||||
{browsing !== null && (
|
||||
<RemoteFolderDialog
|
||||
connection={deploymentConnection(server)}
|
||||
initial={[remoteRoot, mappings[browsing]?.deployment.trim().replace(/^\/+/, "")].filter(Boolean).join("/")}
|
||||
onClose={() => setBrowsing(null)}
|
||||
onChoose={(path) => {
|
||||
const rel = folderWithin(remoteRoot, path);
|
||||
if (rel !== null) set(browsing, { deployment: rel });
|
||||
setBrowsing(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { DEFAULT_DEPLOYMENT_SERVER } from "@/platform/remoteSettings";
|
||||
import {
|
||||
addMapping,
|
||||
fixDefaultServer,
|
||||
folderWithin,
|
||||
formatExcludes,
|
||||
newServer,
|
||||
nextServerName,
|
||||
@@ -83,3 +84,14 @@ describe("server list", () => {
|
||||
expect(remoteParent("/var")).toBe("/");
|
||||
});
|
||||
});
|
||||
|
||||
describe("chosen mapping folders", () => {
|
||||
it("are relative to the project or the server's root path", () => {
|
||||
expect(folderWithin("/work/shop", "/work/shop/web/public")).toBe("web/public");
|
||||
expect(folderWithin("/var/www/", "/var/www/site")).toBe("site");
|
||||
expect(folderWithin("/var/www", "/var/www/")).toBe("");
|
||||
expect(folderWithin("/var/www", "/var/wwwroot")).toBeNull();
|
||||
expect(folderWithin("/var/www", "/srv")).toBeNull();
|
||||
expect(folderWithin("", "/srv")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -79,3 +79,12 @@ export function remoteParent(path: string): string {
|
||||
const i = trimmed.lastIndexOf("/");
|
||||
return i <= 0 ? "/" : trimmed.slice(0, i);
|
||||
}
|
||||
|
||||
/** `path` relative to the folder `root` ("" for `root` itself), for a chosen mapping folder; null outside it. */
|
||||
export function folderWithin(root: string, path: string): string | null {
|
||||
const r = root.replace(/\/+$/, "");
|
||||
const p = path.replace(/\/+$/, "");
|
||||
if (!r) return null;
|
||||
if (p === r) return "";
|
||||
return p.startsWith(`${r}/`) ? p.slice(r.length + 1) : null;
|
||||
}
|
||||
@@ -5705,5 +5705,6 @@
|
||||
"Session cost": "会话花费",
|
||||
"What the session has cost so far, as the agent reports it (API pricing, also shown on subscriptions)": "智能体报告的本会话目前花费(按 API 价格计算,订阅账户也会显示)",
|
||||
"Build and run using": "构建和运行使用",
|
||||
"Gradle runs a main class as a JavaExec task on its source set's runtime classpath (Gradle's resource processing included). OpenIDEA launches it on the Java language server's classpath.": "Gradle 以 JavaExec 任务在源集的运行时类路径上运行主类(包括 Gradle 的资源处理)。OpenIDEA 在 Java 语言服务器的类路径上启动它。"
|
||||
"Gradle runs a main class as a JavaExec task on its source set's runtime classpath (Gradle's resource processing included). OpenIDEA launches it on the Java language server's classpath.": "Gradle 以 JavaExec 任务在源集的运行时类路径上运行主类(包括 Gradle 的资源处理)。OpenIDEA 在 Java 语言服务器的类路径上启动它。",
|
||||
"Set an absolute root path on the Connection tab first": "请先在“连接”页设置绝对根路径"
|
||||
}
|
||||
Reference in new issue
Block a user