mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-06 08:07:49 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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
@@ -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`. */
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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] : []),
|
||||
|
||||
@@ -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`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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" }));
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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: {},
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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": "将选定的文件和文件夹上传到部署服务器",
|
||||
|
||||
Reference in New Issue
Block a user