fix(deployment): accept absolute local paths in mappings and name menu entries after the server (#885)

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 <server>, Download from <server>, Sync with Deployed to <server>…) 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>
This commit is contained in:
Florent V
2026-11-03 23:35:43 +08:00
committed by GitHub
co-authored by florentvdry
parent 0396f0d552
commit 2d4d6ee6bf
17 changed files with 372 additions and 65 deletions
+1 -1
View File
@@ -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,
+7 -8
View File
@@ -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<String> {
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<DeploymentRoot> {
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).
+2 -2
View File
@@ -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};
+123 -5
View File
@@ -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<PathBuf> {
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<ResolvedMapping> {
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 =
+20 -1
View File
@@ -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<String>,
}
/// 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<ResolvedMapping>,
}
pub struct RemoteService {
ssh: Arc<SshManager>,
/// 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<DeploymentRoot> {
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<Vec<RemoteEntry>> {
let fs = self.fs(connection).await?;
let r = async { fs.list(&fs.resolve(path).await?).await }.await;
+57
View File
@@ -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");
}
+6 -2
View File
@@ -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<String> {
pub async fn deploy_server_root(
window: Window,
state: State<'_, AppState>,
server: String,
) -> IdeResult<DeploymentRoot> {
ide_host::ops::deploy::deploy_server_root(window.label(), &state.host, server).await
}
+14 -2
View File
@@ -1018,7 +1018,7 @@ export const commands = {
remotePath: string,
webUrl: string | null,
} | null, IdeError>(__TAURI_INVOKE("deploy_info", { path, server })),
deployServerRoot: (server: string) => typedError<string, IdeError>(__TAURI_INVOKE("deploy_server_root", { server })),
deployServerRoot: (server: string) => typedError<DeploymentRoot, IdeError>(__TAURI_INVOKE("deploy_server_root", { server })),
remoteList: (connection: FsConnection, path: string) => typedError<RemoteEntry[], IdeError>(__TAURI_INVOKE("remote_list", { connection, path })),
deployTestConnection: (connection: FsConnection, path: string) => typedError<null, IdeError>(__TAURI_INVOKE("deploy_test_connection", { connection, path })),
remoteOpen: (connection: FsConnection, path: string) => typedError<IpcPath, IdeError>(__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`. */
+15 -1
View File
@@ -89,7 +89,9 @@ async function pickServer(title: string, paths: string[]): Promise<DeploymentSer
if (!coverage) return null;
const servers = configured.filter((server) => 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",
+7 -5
View File
@@ -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] : []),
+25 -5
View File
@@ -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`);
});
});
+25 -17
View File
@@ -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<DeploymentServerValid, "id" | "name">) => 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;
}
+48 -4
View File
@@ -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<typeof import("@/platform/ipc")>()),
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" }));
});
+12 -9
View File
@@ -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));
}
+3 -1
View File
@@ -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<string, RemoteEntry[]>;
folderErrors: Record<string, string>;
@@ -56,6 +57,7 @@ export const useDeployment = create<DeploymentState>(() => ({
export const EMPTY_TREE: RemoteTreeState = {
source: null,
root: null,
mappings: [],
error: null,
listings: {},
folderErrors: {},
+1 -1
View File
@@ -459,7 +459,7 @@ function MappingsTab({
<div className={styles.note}>
<T>
{
"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."
}
</T>
</div>
+6 -1
View File
@@ -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": "将选定的文件和文件夹上传到部署服务器",