From 6eb5584beb4cd19219a14c5618efdbd0c83552c1 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Wed, 4 Nov 2026 01:39:00 +0800 Subject: [PATCH] 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 --- .../settings/pages/Deployment.module.css | 7 ++ src/features/settings/pages/deployment.tsx | 70 ++++++++++++++++--- .../settings/pages/deploymentModel.test.ts | 12 ++++ .../settings/pages/deploymentModel.ts | 9 +++ src/platform/locales/text/zh.json | 3 +- 5 files changed, 91 insertions(+), 10 deletions(-) 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": "请先在“连接”页设置绝对根路径" }