diff --git a/src/features/settings/pages/Deployment.module.css b/src/features/settings/pages/Deployment.module.css index 43cdb3fea..c154f84f0 100644 --- a/src/features/settings/pages/Deployment.module.css +++ b/src/features/settings/pages/Deployment.module.css @@ -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. */ diff --git a/src/features/settings/pages/deployment.tsx b/src/features/settings/pages/deployment.tsx index 0e5f6907a..c8084834e 100644 --- a/src/features/settings/pages/deployment.tsx +++ b/src/features/settings/pages/deployment.tsx @@ -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(null); + const [browsing, setBrowsing] = useState(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 (
@@ -440,15 +460,35 @@ function MappingsTab({ > {MAPPING_COLUMNS.map((c) => ( - update({ mappings: updateMapping(mappings, i, { [c.key]: e.target.value }) })} - /> + + set(i, { [c.key]: e.target.value })} + /> + {c.key === "local" && ( + void chooseLocal(i)} + /> + )} + {c.key === "deployment" && ( + setBrowsing(i)} + /> + )} + ))} @@ -463,6 +503,18 @@ function MappingsTab({ }
+ {browsing !== null && ( + setBrowsing(null)} + onChoose={(path) => { + const rel = folderWithin(remoteRoot, path); + if (rel !== null) set(browsing, { deployment: rel }); + setBrowsing(null); + }} + /> + )}
); } diff --git a/src/features/settings/pages/deploymentModel.test.ts b/src/features/settings/pages/deploymentModel.test.ts index de0525904..9895d52c6 100644 --- a/src/features/settings/pages/deploymentModel.test.ts +++ b/src/features/settings/pages/deploymentModel.test.ts @@ -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(); + }); +}); diff --git a/src/features/settings/pages/deploymentModel.ts b/src/features/settings/pages/deploymentModel.ts index c5359f589..f50d9e83d 100644 --- a/src/features/settings/pages/deploymentModel.ts +++ b/src/features/settings/pages/deploymentModel.ts @@ -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; +} diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 4d6b52798..80ea6fffd 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -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": "请先在“连接”页设置绝对根路径" }