From 2d4d6ee6bf80eb7feeababb27342d1d0274605a1 Mon Sep 17 00:00:00 2001 From: Florent V Date: Tue, 3 Nov 2026 16:35:43 +0100 Subject: [PATCH] fix(deployment): accept absolute local paths in mappings and name menu entries after the server (#885) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A mapping's local path may now be an absolute path inside the project (the form IntelliJ writes), and a deployment path of . means the server root; absolute paths outside the project are kept as they are. The project-tree Deployment submenu names the default server (Upload to , Download from , Sync with Deployed to …) and gains Sync with Deployed to Default Server. Remote Host downloads use the backend-resolved mapping, which fixes downloads under symlinked or case-different paths. Fixes #877 Co-authored-by: florentvdry <16673235+florentvdry@users.noreply.github.com> --- crates/ide-core/src/remote_settings.rs | 2 +- crates/ide-host/src/ops/deploy.rs | 15 ++- crates/ide-remote/src/lib.rs | 4 +- crates/ide-remote/src/mapping.rs | 128 ++++++++++++++++++++- crates/ide-remote/src/service.rs | 21 +++- crates/ide-remote/tests/local.rs | 57 +++++++++ src-tauri/src/commands/deploy.rs | 8 +- src/bindings.ts | 16 ++- src/features/deployment/actions.tsx | 16 ++- src/features/deployment/index.ts | 12 +- src/features/deployment/model.test.ts | 30 ++++- src/features/deployment/model.ts | 42 ++++--- src/features/deployment/remoteHost.test.ts | 52 ++++++++- src/features/deployment/remoteHost.ts | 21 ++-- src/features/deployment/store.ts | 4 +- src/features/settings/pages/deployment.tsx | 2 +- src/platform/locales/text/zh.json | 7 +- 17 files changed, 372 insertions(+), 65 deletions(-) diff --git a/crates/ide-core/src/remote_settings.rs b/crates/ide-core/src/remote_settings.rs index 3d1703b6..8e41bc14 100644 --- a/crates/ide-core/src/remote_settings.rs +++ b/crates/ide-core/src/remote_settings.rs @@ -132,7 +132,7 @@ pub struct DeploymentServer { #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)] #[serde(default, rename_all = "camelCase")] pub struct DeploymentMapping { - /// Project-relative folder; empty = the project root. + /// Project-relative folder, or absolute inside the project; empty = the project root. pub local: String, /// Remote folder, relative to the server root (a leading `/` is the root too). pub deployment: String, diff --git a/crates/ide-host/src/ops/deploy.rs b/crates/ide-host/src/ops/deploy.rs index e6b0bf02..982b9acd 100644 --- a/crates/ide-host/src/ops/deploy.rs +++ b/crates/ide-host/src/ops/deploy.rs @@ -1,6 +1,6 @@ use crate::Host; use ide_core::{CancellationToken, IdeEvent, IdeResult, IpcPath, RequestId, SharedSink, TaskId}; -use ide_remote::{DeployedPath, DeploymentCoverage, SyncEntry, SyncStep, TransferReport}; +use ide_remote::{DeployedPath, DeploymentCoverage, DeploymentRoot, SyncEntry, SyncStep, TransferReport}; use ide_ssh::{FsConnection, RemoteEntry}; use std::path::PathBuf; @@ -134,13 +134,12 @@ pub async fn deploy_info( state.remote.deployed(&project.settings.effective(), &project.root, server.as_deref(), &path).await } -/// Remote Host tool window: the server root of `server`, resolved on the host. -pub async fn deploy_server_root(window: &str, state: &Host, server: String) -> IdeResult { - let settings = match state.project(window) { - Ok(p) => p.settings.effective(), - Err(_) => state.global_settings.effective(), - }; - state.remote.server_root(&settings, &server).await +/// Remote Host tool window: the resolved server root and project mappings. +pub async fn deploy_server_root(window: &str, state: &Host, server: String) -> IdeResult { + let project = state.project(window).ok(); + let settings = + project.as_ref().map(|p| p.settings.effective()).unwrap_or_else(|| state.global_settings.effective()); + state.remote.server_info(&settings, project.as_ref().map(|p| p.root.as_path()), &server).await } /// Entries of a folder on the selected filesystem (`~`: its home directory). diff --git a/crates/ide-remote/src/lib.rs b/crates/ide-remote/src/lib.rs index 0ea6407c..378aa5cc 100644 --- a/crates/ide-remote/src/lib.rs +++ b/crates/ide-remote/src/lib.rs @@ -10,6 +10,6 @@ pub mod target; pub use deploy::{ Deployment, DeploymentCoverage, SyncAction, SyncEntry, SyncStatus, SyncStep, TransferReport, coverage, }; -pub use mapping::{Excludes, Mappings}; -pub use service::{DeployedPath, RemoteService}; +pub use mapping::{Excludes, Mappings, ResolvedMapping}; +pub use service::{DeployedPath, DeploymentRoot, RemoteService}; pub use target::{RemoteRuns, RemoteStart}; diff --git a/crates/ide-remote/src/mapping.rs b/crates/ide-remote/src/mapping.rs index 0fbcb0ca..b9bbaefa 100644 --- a/crates/ide-remote/src/mapping.rs +++ b/crates/ide-remote/src/mapping.rs @@ -1,8 +1,11 @@ //! Local ↔ remote paths of a deployment server's mappings, and excluded names. use globset::{Glob, GlobSet, GlobSetBuilder}; +use ide_core::IpcPath; use ide_core::remote_settings::{DeploymentServer, DeploymentSettings}; use ide_ssh::sftp::join; +use serde::{Deserialize, Serialize}; +use specta::Type; use std::path::{Component, Path, PathBuf}; /// A server's mappings resolved against the project and the server root. @@ -14,6 +17,13 @@ pub struct Mappings { web_url: String, } +/// A mapping resolved by the backend for the Remote Host browser. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)] +pub struct ResolvedMapping { + pub local: IpcPath, + pub deployment: String, +} + /// `a/b` (POSIX) of a relative path. pub fn posix(rel: &Path) -> String { rel.components() @@ -25,6 +35,28 @@ pub fn posix(rel: &Path) -> String { .join("/") } +/// The folder of a mapping's local path: relative to the project (a leading `/` is the +/// project root too), or absolute inside it as IntelliJ's deployment settings write it. +/// An existing outside absolute path is skipped unless its project-relative folder exists. +fn local_folder(local: &str, project_root: &Path) -> Option { + let path = Path::new(local); + if path.is_absolute() + && let Ok(rel) = path.strip_prefix(project_root) + { + return Some(project_root.join(rel)); + } + // The same folder spelled differently (symlink, letter case). + if path.is_absolute() + && let (Ok(path), Ok(root)) = (path.canonicalize(), project_root.canonicalize()) + && let Ok(rel) = path.strip_prefix(root) + { + return Some(project_root.join(rel)); + } + let rel = local.trim_matches('/'); + let relative = project_root.join(rel); + if !path.is_absolute() || relative.is_dir() || !path.exists() { Some(relative) } else { None } +} + impl Mappings { /// `remote_root` is the server root resolved on the host (its login /// directory when the setting is empty). A server without mappings maps the @@ -33,16 +65,16 @@ impl Mappings { let mut entries: Vec<(PathBuf, String, String)> = server .mappings .iter() - .map(|m| { - let local = m.local.trim().trim_matches('/'); - let local = if local.is_empty() { project_root.to_path_buf() } else { project_root.join(local) }; + .filter_map(|m| { + let local = local_folder(m.local.trim(), project_root)?; // Relative to the server root, with or without a leading `/`. let deployment = m.deployment.trim().trim_start_matches("./").trim_matches('/'); + let deployment = if deployment == "." { "" } else { deployment }; let remote = if deployment.is_empty() { remote_root.to_owned() } else { join(remote_root, deployment) }; - (local, remote.trim_end_matches('/').to_owned(), m.web.trim().trim_matches('/').to_owned()) + Some((local, remote.trim_end_matches('/').to_owned(), m.web.trim().trim_matches('/').to_owned())) }) .collect(); - if entries.is_empty() { + if server.mappings.is_empty() { entries.push((project_root.to_path_buf(), remote_root.trim_end_matches('/').to_owned(), String::new())); } entries.sort_by_key(|(l, _, _)| std::cmp::Reverse(l.components().count())); @@ -53,6 +85,16 @@ impl Mappings { } } + pub fn resolved(&self) -> Vec { + self.entries + .iter() + .map(|(local, remote, _)| ResolvedMapping { + local: local.clone().into(), + deployment: if remote.is_empty() { "/".into() } else { remote.clone() }, + }) + .collect() + } + pub fn project_root(&self) -> &Path { &self.root } @@ -203,6 +245,82 @@ mod tests { assert_eq!(m.local("/srv/app"), Some(PathBuf::from("/p"))); } + #[test] + fn local_paths_may_be_absolute_inside_the_project() { + let root = Path::new("/p"); + let m = Mappings::new(&server(vec![("/p", "/", ""), ("/p/static/", "cdn", "")]), root, "/mnt/deploy"); + assert_eq!(m.remote(Path::new("/p/src/a.py")).as_deref(), Some("/mnt/deploy/src/a.py")); + assert_eq!(m.remote(Path::new("/p/static/x.css")).as_deref(), Some("/mnt/deploy/cdn/x.css")); + assert_eq!(m.local("/mnt/deploy/cdn/x.css"), Some(PathBuf::from("/p/static/x.css"))); + // A leading `/` that is not the project stays project-relative. + let m = Mappings::new(&server(vec![("/static/", "", "")]), root, "/mnt/deploy"); + assert_eq!(m.remote(Path::new("/p/static/x.css")).as_deref(), Some("/mnt/deploy/x.css")); + } + + #[test] + fn existing_outside_absolute_mapping_does_not_cover_nested_project_files() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join("project"); + let outside = tmp.path().join("outside"); + std::fs::create_dir_all(&root).unwrap(); + std::fs::create_dir(&outside).unwrap(); + let nested = root.join(outside.strip_prefix("/").unwrap()).join("a.txt"); + std::fs::create_dir_all(nested.parent().unwrap().parent().unwrap()).unwrap(); + // A nested project file at the outside path's spelling is not a mapping folder. + std::fs::write(nested.parent().unwrap(), "nested").unwrap(); + let m = Mappings::new(&server(vec![(outside.to_str().unwrap(), "/", "")]), &root, "/srv"); + assert_eq!(local_folder(outside.to_str().unwrap(), &root), None); + assert_eq!(m.remote(&root.join("a.txt")), None); + assert_eq!(m.remote(&outside.join("a.txt")), None); + assert_eq!(m.remote(nested.parent().unwrap()), None); + assert_eq!(m.remote(&nested), None); + assert_eq!(m.local("/srv/a.txt"), None); + assert!(m.resolved().is_empty()); + } + + #[test] + fn local_folder_resolution_prefers_inside_then_existing_relative_then_missing_absolute() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join("project"); + let inside = root.join("src"); + std::fs::create_dir_all(&inside).unwrap(); + assert_eq!(local_folder(inside.to_str().unwrap(), &root), Some(inside)); + assert_eq!(local_folder("/", &root), Some(root.clone())); + let outside = tmp.path().join("outside"); + std::fs::create_dir(&outside).unwrap(); + let relative = root.join(outside.strip_prefix("/").unwrap()); + std::fs::create_dir_all(&relative).unwrap(); + assert_eq!(local_folder(outside.to_str().unwrap(), &root), Some(relative.clone())); + let nested = relative.join("a.txt"); + std::fs::write(&nested, "nested project file").unwrap(); + let m = Mappings::new(&server(vec![(outside.to_str().unwrap(), "/", "")]), &root, "/srv"); + assert_eq!(m.remote(&nested).as_deref(), Some("/srv/a.txt")); + assert_eq!(m.local("/srv/a.txt"), Some(nested)); + let missing = tmp.path().join("missing"); + assert_eq!(local_folder(missing.to_str().unwrap(), &root), Some(root.join(missing.strip_prefix("/").unwrap()))); + assert_eq!(local_folder("/src/", &root), Some(root.join("src"))); + } + + #[cfg(unix)] + #[test] + fn absolute_symlink_inside_project_resolves_to_the_project_spelling() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join("project"); + std::fs::create_dir_all(root.join("src")).unwrap(); + let alias = tmp.path().join("alias"); + std::os::unix::fs::symlink(root.join("src"), &alias).unwrap(); + let m = Mappings::new(&server(vec![(alias.to_str().unwrap(), ".", "")]), &root, "/srv"); + assert_eq!(m.local("/srv/a.txt"), Some(root.join("src/a.txt"))); + assert_eq!(m.resolved()[0].local.to_path_buf(), root.join("src")); + } + + #[test] + fn deployment_dot_is_the_server_root() { + let m = Mappings::new(&server(vec![("", ".", "")]), Path::new("/p"), "/mnt/deploy"); + assert_eq!(m.remote(Path::new("/p/a.txt")).as_deref(), Some("/mnt/deploy/a.txt")); + assert_eq!(m.local("/mnt/deploy/a.txt"), Some(PathBuf::from("/p/a.txt"))); + } + #[test] fn excludes_names_and_remote_paths() { let settings = diff --git a/crates/ide-remote/src/service.rs b/crates/ide-remote/src/service.rs index 12b68293..9b383611 100644 --- a/crates/ide-remote/src/service.rs +++ b/crates/ide-remote/src/service.rs @@ -2,7 +2,7 @@ //! Shared by IPC commands and project save/delete hooks. use crate::deploy::{Deployment, SyncEntry, SyncStep, TransferReport}; -use crate::mapping::{Excludes, Mappings}; +use crate::mapping::{Excludes, Mappings, ResolvedMapping}; use crate::target::{RemoteRuns, RemoteStart, SyncFn}; use ide_core::remote_settings::{AutoUpload, DeploymentServer, DeploymentType, RunTarget}; use ide_core::settings::Settings; @@ -35,6 +35,13 @@ pub struct DeployedPath { pub web_url: Option, } +/// Resolved root and project mappings of the Remote Host browser. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)] +pub struct DeploymentRoot { + pub root: String, + pub mappings: Vec, +} + pub struct RemoteService { ssh: Arc, /// Local copies of remote files, separated by filesystem connection. @@ -216,6 +223,18 @@ impl RemoteService { r } + pub async fn server_info( + &self, + settings: &Settings, + project_root: Option<&Path>, + server_id: &str, + ) -> IdeResult { + let root = self.server_root(settings, server_id).await?; + let server = server(settings, Some(server_id))?; + let mappings = project_root.map(|p| Mappings::new(&server, p, &root).resolved()).unwrap_or_default(); + Ok(DeploymentRoot { root, mappings }) + } + pub async fn list(&self, connection: &FsConnection, path: &str) -> IdeResult> { let fs = self.fs(connection).await?; let r = async { fs.list(&fs.resolve(path).await?).await }.await; diff --git a/crates/ide-remote/tests/local.rs b/crates/ide-remote/tests/local.rs index 91aee56f..53fc296a 100644 --- a/crates/ide-remote/tests/local.rs +++ b/crates/ide-remote/tests/local.rs @@ -112,6 +112,30 @@ async fn local_deployment_and_browser_share_all_paths() { assert!(!remote_file.exists()); } +/// #877: the mapping as IntelliJ shows it, an absolute local path and `/` on the server. +#[tokio::test] +async fn absolute_local_mapping_uploads_like_intellij() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join("project"); + let target = tmp.path().join("target"); + std::fs::create_dir_all(root.join("src")).unwrap(); + std::fs::create_dir(&target).unwrap(); + std::fs::write(root.join("src/a.txt"), "content").unwrap(); + let mut settings = settings(&target); + let mapping = &mut settings.deployment.servers[0].mappings[0]; + mapping.local = root.to_string_lossy().into_owned(); + mapping.deployment = "/".into(); + settings.deployment.servers[0].excluded_paths.clear(); + let file = root.join("src/a.txt"); + assert_eq!(ide_remote::coverage(&settings, &root, std::slice::from_ref(&file))[0].paths.len(), 1); + let report = service(tmp.path()) + .upload(&settings, &root, Some("local"), &[file], CancellationToken::new(), &mut |_, _, _| {}) + .await + .unwrap(); + assert_eq!((report.files, report.skipped), (1, 0), "{report:?}"); + assert_eq!(std::fs::read_to_string(target.join("src/a.txt")).unwrap(), "content"); +} + #[tokio::test] async fn self_upload_download_and_aliases_never_destroy_content() { let tmp = tempfile::tempdir().unwrap(); @@ -262,3 +286,36 @@ async fn coverage_matches_transfers_without_connecting() { assert_eq!(ide_remote::coverage(&settings, &root, &[root.join("assets/b.txt")])[0].paths.len(), 1); assert!(ide_remote::coverage(&settings, &root, &[root.join("../outside")])[0].paths.is_empty()); } + +#[cfg(unix)] +#[tokio::test] +async fn browser_mappings_resolve_symlinks_and_download_to_the_project() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join("project"); + let target = tmp.path().join("mounted"); + let alias = tmp.path().join("project-link"); + std::fs::create_dir(&root).unwrap(); + std::fs::create_dir(&target).unwrap(); + std::os::unix::fs::symlink(&root, &alias).unwrap(); + std::fs::write(target.join("a.txt"), "from the server").unwrap(); + let mut settings = settings(&target); + settings.deployment.servers[0].mappings = vec![DeploymentMapping { + local: alias.to_string_lossy().into_owned(), + deployment: ".".into(), + ..Default::default() + }]; + let service = service(tmp.path()); + let resolved = service.server_info(&settings, Some(&root), "local").await.unwrap(); + assert_eq!(resolved.root, target.to_string_lossy()); + assert_eq!(resolved.mappings.len(), 1); + assert_eq!(resolved.mappings[0].local.to_path_buf(), root); + assert_eq!(resolved.mappings[0].deployment, resolved.root); + let local = resolved.mappings[0].local.to_path_buf().join("a.txt"); + let report = service + .download(&settings, &root, None, std::slice::from_ref(&local), CancellationToken::new(), &mut |_, _, _| {}) + .await + .unwrap(); + assert_eq!(report.files, 1); + assert!(report.errors.is_empty()); + assert_eq!(std::fs::read_to_string(local).unwrap(), "from the server"); +} diff --git a/src-tauri/src/commands/deploy.rs b/src-tauri/src/commands/deploy.rs index ca486655..505f28ed 100644 --- a/src-tauri/src/commands/deploy.rs +++ b/src-tauri/src/commands/deploy.rs @@ -1,6 +1,6 @@ use crate::state::AppState; use ide_core::{IdeResult, IpcPath, RequestId}; -use ide_remote::{DeployedPath, DeploymentCoverage, SyncEntry, SyncStep, TransferReport}; +use ide_remote::{DeployedPath, DeploymentCoverage, DeploymentRoot, SyncEntry, SyncStep, TransferReport}; use ide_ssh::{FsConnection, RemoteEntry}; use tauri::{State, Window}; @@ -86,7 +86,11 @@ pub async fn deploy_info( #[tauri::command] #[specta::specta] -pub async fn deploy_server_root(window: Window, state: State<'_, AppState>, server: String) -> IdeResult { +pub async fn deploy_server_root( + window: Window, + state: State<'_, AppState>, + server: String, +) -> IdeResult { ide_host::ops::deploy::deploy_server_root(window.label(), &state.host, server).await } diff --git a/src/bindings.ts b/src/bindings.ts index fb756b5d..14f693f9 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -1018,7 +1018,7 @@ export const commands = { remotePath: string, webUrl: string | null, } | null, IdeError>(__TAURI_INVOKE("deploy_info", { path, server })), - deployServerRoot: (server: string) => typedError(__TAURI_INVOKE("deploy_server_root", { server })), + deployServerRoot: (server: string) => typedError(__TAURI_INVOKE("deploy_server_root", { server })), remoteList: (connection: FsConnection, path: string) => typedError(__TAURI_INVOKE("remote_list", { connection, path })), deployTestConnection: (connection: FsConnection, path: string) => typedError(__TAURI_INVOKE("deploy_test_connection", { connection, path })), remoteOpen: (connection: FsConnection, path: string) => typedError(__TAURI_INVOKE("remote_open", { connection, path })), @@ -2530,7 +2530,7 @@ export type DeploymentCoverage = { }; export type DeploymentMapping = { - /** Project-relative folder; empty = the project root. */ + /** Project-relative folder, or absolute inside the project; empty = the project root. */ local?: string, /** Remote folder, relative to the server root (a leading `/` is the root too). */ deployment?: string, @@ -2538,6 +2538,12 @@ export type DeploymentMapping = { web?: string, }; +/** Resolved root and project mappings of the Remote Host browser. */ +export type DeploymentRoot = { + root: string, + mappings: ResolvedMapping[], +}; + /** A deployment filesystem and how project folders map onto it. */ export type DeploymentServer = { kind?: DeploymentType, @@ -5345,6 +5351,12 @@ export type RequestId = number; /** `git reset` modes offered by Reset Current Branch to Here. */ export type ResetMode = "soft" | "mixed" | "hard" | "keep"; +/** A mapping resolved by the backend for the Remote Host browser. */ +export type ResolvedMapping = { + local: IpcPath, + deployment: string, +}; + export type ReviewEvent = "approve" | "requestChanges" | "comment"; /** A file changed across several commits: its content at `before` ↔ at `after`. */ diff --git a/src/features/deployment/actions.tsx b/src/features/deployment/actions.tsx index 4e6163fa..9a9dbcfe 100644 --- a/src/features/deployment/actions.tsx +++ b/src/features/deployment/actions.tsx @@ -89,7 +89,9 @@ async function pickServer(title: string, paths: string[]): Promise coverage.some((c) => c.server === server.id && c.paths.length)); if (!servers.length) { - notifyNoCoverage(); + // One server: say why, as an explicit upload to it would. + if (configured.length === 1) notifyNoCoverage(configured[0], coverage[0]?.skipped); + else notifyNoCoverage(); return null; } const labels = servers.map(serverLabel); @@ -368,6 +370,18 @@ export function registerDeploymentActions(): () => void { void pickServer("Sync with Deployed to", targets).then((server) => server && openSync(server, targets)); }, }, + { + id: "deployment.syncWithDefault", + title: "Sync with Deployed to Default Server", + description: "Compare local files with the default deployment server and synchronize them", + category: CATEGORY, + when, + keywords: ["deploy", "sftp", "compare", "synchronize", "remote", "diff"], + run: withTargets(async (paths) => { + const server = await defaultServer("Sync with Deployed to", paths); + if (server) await openSync(server, paths); + }), + }, { id: "deployment.deleteFromServer", title: "Delete from Server", diff --git a/src/features/deployment/index.ts b/src/features/deployment/index.ts index 5493fa6a..31411439 100644 --- a/src/features/deployment/index.ts +++ b/src/features/deployment/index.ts @@ -13,6 +13,7 @@ import { registerMenuGroup, registerOverlay, registerToolWindow } from "@/workbe import { executeAction } from "@/platform/actions"; import { autoUploadOn, registerDeploymentActions } from "./actions"; import { DeploymentDialogs } from "./Dialogs"; +import { transferMenuEntries } from "./model"; import { REMOTE_HOST_WINDOW, useDeployment } from "./store"; /** Deployed URL of the path the last menu was prepared for. */ @@ -32,11 +33,12 @@ function deploymentMenu(paths: string[]): MenuItem[] { label: "Deployment", icon: "web", submenu: [ - { kind: "action", action: "deployment.uploadToDefault", arg }, - { kind: "action", action: "deployment.uploadTo", arg }, - { kind: "action", action: "deployment.downloadFromDefault", arg }, - { kind: "action", action: "deployment.downloadFrom", arg }, - { kind: "action", action: "deployment.syncWith", arg }, + ...transferMenuEntries(workflowSettings().deployment).map(({ action, label }): MenuItem => ({ + kind: "action", + action, + arg, + ...(label ? { label } : {}), + })), { kind: "action", action: "deployment.deleteFromServer", arg }, { kind: "separator" }, ...(webUrl ? [{ kind: "action", action: "deployment.openInBrowser", arg } as MenuItem] : []), diff --git a/src/features/deployment/model.test.ts b/src/features/deployment/model.test.ts index 80a9cc4b..839e6fff 100644 --- a/src/features/deployment/model.test.ts +++ b/src/features/deployment/model.test.ts @@ -12,6 +12,7 @@ import { resolveServer, sshRemedy, summarizeReport, + transferMenuEntries, syncChoices, syncSteps, targetPaths, @@ -102,6 +103,16 @@ describe("servers and reports", () => { expect(resolveServer({ ...DEFAULT_DEPLOYMENT, servers: [a, b], defaultServer: "b" }, "a")?.id).toBe("a"); }); + it("names the menu's transfers after the default server", () => { + const actions = (servers: (typeof a)[], defaultServer: string) => + transferMenuEntries({ ...DEFAULT_DEPLOYMENT, servers, defaultServer }).map((e) => e.label ?? e.action); + const named = ["Upload to Staging", "Download from Staging", "Sync with Deployed to Staging…"]; + const choosers = ["deployment.uploadTo", "deployment.downloadFrom", "deployment.syncWith"]; + expect(actions([a], "")).toEqual(named); + expect(actions([a, b], "a")).toEqual([...named, ...choosers]); + expect(actions([a, b], "")).toEqual(choosers); + }); + it("summarizes transfer reports", () => { expect(summarizeReport({ files: 3, skipped: 1, errors: [] }, "upload", "Staging")).toEqual({ level: "info", @@ -140,16 +151,25 @@ describe("remote paths", () => { it("maps remote paths back to project paths through the most specific mapping", () => { const mappings = [ - { local: "", deployment: "", web: "" }, - { local: "public", deployment: "htdocs", web: "" }, - { local: "shared", deployment: "/opt/shared", web: "" }, + { local: ROOT, deployment: "/var/www" }, + { local: `${ROOT}/public`, deployment: "/var/www/htdocs" }, + { local: `${ROOT}/shared`, deployment: "/var/www/opt/shared" }, ]; - const map = (p: string) => localPathForRemote(p, "/var/www", mappings, ROOT); + const map = (p: string) => localPathForRemote(p, mappings); expect(map("/var/www/index.php")).toBe(`${ROOT}/index.php`); expect(map("/var/www/htdocs/css/a.css")).toBe(`${ROOT}/public/css/a.css`); expect(map("/var/www/htdocs")).toBe(`${ROOT}/public`); - expect(map("/opt/shared/lib")).toBe(`${ROOT}/shared/lib`); + expect(map("/var/www/opt/shared/lib")).toBe(`${ROOT}/shared/lib`); + expect(map("/opt/shared/lib")).toBeNull(); expect(map("/var/wwwx/a")).toBeNull(); expect(map("/etc/passwd")).toBeNull(); }); + + it("uses the backend folder for aliases and does not invent mappings", () => { + const mappings = [{ local: `${ROOT}/src`, deployment: "/mnt/deploy" }]; + expect(localPathForRemote("/mnt/deploy/a.txt", mappings)).toBe(`${ROOT}/src/a.txt`); + expect(localPathForRemote("/mnt/deploy/a.txt", [])).toBeNull(); + expect(localPathForRemote("/mnt/deploy/../a.txt", mappings)).toBeNull(); + expect(localPathForRemote("/a.txt", [{ local: ROOT, deployment: "/" }])).toBe(`${ROOT}/a.txt`); + }); }); diff --git a/src/features/deployment/model.ts b/src/features/deployment/model.ts index b06e649e..c0e9c5a0 100644 --- a/src/features/deployment/model.ts +++ b/src/features/deployment/model.ts @@ -1,5 +1,6 @@ /** Pure helpers of the deployment feature: target paths, sync defaults, reports, remote paths. */ -import type { SyncAction, SyncEntry, SyncStatus, TransferReport } from "@/platform/ipc"; +import { tx } from "@/platform/i18n"; +import type { ResolvedMapping, SyncAction, SyncEntry, SyncStatus, TransferReport } from "@/platform/ipc"; import type { DeploymentServerValid, DeploymentSettingsValid, SshAuthValid } from "@/platform/remoteSettings"; /** Which part had focus last: the project view's selection or the editor's file is the target. */ @@ -87,6 +88,24 @@ export function resolveServer(settings: DeploymentSettingsValid, id: string | nu export const serverLabel = (s: Pick) => s.name.trim() || s.id; +/** Transfer entries of the Deployment menu: named after the default server, choosers when there are several. */ +export function transferMenuEntries(settings: DeploymentSettingsValid): { action: string; label?: string }[] { + const target = resolveServer(settings, null); + const name = target && serverLabel(target); + const named = target + ? [ + { action: "deployment.uploadToDefault", label: tx`Upload to ${name}` }, + { action: "deployment.downloadFromDefault", label: tx`Download from ${name}` }, + { action: "deployment.syncWithDefault", label: tx`Sync with Deployed to ${name}…` }, + ] + : []; + const choosers = + settings.servers.length > 1 || !target + ? [{ action: "deployment.uploadTo" }, { action: "deployment.downloadFrom" }, { action: "deployment.syncWith" }] + : []; + return [...named, ...choosers]; +} + export interface ReportSummary { level: "info" | "warning" | "error"; /** English source text with `{n}` / `{server}` placeholders (translated by the caller). */ @@ -129,28 +148,17 @@ export function remoteName(path: string): string { return trimmed.slice(trimmed.lastIndexOf("/") + 1) || "/"; } -/** - * The local project path a remote path is deployed from, through the server's - * mappings (the most specific deployment path wins); null when not mapped. - * `root` is the server's root path as resolved on the host. - */ -export function localPathForRemote( - remotePath: string, - root: string, - mappings: DeploymentServerValid["mappings"], - projectRoot: string, -): string | null { +/** The most specific backend-resolved mapping of a remote path; null when unmapped. */ +export function localPathForRemote(remotePath: string, mappings: readonly ResolvedMapping[]): string | null { let best: { len: number; local: string } | null = null; for (const m of mappings) { - const deployed = m.deployment.startsWith("/") - ? m.deployment.replace(/\/+$/, "") || "/" - : joinRemote(root, m.deployment); + const deployed = m.deployment; const within = remotePath === deployed || remotePath.startsWith(deployed === "/" ? "/" : `${deployed}/`) ? deployed : null; if (within === null || (best && best.len >= deployed.length)) continue; const rest = remotePath.slice(deployed.length).replace(/^\/+/, ""); - const base = joinRemote(projectRoot, m.local); - best = { len: deployed.length, local: joinRemote(base, rest) }; + if (rest.split("/").some((part) => part === "." || part === "..")) return null; + best = { len: deployed.length, local: joinRemote(m.local, rest) }; } return best?.local ?? null; } diff --git a/src/features/deployment/remoteHost.test.ts b/src/features/deployment/remoteHost.test.ts index 7ef2cfa4..d1ce4ccc 100644 --- a/src/features/deployment/remoteHost.test.ts +++ b/src/features/deployment/remoteHost.test.ts @@ -1,9 +1,16 @@ import { beforeEach, expect, it, vi } from "vitest"; import { DEFAULT_DEPLOYMENT_SERVER } from "@/platform/remoteSettings"; -import { ensureRoot } from "./remoteHost"; +import { downloadToProject, ensureRoot } from "./remoteHost"; import { useDeployment } from "./store"; -const ipc = vi.hoisted(() => ({ root: vi.fn(), list: vi.fn() })); +const ipc = vi.hoisted(() => ({ root: vi.fn(), list: vi.fn(), download: vi.fn(), notify: vi.fn() })); +vi.mock("./actions", () => ({ downloadPaths: ipc.download })); +vi.mock("./ssh", () => ({ withSsh: (_: string, run: () => unknown) => run() })); +vi.mock("@/ui", () => ({ copyText: vi.fn() })); +vi.mock("@/workbench/inputDialogs", () => ({ promptInput: vi.fn() })); +vi.mock("@/workbench/messages", () => ({ confirm: vi.fn() })); +vi.mock("@/platform/project", () => ({ useProject: { getState: () => ({ project: { root: "/project" } }) } })); +vi.mock("@/workbench/contributions", () => ({ notifications: () => ({ notify: ipc.notify }) })); vi.mock("@/platform/ipc", async (original) => ({ ...(await original()), commands: { deployServerRoot: ipc.root, remoteList: ipc.list }, @@ -12,7 +19,7 @@ vi.mock("@/platform/ipc", async (original) => ({ beforeEach(() => { useDeployment.setState({ trees: {} }); vi.clearAllMocks(); - ipc.root.mockResolvedValue({ status: "ok", data: "/srv" }); + ipc.root.mockResolvedValue({ status: "ok", data: { root: "/srv", mappings: [] } }); ipc.list.mockResolvedValue({ status: "ok", data: [] }); }); @@ -25,8 +32,45 @@ it("reloads a server's tree when changing its filesystem type or root", async () const local = { ...server, kind: "local" as const }; await ensureRoot(local); expect(ipc.list).toHaveBeenLastCalledWith({ kind: "local" }, "/srv"); - ipc.root.mockResolvedValue({ status: "ok", data: "/mounted" }); + ipc.root.mockResolvedValue({ status: "ok", data: { root: "/mounted", mappings: [] } }); await ensureRoot({ ...local, rootPath: "/mounted" }); expect(ipc.list).toHaveBeenLastCalledWith({ kind: "local" }, "/mounted"); expect(useDeployment.getState().trees.existing?.root).toBe("/mounted"); }); + +it("refreshes resolved mappings when the local mapping changes", async () => { + const server = { ...DEFAULT_DEPLOYMENT_SERVER, id: "local", kind: "local" as const }; + await ensureRoot(server); + const mappings = [{ local: "/project/src", deployment: "/srv" }]; + ipc.root.mockResolvedValue({ status: "ok", data: { root: "/srv", mappings } }); + await ensureRoot({ ...server, mappings: [{ local: "/alias", deployment: "/", web: "" }] }); + expect(ipc.root).toHaveBeenCalledTimes(2); + expect(useDeployment.getState().trees.local?.mappings).toEqual(mappings); +}); + +it("downloads using backend-resolved folders instead of the configured alias", async () => { + const server = { + ...DEFAULT_DEPLOYMENT_SERVER, + id: "local", + kind: "local" as const, + mappings: [{ local: "/alias", deployment: ".", web: "" }], + }; + const mappings = [{ local: "/project/src", deployment: "/srv" }]; + ipc.root.mockResolvedValue({ status: "ok", data: { root: "/srv", mappings } }); + const entry = { + path: "/srv/a.txt", + name: "a.txt", + isDir: false, + isSymlink: false, + size: 5, + mtime: null, + permissions: "rw-r--r--", + }; + await downloadToProject(server, [entry]); + expect(ipc.download).toHaveBeenCalledWith(server, ["/project/src/a.txt"]); + ipc.download.mockClear(); + ipc.root.mockResolvedValue({ status: "ok", data: { root: "/srv", mappings: [] } }); + await downloadToProject(server, [entry]); + expect(ipc.download).not.toHaveBeenCalled(); + expect(ipc.notify).toHaveBeenCalledWith(expect.objectContaining({ title: "Not mapped to a project path" })); +}); diff --git a/src/features/deployment/remoteHost.ts b/src/features/deployment/remoteHost.ts index 34a90091..60fa6ee5 100644 --- a/src/features/deployment/remoteHost.ts +++ b/src/features/deployment/remoteHost.ts @@ -14,7 +14,8 @@ import { EMPTY_TREE, patchTree, useDeployment } from "./store"; const errorText = (e: unknown) => txTemplate(e instanceof Error ? e.message : String(e)); -const sourceKey = (server: DeploymentServerValid) => JSON.stringify([deploymentConnection(server), server.rootPath]); +const sourceKey = (server: DeploymentServerValid) => + JSON.stringify([deploymentConnection(server), server.rootPath, server.mappings, useProject.getState().project?.root]); const tree = (server: string) => useDeployment.getState().trees[server] ?? EMPTY_TREE; @@ -46,7 +47,7 @@ export async function loadFolder(server: DeploymentServerValid, path: string) { } } -/** Resolves the server root once per session and lists it. */ +/** Resolves the server root and mappings, then lists the root. */ export async function ensureRoot(server: DeploymentServerValid, force = false) { const source = sourceKey(server); if (tree(server.id).source !== source) patchTree(server.id, { ...EMPTY_TREE, source }); @@ -54,15 +55,15 @@ export async function ensureRoot(server: DeploymentServerValid, force = false) { if (current.root && !force && current.listings[current.root]) return; patchTree(server.id, { error: null }); try { - const root = + const resolved = current.root && !force - ? current.root + ? { root: current.root, mappings: current.mappings } : await withSsh(server.kind === "sftp" ? server.sshConfig : "", () => call(commands.deployServerRoot(server.id)), ); if (tree(server.id).source !== source) return; - patchTree(server.id, { root }); - await loadFolder(server, root); + patchTree(server.id, resolved); + await loadFolder(server, resolved.root); } catch (e) { if (tree(server.id).source !== source) return; patchTree(server.id, { error: errorText(e) }); @@ -158,12 +159,14 @@ export async function deleteEntries(server: DeploymentServerValid, entries: Remo /** Downloads mapped entries to their local paths; unmapped ones are reported. */ export async function downloadToProject(server: DeploymentServerValid, entries: RemoteEntry[]) { const projectRoot = useProject.getState().project?.root; - const root = tree(server.id).root; - if (!projectRoot || !root) return; + if (!projectRoot) return; + await ensureRoot(server, true); + const { root, mappings } = tree(server.id); + if (!root || tree(server.id).error) return; const locals: string[] = []; const unmapped: string[] = []; for (const e of entries) { - const local = localPathForRemote(e.path, root, server.mappings, projectRoot); + const local = localPathForRemote(e.path, mappings); if (local) locals.push(local); else unmapped.push(remoteName(e.path)); } diff --git a/src/features/deployment/store.ts b/src/features/deployment/store.ts index 7d7dce02..382d28c5 100644 --- a/src/features/deployment/store.ts +++ b/src/features/deployment/store.ts @@ -1,6 +1,6 @@ /** Session state of the deployment feature: the Sync dialog, the SSH secret prompt and the Remote Host tree. */ import { create } from "zustand"; -import type { TransferReport, RemoteEntry, RequestId, SyncEntry } from "@/platform/ipc"; +import type { TransferReport, ResolvedMapping, RemoteEntry, RequestId, SyncEntry } from "@/platform/ipc"; import type { FocusedPart, SyncChoice } from "./model"; export const REMOTE_HOST_WINDOW = "remoteHost"; @@ -29,6 +29,7 @@ export interface SecretPrompt { export interface RemoteTreeState { source: string | null; root: string | null; + mappings: ResolvedMapping[]; error: string | null; listings: Record; folderErrors: Record; @@ -56,6 +57,7 @@ export const useDeployment = create(() => ({ export const EMPTY_TREE: RemoteTreeState = { source: null, root: null, + mappings: [], error: null, listings: {}, folderErrors: {}, diff --git a/src/features/settings/pages/deployment.tsx b/src/features/settings/pages/deployment.tsx index 53378a7e..0e5f6907 100644 --- a/src/features/settings/pages/deployment.tsx +++ b/src/features/settings/pages/deployment.tsx @@ -459,7 +459,7 @@ function MappingsTab({
{ - "Local paths are relative to the project root (empty: the whole project); deployment paths are relative to the server's root path." + "Local paths are relative to the project root or absolute inside it (empty: the whole project); deployment paths are relative to the server's root path." }
diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index d24144b1..1a8863ab 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -4294,7 +4294,7 @@ "Use as Default": "设为默认", "Mappings actions": "映射操作", "No mappings: nothing is deployed": "没有映射:不会部署任何内容", - "Local paths are relative to the project root (empty: the whole project); deployment paths are relative to the server's root path.": "本地路径相对于项目根目录(为空表示整个项目);部署路径相对于服务器的根路径。", + "Local paths are relative to the project root or absolute inside it (empty: the whole project); deployment paths are relative to the server's root path.": "本地路径相对于项目根目录,或为项目内的绝对路径(为空表示整个项目);部署路径相对于服务器的根路径。", "No excluded paths": "没有排除的路径", "Excluded remote paths": "排除的远程路径", "Remote paths that are never uploaded to, downloaded from or deleted.": "这些远程路径永远不会被上传、下载或删除。", @@ -4309,6 +4309,11 @@ "Delete remote files when local are deleted": "删除本地文件时删除远程文件", "Preserve files timestamps": "保留文件时间戳", "Upload to Default Server": "上传到默认服务器", + "Upload to {server}": "上传到 {server}", + "Download from {server}": "从 {server} 下载", + "Sync with Deployed to {server}…": "与 {server} 上已部署的同步…", + "Sync with Deployed to Default Server": "与默认服务器上已部署的同步", + "Compare local files with the default deployment server and synchronize them": "将本地文件与默认部署服务器比较并同步", "Upload the selected files and folders to the default deployment server": "将选定的文件和文件夹上传到默认部署服务器", "Upload to…": "上传到…", "Upload the selected files and folders to a deployment server": "将选定的文件和文件夹上传到部署服务器",