diff --git a/crates/ide-core/src/remote_settings.rs b/crates/ide-core/src/remote_settings.rs index 771c30735..3d1703b6f 100644 --- a/crates/ide-core/src/remote_settings.rs +++ b/crates/ide-core/src/remote_settings.rs @@ -103,10 +103,19 @@ impl Default for DeploymentSettings { } } -/// An SFTP server and how project folders map onto it. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub enum DeploymentType { + #[default] + Sftp, + Local, +} + +/// A deployment filesystem and how project folders map onto it. #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)] #[serde(default, rename_all = "camelCase")] pub struct DeploymentServer { + pub kind: DeploymentType, pub id: String, pub name: String, /// [`SshConfiguration::id`]. @@ -148,3 +157,23 @@ pub struct RunTarget { pub node: String, pub java: String, } + +#[cfg(test)] +mod deployment_tests { + use super::*; + + #[test] + fn deployment_type_defaults_and_round_trips() { + let legacy: DeploymentServer = + serde_json::from_str(r#"{"id":"existing","sshConfig":"ssh","rootPath":"/srv"}"#).unwrap(); + assert_eq!(legacy.kind, DeploymentType::Sftp); + assert_eq!(legacy.ssh_config, "ssh"); + for kind in [DeploymentType::Sftp, DeploymentType::Local] { + let server = DeploymentServer { kind, ..legacy.clone() }; + assert_eq!( + serde_json::from_str::(&serde_json::to_string(&server).unwrap()).unwrap(), + server + ); + } + } +} diff --git a/crates/ide-host/src/ops/deploy.rs b/crates/ide-host/src/ops/deploy.rs new file mode 100644 index 000000000..1b843995a --- /dev/null +++ b/crates/ide-host/src/ops/deploy.rs @@ -0,0 +1,200 @@ +use crate::Host; +use ide_core::{CancellationToken, IdeEvent, IdeResult, IpcPath, RequestId, SharedSink, TaskId}; +use ide_remote::{DeployedPath, SyncEntry, SyncStep, TransferReport}; +use ide_ssh::{FsConnection, RemoteEntry}; +use std::path::PathBuf; + +/// Reports a transfer as a background task (status bar progress) whose Cancel fires `token`. +fn progress( + sink: SharedSink, + title: &str, + token: CancellationToken, +) -> (impl FnMut(&str, usize, usize) + Send, impl FnOnce()) { + let id = TaskId::next(); + let cancel = token; + let handle = ide_core::task::on_cancel(id, move || cancel.cancel()); + let title = title.to_owned(); + let (s, t) = (sink.clone(), title.clone()); + let report = move |path: &str, i: usize, n: usize| { + let fraction = if n == 0 { None } else { Some(i as f32 / n as f32) }; + let message = Some(path.to_owned()); + s.emit(IdeEvent::Task { id, title: t.clone(), message, fraction, done: false, cancellable: true }); + }; + let done = move || { + drop(handle); + sink.emit(IdeEvent::Task { id, title, message: None, fraction: Some(1.0), done: true, cancellable: false }) + }; + (report, done) +} + +fn local(paths: Vec) -> Vec { + paths.into_iter().map(|p| p.to_path_buf()).collect() +} + +/// Tools › Deployment › Upload to…: files and folders to `server` (default server when `None`). +pub async fn deploy_upload( + window: &str, + state: &Host, + paths: Vec, + server: Option, + request: RequestId, +) -> IdeResult { + let project = state.project(window)?; + let token = state.begin_request(request); + let (mut report, done) = progress(project.sink.clone(), "Uploading", token.clone()); + let r = state + .remote + .upload(&project.settings.effective(), &project.root, server.as_deref(), &local(paths), token, &mut report) + .await; + done(); + state.end_request(request); + r +} + +/// Download from…: the remote counterparts of files and folders. +pub async fn deploy_download( + window: &str, + state: &Host, + paths: Vec, + server: Option, + request: RequestId, +) -> IdeResult { + let project = state.project(window)?; + let token = state.begin_request(request); + let (mut report, done) = progress(project.sink.clone(), "Downloading", token.clone()); + let r = state + .remote + .download(&project.settings.effective(), &project.root, server.as_deref(), &local(paths), token, &mut report) + .await; + done(); + state.end_request(request); + r +} + +/// Deletes the remote counterparts of files and folders. +pub async fn deploy_delete_remote( + window: &str, + state: &Host, + paths: Vec, + server: Option, +) -> IdeResult { + let project = state.project(window)?; + state.remote.delete_remote(&project.settings.effective(), &project.root, server.as_deref(), &local(paths)).await +} + +/// Sync with Deployed: local and remote files under `path`, side by side. +pub async fn deploy_compare( + window: &str, + state: &Host, + path: IpcPath, + server: Option, + request: RequestId, +) -> IdeResult> { + let project = state.project(window)?; + let token = state.begin_request(request); + let r = state.remote.compare(&project.settings.effective(), &project.root, server.as_deref(), &path, token).await; + state.end_request(request); + r +} + +/// Applies the Sync with Deployed choices. +pub async fn deploy_sync( + window: &str, + state: &Host, + steps: Vec, + server: Option, + request: RequestId, +) -> IdeResult { + let project = state.project(window)?; + let token = state.begin_request(request); + let (mut report, done) = progress(project.sink.clone(), "Synchronizing", token.clone()); + let r = state + .remote + .sync(&project.settings.effective(), &project.root, server.as_deref(), &steps, token, &mut report) + .await; + done(); + state.end_request(request); + r +} + +/// Where `path` is deployed (remote path, web URL); `None` when not mapped. +pub async fn deploy_info( + window: &str, + state: &Host, + path: IpcPath, + server: Option, +) -> IdeResult> { + let project = state.project(window)?; + 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 +} + +/// Entries of a folder on the selected filesystem (`~`: its home directory). +pub async fn remote_list(state: &Host, connection: FsConnection, path: String) -> IdeResult> { + state.remote.list(&connection, &path).await +} + +/// Opens a remote file for editing: a local copy, uploaded back on save. +pub async fn remote_open(state: &Host, connection: FsConnection, path: String) -> IdeResult { + Ok(state.remote.open_remote(&connection, &path).await?.into()) +} + +/// The filesystem connection and path a cached editing copy stands for. +pub fn remote_of(state: &Host, path: IpcPath) -> Option<(FsConnection, String)> { + state.remote.remote_of(&path) +} + +pub async fn remote_delete(state: &Host, connection: FsConnection, path: String) -> IdeResult<()> { + state.remote.remote_delete(&connection, &path).await +} + +pub async fn remote_mkdir(state: &Host, connection: FsConnection, path: String) -> IdeResult<()> { + state.remote.remote_mkdir(&connection, &path).await +} + +pub async fn remote_rename(state: &Host, connection: FsConnection, from: String, to: String) -> IdeResult<()> { + state.remote.remote_rename(&connection, &from, &to).await +} + +pub async fn deploy_test_connection(state: &Host, connection: FsConnection, path: String) -> IdeResult<()> { + state.remote.test_connection(&connection, &path).await +} + +pub(crate) fn register(d: &mut crate::rpc::Dispatcher) { + d.add("deploy_upload", |c| async move { + deploy_upload(&c.window, &c.host, c.arg("paths")?, c.arg("server")?, c.arg("request")?).await + }); + d.add("deploy_download", |c| async move { + deploy_download(&c.window, &c.host, c.arg("paths")?, c.arg("server")?, c.arg("request")?).await + }); + d.add("deploy_delete_remote", |c| async move { + deploy_delete_remote(&c.window, &c.host, c.arg("paths")?, c.arg("server")?).await + }); + d.add("deploy_compare", |c| async move { + deploy_compare(&c.window, &c.host, c.arg("path")?, c.arg("server")?, c.arg("request")?).await + }); + d.add("deploy_sync", |c| async move { + deploy_sync(&c.window, &c.host, c.arg("steps")?, c.arg("server")?, c.arg("request")?).await + }); + d.add("deploy_info", |c| async move { deploy_info(&c.window, &c.host, c.arg("path")?, c.arg("server")?).await }); + d.add("deploy_server_root", |c| async move { deploy_server_root(&c.window, &c.host, c.arg("server")?).await }); + d.add("remote_list", |c| async move { remote_list(&c.host, c.arg("connection")?, c.arg("path")?).await }); + d.add("remote_open", |c| async move { remote_open(&c.host, c.arg("connection")?, c.arg("path")?).await }); + d.add("remote_of", |c| async move { Ok(remote_of(&c.host, c.arg("path")?)) }); + d.add("remote_delete", |c| async move { remote_delete(&c.host, c.arg("connection")?, c.arg("path")?).await }); + d.add("remote_mkdir", |c| async move { remote_mkdir(&c.host, c.arg("connection")?, c.arg("path")?).await }); + d.add("remote_rename", |c| async move { + remote_rename(&c.host, c.arg("connection")?, c.arg("from")?, c.arg("to")?).await + }); + d.add("deploy_test_connection", |c| async move { + deploy_test_connection(&c.host, c.arg("connection")?, c.arg("path")?).await + }); +} diff --git a/crates/ide-host/src/ops/mod.rs b/crates/ide-host/src/ops/mod.rs index 854b0cfc5..228d02fcd 100644 --- a/crates/ide-host/src/ops/mod.rs +++ b/crates/ide-host/src/ops/mod.rs @@ -8,6 +8,7 @@ pub mod build; pub mod bytes; pub mod coverage; pub mod debug; +pub mod deploy; pub mod doc; pub mod env; pub mod fs; @@ -81,6 +82,7 @@ pub fn register_all(d: &mut crate::rpc::Dispatcher) { search::register(d); run::register(d); env::register(d); + deploy::register(d); } impl Host { diff --git a/crates/ide-host/tests/rpc.rs b/crates/ide-host/tests/rpc.rs index 2455476f3..ade529267 100644 --- a/crates/ide-host/tests/rpc.rs +++ b/crates/ide-host/tests/rpc.rs @@ -191,3 +191,69 @@ impl EventType for EventMessage { serde_json::to_value(&self.event).unwrap()["type"].as_str().unwrap_or_default().to_owned() } } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn local_deployment_runs_on_the_project_host() { + let tmp = tempfile::tempdir().unwrap(); + let project = tmp.path().join("project"); + let target = tmp.path().join("mounted"); + std::fs::create_dir(&project).unwrap(); + std::fs::create_dir(&target).unwrap(); + let project = dunce::canonicalize(project).unwrap(); + let file = project.join("local.txt"); + std::fs::write(&file, "host file").unwrap(); + let slot = Arc::new(OnceLock::new()); + let sink_slot = slot.clone(); + let host = Arc::new(Host::new(HostConfig { + config_dir: tmp.path().join("config"), + data_dir: tmp.path().join("data"), + languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()), + syntax: Arc::default(), + sink_for: Box::new(move |w| rpc::event_sink(sink_slot.clone(), Some(w.to_owned()))), + owner: |w| w, + runtime: tokio::runtime::Handle::current(), + project_environment: false, + })); + let dispatcher = Dispatcher::standard(); + for cmd in [ + "deploy_upload", + "deploy_download", + "deploy_delete_remote", + "deploy_compare", + "deploy_sync", + "deploy_info", + "deploy_server_root", + "deploy_test_connection", + "remote_list", + "remote_open", + "remote_of", + "remote_delete", + "remote_mkdir", + "remote_rename", + ] { + assert!(dispatcher.commands().contains(&cmd), "missing host command {cmd}"); + } + let (client_io, server_io) = tokio::io::duplex(1 << 20); + let (sr, sw) = tokio::io::split(server_io); + tokio::spawn(rpc::serve(host, dispatcher, slot, sr, sw)); + let (cr, cw) = tokio::io::split(client_io); + let peer = Peer::connect(cr, cw, Arc::new(Client::default())); + invoke(&peer, "open_project", json!({"path": project})).await.unwrap(); + let deployment = json!({"servers": [{"id":"local", "kind":"local", "rootPath":target}], "defaultServer":"local"}); + invoke(&peer, "settings_set", json!({"scope":"project", "key":"deployment", "value":deployment.to_string()})) + .await + .unwrap(); + invoke(&peer, "deploy_test_connection", json!({"connection":{"kind":"local"}, "path":target})).await.unwrap(); + let report = invoke(&peer, "deploy_upload", json!({"paths":[file], "request":1001})).await.unwrap(); + assert_eq!(report["files"], 1, "{report}"); + assert_eq!(std::fs::read_to_string(target.join("local.txt")).unwrap(), "host file"); + let entries = invoke(&peer, "remote_list", json!({"connection":{"kind":"local"}, "path":target})).await.unwrap(); + assert_eq!(entries[0]["name"], "local.txt"); + std::fs::write(target.join("local.txt"), "host download").unwrap(); + let report = invoke(&peer, "deploy_download", json!({"paths":[file], "request":1002})).await.unwrap(); + assert_eq!(report["files"], 1); + assert_eq!(std::fs::read_to_string(&file).unwrap(), "host download"); + invoke(&peer, "deploy_delete_remote", json!({"paths":[file]})).await.unwrap(); + assert!(!target.join("local.txt").exists()); + invoke(&peer, "close_project", json!({})).await.unwrap(); +} diff --git a/crates/ide-remote/src/deploy.rs b/crates/ide-remote/src/deploy.rs index 81887a01d..712b9c197 100644 --- a/crates/ide-remote/src/deploy.rs +++ b/crates/ide-remote/src/deploy.rs @@ -1,5 +1,5 @@ //! Upload, download, delete and synchronise project files with a deployment -//! server over SFTP. +//! server through a shared filesystem interface. use crate::mapping::{Excludes, Mappings, posix}; use ide_core::{CancellationToken, IdeError, IdeResult}; @@ -96,7 +96,7 @@ pub fn status(local: Option, remote: Option) -> SyncStatus } } -/// Operations on one server through one SFTP session. +/// Operations on one server through its filesystem. pub struct Deployment<'a> { pub fs: &'a RemoteFs, pub map: &'a Mappings, diff --git a/crates/ide-remote/src/service.rs b/crates/ide-remote/src/service.rs index 9be7444b5..12b682936 100644 --- a/crates/ide-remote/src/service.rs +++ b/crates/ide-remote/src/service.rs @@ -1,14 +1,14 @@ -//! The deployment and run-target operations of one app, over the shared SSH -//! connections: what the IPC commands and the save hook call. +//! Deployment over SFTP or local folders, and SSH run targets. +//! Shared by IPC commands and project save/delete hooks. use crate::deploy::{Deployment, SyncEntry, SyncStep, TransferReport}; use crate::mapping::{Excludes, Mappings}; use crate::target::{RemoteRuns, RemoteStart, SyncFn}; -use ide_core::remote_settings::{AutoUpload, DeploymentServer, RunTarget}; +use ide_core::remote_settings::{AutoUpload, DeploymentServer, DeploymentType, RunTarget}; use ide_core::settings::Settings; use ide_core::{CancellationToken, IdeError, IdeResult, SharedSink}; use ide_run::{OutputChunk, RunConfig, RunInfo}; -use ide_ssh::{RemoteEntry, RemoteFs, SshManager}; +use ide_ssh::{FsConnection, RemoteEntry, RemoteFs, SshManager}; use parking_lot::Mutex; use serde::{Deserialize, Serialize}; use specta::Type; @@ -22,7 +22,7 @@ pub type Progress<'a> = &'a mut (dyn FnMut(&str, usize, usize) + Send); /// A remote file opened for editing (a local copy uploaded back on save). #[derive(Debug, Clone)] struct RemoteEdit { - ssh_config: String, + connection: FsConnection, remote: String, } @@ -37,7 +37,7 @@ pub struct DeployedPath { pub struct RemoteService { ssh: Arc, - /// Local copies of remote files: `//`. + /// Local copies of remote files, separated by filesystem connection. cache: PathBuf, edits: Mutex>, pub runs: RemoteRuns, @@ -71,12 +71,24 @@ impl RemoteService { Self { ssh, cache, edits: Mutex::default(), runs: RemoteRuns::new() } } - async fn fs(&self, ssh_config: &str) -> IdeResult { - if ssh_config.is_empty() { - return Err(IdeError::invalid("the deployment server has no SSH configuration")); + async fn fs(&self, connection: &FsConnection) -> IdeResult { + match connection { + FsConnection::Local => Ok(RemoteFs::Local), + FsConnection::Sftp { ssh } => { + if ssh.is_empty() { + return Err(IdeError::invalid("the deployment server has no SSH configuration")); + } + let session = self.ssh.session(ssh).await?; + Ok(RemoteFs::new(session.sftp().await?)) + } } - let session = self.ssh.session(ssh_config).await?; - Ok(RemoteFs::new(session.sftp().await?)) + } + + pub async fn test_connection(&self, connection: &FsConnection, path: &str) -> IdeResult<()> { + let fs = self.fs(connection).await?; + let result = fs.test(path).await; + fs.close().await; + result } /// Runs `f` with a [`Deployment`] on `server`. @@ -88,7 +100,7 @@ impl RemoteService { cancel: CancellationToken, f: impl AsyncFnOnce(&Deployment<'_>) -> T, ) -> IdeResult { - let fs = self.fs(&server.ssh_config).await?; + let fs = self.fs(&connection(server)).await?; let remote_root = match fs.resolve(&server.root_path).await { Ok(r) => r, Err(e) => { @@ -198,54 +210,57 @@ impl RemoteService { /// The server root of `server_id`, resolved on the host. pub async fn server_root(&self, settings: &Settings, server_id: &str) -> IdeResult { let server = server(settings, Some(server_id))?; - let fs = self.fs(&server.ssh_config).await?; + let fs = self.fs(&connection(&server)).await?; let r = fs.resolve(&server.root_path).await; fs.close().await; r } - pub async fn list(&self, ssh_config: &str, path: &str) -> IdeResult> { - let fs = self.fs(ssh_config).await?; + 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; fs.close().await; r } /// Downloads a remote file for editing; saving the copy uploads it back. - pub async fn open_remote(&self, ssh_config: &str, remote: &str) -> IdeResult { - let rel = remote.split('/').filter(|s| !s.is_empty() && *s != "..").fold(PathBuf::new(), |p, s| p.join(s)); - let local = self.cache.join(sanitize(ssh_config)).join(rel); - let fs = self.fs(ssh_config).await?; + pub async fn open_remote(&self, connection: &FsConnection, remote: &str) -> IdeResult { + let rel = remote + .split('/') + .filter(|s| !s.is_empty() && *s != ".." && *s != ".") + .fold(PathBuf::new(), |p, s| p.join(s.replace('%', "%25").replace(':', "%3A").replace('\\', "%5C"))); + let local = self.cache.join(sanitize(&cache_key(connection))).join(rel); + let fs = self.fs(connection).await?; let r = fs.download(remote, &local).await; fs.close().await; r?; self.edits .lock() - .insert(local.clone(), RemoteEdit { ssh_config: ssh_config.to_owned(), remote: remote.to_owned() }); + .insert(local.clone(), RemoteEdit { connection: connection.clone(), remote: remote.to_owned() }); Ok(local) } /// The remote file a local copy stands for. - pub fn remote_of(&self, local: &Path) -> Option<(String, String)> { - self.edits.lock().get(local).map(|e| (e.ssh_config.clone(), e.remote.clone())) + pub fn remote_of(&self, local: &Path) -> Option<(FsConnection, String)> { + self.edits.lock().get(local).map(|e| (e.connection.clone(), e.remote.clone())) } - pub async fn remote_delete(&self, ssh_config: &str, remote: &str) -> IdeResult<()> { - let fs = self.fs(ssh_config).await?; + pub async fn remote_delete(&self, connection: &FsConnection, remote: &str) -> IdeResult<()> { + let fs = self.fs(connection).await?; let r = fs.remove(remote).await; fs.close().await; r } - pub async fn remote_mkdir(&self, ssh_config: &str, remote: &str) -> IdeResult<()> { - let fs = self.fs(ssh_config).await?; + pub async fn remote_mkdir(&self, connection: &FsConnection, remote: &str) -> IdeResult<()> { + let fs = self.fs(connection).await?; let r = fs.mkdir_all(remote).await; fs.close().await; r } - pub async fn remote_rename(&self, ssh_config: &str, from: &str, to: &str) -> IdeResult<()> { - let fs = self.fs(ssh_config).await?; + pub async fn remote_rename(&self, connection: &FsConnection, from: &str, to: &str) -> IdeResult<()> { + let fs = self.fs(connection).await?; let r = fs.rename(from, to).await; fs.close().await; r @@ -366,3 +381,18 @@ impl RemoteService { fn sanitize(s: &str) -> String { s.chars().map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' }).collect() } + +/// A server's transport, also used by the browser and remote editor save hook. +pub fn connection(server: &DeploymentServer) -> FsConnection { + match server.kind { + DeploymentType::Local => FsConnection::Local, + DeploymentType::Sftp => FsConnection::Sftp { ssh: server.ssh_config.clone() }, + } +} + +fn cache_key(connection: &FsConnection) -> String { + match connection { + FsConnection::Local => "local".to_owned(), + FsConnection::Sftp { ssh } => format!("sftp-{ssh}"), + } +} diff --git a/crates/ide-remote/tests/local.rs b/crates/ide-remote/tests/local.rs new file mode 100644 index 000000000..cb6540579 --- /dev/null +++ b/crates/ide-remote/tests/local.rs @@ -0,0 +1,211 @@ +//! Local deployment uses the service entry points used by SFTP and save hooks. +use ide_core::CancellationToken; +use ide_core::remote_settings::{AutoUpload, DeploymentMapping, DeploymentServer, DeploymentType}; +use ide_core::settings::Settings; +use ide_remote::{RemoteService, SyncAction, SyncStatus, SyncStep}; +use ide_ssh::{FsConnection, SshFiles, SshManager}; +use std::path::Path; +use std::sync::Arc; + +fn service(root: &Path) -> RemoteService { + RemoteService::new( + Arc::new(SshManager::with_files( + root.join("secrets"), + SshFiles { known_hosts: root.join("known_hosts"), config: None }, + )), + root.join("cache"), + ) +} + +fn settings(target: &Path) -> Settings { + let mut s = Settings::default(); + s.deployment.servers.push(DeploymentServer { + id: "local".into(), + kind: DeploymentType::Local, + root_path: target.to_string_lossy().into_owned(), + mappings: vec![DeploymentMapping { deployment: "app".into(), ..Default::default() }], + excluded_paths: vec!["app/private".into()], + ..Default::default() + }); + s.deployment.default_server = "local".into(); + s.deployment.auto_upload = AutoUpload::Always; + s.deployment.delete_remote = true; + s +} + +#[tokio::test] +async fn local_deployment_and_browser_share_all_paths() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join("project"); + let target = tmp.path().join("mounted"); + std::fs::create_dir_all(root.join("src/nested")).unwrap(); + std::fs::create_dir_all(root.join(".git")).unwrap(); + std::fs::create_dir_all(root.join("private")).unwrap(); + std::fs::create_dir(&target).unwrap(); + std::fs::write(root.join("src/nested/a.txt"), "initial").unwrap(); + std::fs::write(root.join(".git/config"), "excluded").unwrap(); + std::fs::write(root.join("private/key"), "excluded remote path").unwrap(); + let service = service(tmp.path()); + let settings = settings(&target); + let cancel = CancellationToken::new(); + service.test_connection(&FsConnection::Local, target.to_str().unwrap()).await.unwrap(); + let report = service + .upload(&settings, &root, None, std::slice::from_ref(&root), cancel.clone(), &mut |_, _, _| {}) + .await + .unwrap(); + assert_eq!(report.files, 1, "{report:?}"); + assert!(report.errors.is_empty(), "{report:?}"); + assert!(!target.join("app/.git").exists()); + assert!(!target.join("app/private").exists()); + assert_eq!( + ide_ssh::sftp::mtime_of(&root.join("src/nested/a.txt")), + ide_ssh::sftp::mtime_of(&target.join("app/src/nested/a.txt")) + ); + let compared = service.compare(&settings, &root, None, &root, cancel.clone()).await.unwrap(); + assert_eq!(compared.iter().find(|e| e.path == "src/nested/a.txt").unwrap().status, SyncStatus::Same); + std::fs::write(root.join("src/nested/a.txt"), "saved").unwrap(); + let saved = service.on_saved(&settings, &root, &root.join("src/nested/a.txt")).await.unwrap().unwrap(); + assert!(saved.errors.is_empty()); + let remote_file = target.join("app/src/nested/a.txt"); + assert_eq!(std::fs::read_to_string(&remote_file).unwrap(), "saved"); + std::fs::write(&remote_file, "downloaded").unwrap(); + let report = + service.download(&settings, &root, None, &[root.join("src")], cancel.clone(), &mut |_, _, _| {}).await.unwrap(); + assert!(report.errors.is_empty()); + assert_eq!(std::fs::read_to_string(root.join("src/nested/a.txt")).unwrap(), "downloaded"); + let connection = FsConnection::Local; + let entries = service.list(&connection, target.join("app/src/nested").to_str().unwrap()).await.unwrap(); + assert_eq!(entries[0].name, "a.txt"); + let cached = service.open_remote(&connection, remote_file.to_str().unwrap()).await.unwrap(); + std::fs::write(&cached, "remote edit").unwrap(); + assert!(service.on_saved(&settings, &root, &cached).await.unwrap().unwrap().errors.is_empty()); + assert_eq!(std::fs::read_to_string(&remote_file).unwrap(), "remote edit"); + service.remote_mkdir(&connection, target.join("browser/new/nested").to_str().unwrap()).await.unwrap(); + service + .remote_rename(&connection, target.join("browser").to_str().unwrap(), target.join("renamed").to_str().unwrap()) + .await + .unwrap(); + service.remote_delete(&connection, target.join("renamed").to_str().unwrap()).await.unwrap(); + assert!(!target.join("renamed").exists()); + let report = service + .sync( + &settings, + &root, + None, + &[SyncStep { path: "src/nested/a.txt".into(), action: SyncAction::Download }], + cancel, + &mut |_, _, _| {}, + ) + .await + .unwrap(); + assert_eq!(report.files, 1); + std::fs::remove_file(root.join("src/nested/a.txt")).unwrap(); + assert!( + service + .on_deleted(&settings, &root, &[root.join("src/nested/a.txt")]) + .await + .unwrap() + .unwrap() + .errors + .is_empty() + ); + assert!(!remote_file.exists()); +} + +#[tokio::test] +async fn self_upload_download_and_aliases_never_destroy_content() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let service = service(root); + let mut settings = settings(root); + settings.deployment.servers[0].mappings[0].deployment.clear(); + let file = root.join("file"); + std::fs::write(&file, "keep me").unwrap(); + for download in [false, true] { + let report = if download { + service + .download( + &settings, + root, + None, + std::slice::from_ref(&file), + CancellationToken::new(), + &mut |_, _, _| {}, + ) + .await + } else { + service + .upload(&settings, root, None, std::slice::from_ref(&file), CancellationToken::new(), &mut |_, _, _| {}) + .await + } + .unwrap(); + assert!(report.errors[0].message.contains("same file")); + assert_eq!(std::fs::read_to_string(&file).unwrap(), "keep me"); + } + #[cfg(unix)] + { + let alias = root.join("alias"); + std::fs::hard_link(&file, &alias).unwrap(); + assert!( + ide_ssh::RemoteFs::Local + .upload(&file, alias.to_str().unwrap(), true) + .await + .unwrap_err() + .message + .contains("same file") + ); + std::fs::remove_file(&alias).unwrap(); + std::os::unix::fs::symlink(&file, &alias).unwrap(); + assert!( + ide_ssh::RemoteFs::Local + .upload(&file, alias.to_str().unwrap(), true) + .await + .unwrap_err() + .message + .contains("same file") + ); + ide_ssh::RemoteFs::Local.remove(alias.to_str().unwrap()).await.unwrap(); + assert_eq!(std::fs::read_to_string(&file).unwrap(), "keep me"); + } +} + +#[cfg(unix)] +#[tokio::test] +async fn missing_non_directory_and_unwritable_targets_are_clear() { + use std::os::unix::fs::PermissionsExt; + let tmp = tempfile::tempdir().unwrap(); + let service = service(tmp.path()); + let target = tmp.path().join("target"); + assert!( + service + .test_connection(&FsConnection::Local, target.to_str().unwrap()) + .await + .unwrap_err() + .message + .contains("does not exist") + ); + std::fs::write(&target, "file").unwrap(); + assert!( + service + .test_connection(&FsConnection::Local, target.to_str().unwrap()) + .await + .unwrap_err() + .message + .contains("Not a folder") + ); + std::fs::remove_file(&target).unwrap(); + std::fs::create_dir(&target).unwrap(); + std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o555)).unwrap(); + let result = service.test_connection(&FsConnection::Local, target.to_str().unwrap()).await; + let file = tmp.path().join("source"); + std::fs::write(&file, "data").unwrap(); + let mut settings = settings(&target); + settings.deployment.servers[0].mappings[0].deployment.clear(); + let report = service + .upload(&settings, tmp.path(), None, &[file], CancellationToken::new(), &mut |_, _, _| {}) + .await + .unwrap(); + std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o755)).unwrap(); + assert!(result.unwrap_err().message.contains("not writable")); + assert_eq!(report.errors.len(), 1, "{report:?}"); +} diff --git a/crates/ide-ssh/src/filesystem.rs b/crates/ide-ssh/src/filesystem.rs new file mode 100644 index 000000000..04bdbed7c --- /dev/null +++ b/crates/ide-ssh/src/filesystem.rs @@ -0,0 +1,274 @@ +//! Filesystem operations shared by deployment and remote development bootstrap. +use crate::sftp::{RemoteEntry, SftpFs}; +use ide_core::{IdeError, IdeResult}; +use russh_sftp::client::SftpSession; +use serde::{Deserialize, Serialize}; +use specta::Type; +use std::path::Path; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(tag = "kind", rename_all = "camelCase")] +pub enum FsConnection { + Sftp { ssh: String }, + Local, +} + +pub enum RemoteFs { + Sftp(SftpFs), + Local, +} + +impl RemoteFs { + /// Keeps SSH bootstrap callers explicitly on SFTP. + pub fn new(sftp: SftpSession) -> Self { + Self::Sftp(SftpFs::new(sftp)) + } + + pub async fn home(&self) -> IdeResult { + match self { + Self::Sftp(fs) => fs.home().await, + Self::Local => { + dirs::home_dir().map(|p| path_string(&p)).ok_or_else(|| IdeError::invalid("Home folder is unavailable")) + } + } + } + + pub async fn resolve(&self, path: &str) -> IdeResult { + match self { + Self::Sftp(fs) => fs.resolve(path).await, + Self::Local => { + let expanded = crate::expand(path.trim()); + let path = if path.trim().is_empty() || path.trim() == "~" { + std::path::PathBuf::from(self.home().await?) + } else { + expanded + }; + let path = if path.is_absolute() { path } else { std::env::current_dir()?.join(path) }; + Ok(path_string(&path)) + } + } + } + + pub async fn stat(&self, path: &str) -> IdeResult> { + match self { + Self::Sftp(fs) => fs.stat(path).await, + Self::Local => match tokio::fs::metadata(path).await { + Ok(meta) => Ok(Some(local_entry(path, meta).await?)), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(e.into()), + }, + } + } + + pub async fn list(&self, dir: &str) -> IdeResult> { + match self { + Self::Sftp(fs) => fs.list(dir).await, + Self::Local => { + let mut read = tokio::fs::read_dir(dir).await?; + let mut entries = Vec::new(); + while let Some(e) = read.next_entry().await? { + let path = path_string(&e.path()); + entries.push(local_entry(&path, tokio::fs::symlink_metadata(e.path()).await?).await?); + } + entries.sort_by(|a, b| { + b.is_dir.cmp(&a.is_dir).then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase())) + }); + Ok(entries) + } + } + } + + pub async fn read(&self, path: &str) -> IdeResult> { + match self { + Self::Sftp(fs) => fs.read(path).await, + Self::Local => Ok(tokio::fs::read(path).await?), + } + } + + pub async fn write(&self, path: &str, data: &[u8], mtime: Option) -> IdeResult<()> { + match self { + Self::Sftp(fs) => fs.write(path, data, mtime).await, + Self::Local => { + if let Some(parent) = Path::new(path).parent() { + tokio::fs::create_dir_all(parent).await?; + } + tokio::fs::write(path, data).await?; + if let Some(t) = mtime { + set_mtime(path, t).await; + } + Ok(()) + } + } + } + + pub async fn upload(&self, local: &Path, remote: &str, preserve_time: bool) -> IdeResult<()> { + match self { + Self::Sftp(fs) => fs.upload(local, remote, preserve_time).await, + Self::Local => { + reject_same_file(local, Path::new(remote)).await?; + copy_file(local, Path::new(remote)).await?; + if let Some(t) = preserve_time.then(|| crate::sftp::mtime_of(local)).flatten() { + set_mtime(remote, t).await; + } + Ok(()) + } + } + } + + pub async fn download(&self, remote: &str, local: &Path) -> IdeResult<()> { + match self { + Self::Sftp(fs) => fs.download(remote, local).await, + Self::Local => { + reject_same_file(Path::new(remote), local).await?; + copy_file(Path::new(remote), local).await + } + } + } + + pub async fn mkdir_all(&self, path: &str) -> IdeResult<()> { + match self { + Self::Sftp(fs) => fs.mkdir_all(path).await, + Self::Local => Ok(tokio::fs::create_dir_all(path).await?), + } + } + + pub async fn remove(&self, path: &str) -> IdeResult<()> { + match self { + Self::Sftp(fs) => fs.remove(path).await, + Self::Local => { + let meta = tokio::fs::symlink_metadata(path).await?; + if meta.is_dir() { + tokio::fs::remove_dir_all(path).await?; + } else { + tokio::fs::remove_file(path).await?; + } + Ok(()) + } + } + } + + pub async fn rename(&self, from: &str, to: &str) -> IdeResult<()> { + match self { + Self::Sftp(fs) => fs.rename(from, to).await, + Self::Local => Ok(tokio::fs::rename(from, to).await?), + } + } + + /// Probe actual write access, including ACLs and read-only mounts. + pub async fn test(&self, path: &str) -> IdeResult<()> { + let path = self.resolve(path).await?; + let entry = + self.stat(&path).await?.ok_or_else(|| IdeError::not_found(format!("Folder does not exist: {path}")))?; + if !entry.is_dir { + return Err(IdeError::invalid(format!("Not a folder: {path}"))); + } + if matches!(self, Self::Local) { + let name = format!( + ".openidea-write-test-{}-{}", + std::process::id(), + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_nanos() + ); + let probe = Path::new(&path).join(name); + let mut file = + tokio::fs::OpenOptions::new().write(true).create_new(true).open(&probe).await.map_err(|e| { + IdeError::new( + ide_core::error::ErrorKind::PermissionDenied, + format!("Folder is not writable: {path}: {e}"), + ) + })?; + use tokio::io::AsyncWriteExt; + let result = file.write_all(b"test").await; + drop(file); + let cleanup = tokio::fs::remove_file(probe).await; + result.and(cleanup).map_err(|e| { + IdeError::new( + ide_core::error::ErrorKind::PermissionDenied, + format!("Folder is not writable: {path}: {e}"), + ) + })?; + } else { + self.list(&path).await?; + } + Ok(()) + } + + pub async fn close(&self) { + if let Self::Sftp(fs) = self { + fs.close().await; + } + } +} + +/// Copies without holding the file in memory: deployments can include large archives. +async fn copy_file(from: &Path, to: &Path) -> IdeResult<()> { + if let Some(parent) = to.parent() { + tokio::fs::create_dir_all(parent).await?; + } + tokio::fs::copy(from, to).await?; + Ok(()) +} + +async fn set_mtime(path: &str, seconds: u32) { + let result = async { + let file = tokio::fs::OpenOptions::new().write(true).open(path).await?.into_std().await; + file.set_modified(std::time::UNIX_EPOCH + std::time::Duration::from_secs(u64::from(seconds))) + }; + if let Err(e) = result.await { + tracing::debug!("Cannot set the time of {path}: {e}"); + } +} + +fn path_string(path: &Path) -> String { + let path = path.to_string_lossy().into_owned(); + #[cfg(windows)] + let path = path.replace('\\', "/"); + path +} + +async fn local_entry(path: &str, meta: std::fs::Metadata) -> IdeResult { + #[cfg(unix)] + let permissions = { + use std::os::unix::fs::PermissionsExt; + crate::sftp::mode_string(meta.permissions().mode()) + }; + #[cfg(not(unix))] + let permissions = if meta.permissions().readonly() { "r--r--r--" } else { "rw-rw-rw-" }.to_owned(); + let is_dir = meta.is_dir() || (meta.is_symlink() && tokio::fs::metadata(path).await.is_ok_and(|m| m.is_dir())); + Ok(RemoteEntry { + name: Path::new(path).file_name().unwrap_or_default().to_string_lossy().into_owned(), + path: path.to_owned(), + is_dir, + is_symlink: meta.is_symlink(), + size: meta.len(), + mtime: meta + .modified() + .ok() + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .and_then(|t| t.as_secs().try_into().ok()), + permissions, + }) +} + +async fn reject_same_file(from: &Path, to: &Path) -> IdeResult<()> { + let source = tokio::fs::metadata(from).await?; + let target = match tokio::fs::metadata(to).await { + Ok(m) => m, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(e) => return Err(e.into()), + }; + #[cfg(unix)] + let same = { + use std::os::unix::fs::MetadataExt; + source.dev() == target.dev() && source.ino() == target.ino() + }; + #[cfg(not(unix))] + let same = { + let _ = (source, target); + tokio::fs::canonicalize(from).await? == tokio::fs::canonicalize(to).await? + }; + if same { + Err(IdeError::invalid(format!("Source and destination are the same file: {}", from.display()))) + } else { + Ok(()) + } +} diff --git a/crates/ide-ssh/src/lib.rs b/crates/ide-ssh/src/lib.rs index 1ab25a185..c5790b281 100644 --- a/crates/ide-ssh/src/lib.rs +++ b/crates/ide-ssh/src/lib.rs @@ -3,6 +3,7 @@ //! processes, SFTP, local port forwarding, and askpass for `ssh` / `git`. pub mod askpass; +pub mod filesystem; pub mod git_creds; pub mod manager; pub mod secrets; @@ -14,9 +15,10 @@ pub mod target; pub mod testing; pub mod tunnel; +pub use filesystem::{FsConnection, RemoteFs}; pub use manager::SshManager; pub use russh::Sig; pub use session::{ConnectError, ExecOutput, Output, RemoteProcess, Session, UnknownHostKey}; -pub use sftp::{RemoteEntry, RemoteFs}; +pub use sftp::RemoteEntry; pub use target::{SshFiles, SshTarget, expand, openssh_args}; pub use tunnel::Tunnel; diff --git a/crates/ide-ssh/src/sftp.rs b/crates/ide-ssh/src/sftp.rs index c14c14463..38a8fde74 100644 --- a/crates/ide-ssh/src/sftp.rs +++ b/crates/ide-ssh/src/sftp.rs @@ -41,7 +41,7 @@ pub fn parent(path: &str) -> Option<&str> { Some(if i == 0 { "/" } else { &trimmed[..i] }) } -fn mode_string(mode: u32) -> String { +pub(crate) fn mode_string(mode: u32) -> String { let bits = [ (0o400, 'r'), (0o200, 'w'), @@ -78,11 +78,11 @@ fn err(path: &str, e: SftpError) -> IdeError { } /// Higher-level operations on an [`SftpSession`]. -pub struct RemoteFs { +pub struct SftpFs { sftp: SftpSession, } -impl RemoteFs { +impl SftpFs { pub fn new(sftp: SftpSession) -> Self { Self { sftp } } diff --git a/src-tauri/src/commands/deploy.rs b/src-tauri/src/commands/deploy.rs index 22df06480..be959bde2 100644 --- a/src-tauri/src/commands/deploy.rs +++ b/src-tauri/src/commands/deploy.rs @@ -1,38 +1,9 @@ use crate::state::AppState; -use ide_core::{CancellationToken, IdeEvent, IdeResult, IpcPath, RequestId, SharedSink, TaskId}; +use ide_core::{IdeResult, IpcPath, RequestId}; use ide_remote::{DeployedPath, SyncEntry, SyncStep, TransferReport}; -use ide_ssh::RemoteEntry; -use std::path::PathBuf; +use ide_ssh::{FsConnection, RemoteEntry}; use tauri::{State, Window}; -/// Reports a transfer as a background task (status bar progress) whose Cancel fires `token`. -fn progress( - sink: SharedSink, - title: &str, - token: CancellationToken, -) -> (impl FnMut(&str, usize, usize) + Send, impl FnOnce()) { - let id = TaskId::next(); - let cancel = token; - let handle = ide_core::task::on_cancel(id, move || cancel.cancel()); - let title = title.to_owned(); - let (s, t) = (sink.clone(), title.clone()); - let report = move |path: &str, i: usize, n: usize| { - let fraction = if n == 0 { None } else { Some(i as f32 / n as f32) }; - let message = Some(path.to_owned()); - s.emit(IdeEvent::Task { id, title: t.clone(), message, fraction, done: false, cancellable: true }); - }; - let done = move || { - drop(handle); - sink.emit(IdeEvent::Task { id, title, message: None, fraction: Some(1.0), done: true, cancellable: false }) - }; - (report, done) -} - -fn local(paths: Vec) -> Vec { - paths.into_iter().map(|p| p.to_path_buf()).collect() -} - -/// Tools › Deployment › Upload to…: files and folders to `server` (default server when `None`). #[tauri::command] #[specta::specta] pub async fn deploy_upload( @@ -42,19 +13,9 @@ pub async fn deploy_upload( server: Option, request: RequestId, ) -> IdeResult { - let project = state.project(window.label())?; - let token = state.begin_request(request); - let (mut report, done) = progress(project.sink.clone(), "Uploading", token.clone()); - let r = state - .remote - .upload(&project.settings.effective(), &project.root, server.as_deref(), &local(paths), token, &mut report) - .await; - done(); - state.end_request(request); - r + ide_host::ops::deploy::deploy_upload(window.label(), &state.host, paths, server, request).await } -/// Download from…: the remote counterparts of files and folders. #[tauri::command] #[specta::specta] pub async fn deploy_download( @@ -64,19 +25,9 @@ pub async fn deploy_download( server: Option, request: RequestId, ) -> IdeResult { - let project = state.project(window.label())?; - let token = state.begin_request(request); - let (mut report, done) = progress(project.sink.clone(), "Downloading", token.clone()); - let r = state - .remote - .download(&project.settings.effective(), &project.root, server.as_deref(), &local(paths), token, &mut report) - .await; - done(); - state.end_request(request); - r + ide_host::ops::deploy::deploy_download(window.label(), &state.host, paths, server, request).await } -/// Deletes the remote counterparts of files and folders. #[tauri::command] #[specta::specta] pub async fn deploy_delete_remote( @@ -85,11 +36,9 @@ pub async fn deploy_delete_remote( paths: Vec, server: Option, ) -> IdeResult { - let project = state.project(window.label())?; - state.remote.delete_remote(&project.settings.effective(), &project.root, server.as_deref(), &local(paths)).await + ide_host::ops::deploy::deploy_delete_remote(window.label(), &state.host, paths, server).await } -/// Sync with Deployed: local and remote files under `path`, side by side. #[tauri::command] #[specta::specta] pub async fn deploy_compare( @@ -99,14 +48,9 @@ pub async fn deploy_compare( server: Option, request: RequestId, ) -> IdeResult> { - let project = state.project(window.label())?; - let token = state.begin_request(request); - let r = state.remote.compare(&project.settings.effective(), &project.root, server.as_deref(), &path, token).await; - state.end_request(request); - r + ide_host::ops::deploy::deploy_compare(window.label(), &state.host, path, server, request).await } -/// Applies the Sync with Deployed choices. #[tauri::command] #[specta::specta] pub async fn deploy_sync( @@ -116,19 +60,9 @@ pub async fn deploy_sync( server: Option, request: RequestId, ) -> IdeResult { - let project = state.project(window.label())?; - let token = state.begin_request(request); - let (mut report, done) = progress(project.sink.clone(), "Synchronizing", token.clone()); - let r = state - .remote - .sync(&project.settings.effective(), &project.root, server.as_deref(), &steps, token, &mut report) - .await; - done(); - state.end_request(request); - r + ide_host::ops::deploy::deploy_sync(window.label(), &state.host, steps, server, request).await } -/// Where `path` is deployed (remote path, web URL); `None` when not mapped. #[tauri::command] #[specta::specta] pub async fn deploy_info( @@ -137,56 +71,66 @@ pub async fn deploy_info( path: IpcPath, server: Option, ) -> IdeResult> { - let project = state.project(window.label())?; - state.remote.deployed(&project.settings.effective(), &project.root, server.as_deref(), &path).await + ide_host::ops::deploy::deploy_info(window.label(), &state.host, path, server).await } -/// Remote Host tool window: the server root of `server`, resolved on the host. #[tauri::command] #[specta::specta] pub async fn deploy_server_root(window: Window, state: State<'_, AppState>, server: String) -> IdeResult { - let settings = match state.project(window.label()) { - Ok(p) => p.settings.effective(), - Err(_) => state.global_settings.effective(), - }; - state.remote.server_root(&settings, &server).await -} - -/// Entries of a remote folder of SSH configuration `ssh` (`~`: the login directory). -#[tauri::command] -#[specta::specta] -pub async fn remote_list(state: State<'_, AppState>, ssh: String, path: String) -> IdeResult> { - state.remote.list(&ssh, &path).await -} - -/// Opens a remote file for editing: a local copy, uploaded back on save. -#[tauri::command] -#[specta::specta] -pub async fn remote_open(state: State<'_, AppState>, ssh: String, path: String) -> IdeResult { - Ok(state.remote.open_remote(&ssh, &path).await?.into()) -} - -/// The remote file a local copy stands for: (SSH configuration id, remote path). -#[tauri::command] -#[specta::specta] -pub fn remote_of(state: State<'_, AppState>, path: IpcPath) -> Option<(String, String)> { - state.remote.remote_of(&path) + ide_host::ops::deploy::deploy_server_root(window.label(), &state.host, server).await } #[tauri::command] #[specta::specta] -pub async fn remote_delete(state: State<'_, AppState>, ssh: String, path: String) -> IdeResult<()> { - state.remote.remote_delete(&ssh, &path).await +pub async fn remote_list( + state: State<'_, AppState>, + connection: FsConnection, + path: String, +) -> IdeResult> { + ide_host::ops::deploy::remote_list(&state.host, connection, path).await } #[tauri::command] #[specta::specta] -pub async fn remote_mkdir(state: State<'_, AppState>, ssh: String, path: String) -> IdeResult<()> { - state.remote.remote_mkdir(&ssh, &path).await +pub async fn remote_open(state: State<'_, AppState>, connection: FsConnection, path: String) -> IdeResult { + ide_host::ops::deploy::remote_open(&state.host, connection, path).await } #[tauri::command] #[specta::specta] -pub async fn remote_rename(state: State<'_, AppState>, ssh: String, from: String, to: String) -> IdeResult<()> { - state.remote.remote_rename(&ssh, &from, &to).await +pub fn remote_of(state: State<'_, AppState>, path: IpcPath) -> Option<(FsConnection, String)> { + ide_host::ops::deploy::remote_of(&state.host, path) +} + +#[tauri::command] +#[specta::specta] +pub async fn remote_delete(state: State<'_, AppState>, connection: FsConnection, path: String) -> IdeResult<()> { + ide_host::ops::deploy::remote_delete(&state.host, connection, path).await +} + +#[tauri::command] +#[specta::specta] +pub async fn remote_mkdir(state: State<'_, AppState>, connection: FsConnection, path: String) -> IdeResult<()> { + ide_host::ops::deploy::remote_mkdir(&state.host, connection, path).await +} + +#[tauri::command] +#[specta::specta] +pub async fn remote_rename( + state: State<'_, AppState>, + connection: FsConnection, + from: String, + to: String, +) -> IdeResult<()> { + ide_host::ops::deploy::remote_rename(&state.host, connection, from, to).await +} + +#[tauri::command] +#[specta::specta] +pub async fn deploy_test_connection( + state: State<'_, AppState>, + connection: FsConnection, + path: String, +) -> IdeResult<()> { + ide_host::ops::deploy::deploy_test_connection(&state.host, connection, path).await } diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 121f2eab1..f7ff09bd5 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -457,11 +457,13 @@ pub fn builder() -> tauri_specta::Builder { deploy::deploy_info, deploy::deploy_server_root, deploy::remote_list, + deploy::deploy_test_connection, deploy::remote_open, deploy::remote_of, deploy::remote_delete, deploy::remote_mkdir, deploy::remote_rename, + crate::remote_dev::remote_bootstrap_list, crate::remote_dev::remote_connect, crate::remote_dev::remote_current, crate::remote_dev::remote_disconnect, diff --git a/src-tauri/src/remote_dev.rs b/src-tauri/src/remote_dev.rs index 876b15e68..8b5f97be0 100644 --- a/src-tauri/src/remote_dev.rs +++ b/src-tauri/src/remote_dev.rs @@ -17,6 +17,17 @@ use std::sync::Arc; use tauri::ipc::Channel; use tauri::{AppHandle, Manager, State, Window}; +/// Browse before connecting, using the desktop's SSH configuration even in a remote window. +#[tauri::command] +#[specta::specta] +pub async fn remote_bootstrap_list( + state: State<'_, AppState>, + ssh: String, + path: String, +) -> IdeResult> { + state.remote.list(&ide_ssh::FsConnection::Sftp { ssh }, &path).await +} + /// Where the server is installed on the remote host (per version). fn install_dir(version: &str) -> String { format!("~/.openidea/server/{version}") diff --git a/src/bindings.ts b/src/bindings.ts index 501a2116e..0ce6fda5f 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -966,33 +966,26 @@ export const commands = { * (run in a terminal tab, so prompts and host-key questions are answered there). */ sshTerminalCommand: (id: string) => typedError(__TAURI_INVOKE("ssh_terminal_command", { id })), - /** Tools › Deployment › Upload to…: files and folders to `server` (default server when `None`). */ deployUpload: (paths: IpcPath[], server: string | null, request: RequestId) => typedError(__TAURI_INVOKE("deploy_upload", { paths, server, request })), - /** Download from…: the remote counterparts of files and folders. */ deployDownload: (paths: IpcPath[], server: string | null, request: RequestId) => typedError(__TAURI_INVOKE("deploy_download", { paths, server, request })), - /** Deletes the remote counterparts of files and folders. */ deployDeleteRemote: (paths: IpcPath[], server: string | null) => typedError(__TAURI_INVOKE("deploy_delete_remote", { paths, server })), - /** Sync with Deployed: local and remote files under `path`, side by side. */ deployCompare: (path: IpcPath, server: string | null, request: RequestId) => typedError(__TAURI_INVOKE("deploy_compare", { path, server, request })), - /** Applies the Sync with Deployed choices. */ deploySync: (steps: SyncStep[], server: string | null, request: RequestId) => typedError(__TAURI_INVOKE("deploy_sync", { steps, server, request })), - /** Where `path` is deployed (remote path, web URL); `None` when not mapped. */ deployInfo: (path: IpcPath, server: string | null) => typedError<{ server: string, remotePath: string, webUrl: string | null, } | null, IdeError>(__TAURI_INVOKE("deploy_info", { path, server })), - /** Remote Host tool window: the server root of `server`, resolved on the host. */ deployServerRoot: (server: string) => typedError(__TAURI_INVOKE("deploy_server_root", { server })), - /** Entries of a remote folder of SSH configuration `ssh` (`~`: the login directory). */ - remoteList: (ssh: string, path: string) => typedError(__TAURI_INVOKE("remote_list", { ssh, path })), - /** Opens a remote file for editing: a local copy, uploaded back on save. */ - remoteOpen: (ssh: string, path: string) => typedError(__TAURI_INVOKE("remote_open", { ssh, path })), - /** The remote file a local copy stands for: (SSH configuration id, remote path). */ - remoteOf: (path: IpcPath) => __TAURI_INVOKE<[string, string] | null>("remote_of", { path }), - remoteDelete: (ssh: string, path: string) => typedError(__TAURI_INVOKE("remote_delete", { ssh, path })), - remoteMkdir: (ssh: string, path: string) => typedError(__TAURI_INVOKE("remote_mkdir", { ssh, path })), - remoteRename: (ssh: string, from: string, to: string) => typedError(__TAURI_INVOKE("remote_rename", { ssh, from, to })), + 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 })), + remoteOf: (path: IpcPath) => __TAURI_INVOKE<[FsConnection, string] | null>("remote_of", { path }), + remoteDelete: (connection: FsConnection, path: string) => typedError(__TAURI_INVOKE("remote_delete", { connection, path })), + remoteMkdir: (connection: FsConnection, path: string) => typedError(__TAURI_INVOKE("remote_mkdir", { connection, path })), + remoteRename: (connection: FsConnection, from: string, to: string) => typedError(__TAURI_INVOKE("remote_rename", { connection, from, to })), + /** Browse before connecting, using the desktop's SSH configuration even in a remote window. */ + remoteBootstrapList: (ssh: string, path: string) => typedError(__TAURI_INVOKE("remote_bootstrap_list", { ssh, path })), /** * File › Remote Development: opens `path` on the host of SSH configuration * `ssh_config` in this window (installs / starts the server as needed). @@ -2410,8 +2403,9 @@ export type DeploymentMapping = { web?: string, }; -/** An SFTP server and how project folders map onto it. */ +/** A deployment filesystem and how project folders map onto it. */ export type DeploymentServer = { + kind?: DeploymentType, id?: string, name?: string, /** [`SshConfiguration::id`]. */ @@ -2439,6 +2433,8 @@ export type DeploymentSettings = { exclude?: string[], }; +export type DeploymentType = "sftp" | "local"; + export type Diagnostic = { range: Range, severity: Severity, @@ -3118,6 +3114,8 @@ export type Fragment = { text: string, }; +export type FsConnection = { kind: "sftp"; ssh: string } | { kind: "local" }; + /** * A method breakpoint (DAP function breakpoint). `name` uses the adapter's syntax: * `pkg.Class#method` for java-debug, `pkg.Func` / `pkg.(*T).Method` for Delve, a plain diff --git a/src/features/deployment/actions.tsx b/src/features/deployment/actions.tsx index ceb52d845..b54458b07 100644 --- a/src/features/deployment/actions.tsx +++ b/src/features/deployment/actions.tsx @@ -17,7 +17,7 @@ import styles from "./Deployment.module.css"; const GROUP = "tools"; const CATEGORY = "Deployment"; -const errorText = (e: unknown) => (e instanceof Error ? e.message : String(e)); +const errorText = (e: unknown) => txTemplate(e instanceof Error ? e.message : String(e)); const projectRoot = () => useProject.getState().project?.root ?? null; const deployment = () => workflowSettings().deployment; @@ -80,7 +80,7 @@ function ReportBody({ report }: { report: TransferReport }) {
    {shown.map((e, i) => (
  • - {root ? relativePath(root, e.path) : e.path}: {e.message} + {root ? relativePath(root, e.path) : e.path}: {txt(e.message)}
  • ))}
@@ -120,7 +120,7 @@ async function transfer(direction: "upload" | "download", server: DeploymentServ if (!server) return; const request = nextRequestId(); try { - const report = await withSsh(server.sshConfig, () => + const report = await withSsh(server.kind === "sftp" ? server.sshConfig : "", () => call( direction === "upload" ? commands.deployUpload(paths, server.id, request) @@ -155,7 +155,9 @@ async function deleteRemote(paths: string[]) { }); if (!ok) return; try { - const report = await withSsh(server.sshConfig, () => call(commands.deployDeleteRemote(paths, server.id))); + const report = await withSsh(server.kind === "sftp" ? server.sshConfig : "", () => + call(commands.deployDeleteRemote(paths, server.id)), + ); showReport(report, "delete", server); } catch (e) { showFailure("Cannot delete from {server}", server, e); @@ -179,7 +181,9 @@ export async function openSync(server: DeploymentServerValid, path: string) { }, }); try { - const entries = await withSsh(server.sshConfig, () => call(commands.deployCompare(path, server.id, request))); + const entries = await withSsh(server.kind === "sftp" ? server.sshConfig : "", () => + call(commands.deployCompare(path, server.id, request)), + ); if (useDeployment.getState().sync?.request !== request) return; patchSync({ entries, choices: initialSyncChoices(entries), request: null }); } catch (e) { @@ -203,7 +207,9 @@ export async function runSync(steps: { path: string; action: SyncAction }[]) { const request = nextRequestId(); patchSync({ running: true, request }); try { - const report = await withSsh(server.sshConfig, () => call(commands.deploySync(steps, server.id, request))); + const report = await withSsh(server.kind === "sftp" ? server.sshConfig : "", () => + call(commands.deploySync(steps, server.id, request)), + ); useDeployment.setState({ sync: null }); showReport(report, "sync", server); } catch (e) { diff --git a/src/features/deployment/remoteHost.test.ts b/src/features/deployment/remoteHost.test.ts new file mode 100644 index 000000000..7ef2cfa46 --- /dev/null +++ b/src/features/deployment/remoteHost.test.ts @@ -0,0 +1,32 @@ +import { beforeEach, expect, it, vi } from "vitest"; +import { DEFAULT_DEPLOYMENT_SERVER } from "@/platform/remoteSettings"; +import { ensureRoot } from "./remoteHost"; +import { useDeployment } from "./store"; + +const ipc = vi.hoisted(() => ({ root: vi.fn(), list: vi.fn() })); +vi.mock("@/platform/ipc", async (original) => ({ + ...(await original()), + commands: { deployServerRoot: ipc.root, remoteList: ipc.list }, +})); + +beforeEach(() => { + useDeployment.setState({ trees: {} }); + vi.clearAllMocks(); + ipc.root.mockResolvedValue({ status: "ok", data: "/srv" }); + ipc.list.mockResolvedValue({ status: "ok", data: [] }); +}); + +it("reloads a server's tree when changing its filesystem type or root", async () => { + const server = { ...DEFAULT_DEPLOYMENT_SERVER, id: "existing", sshConfig: "ssh", rootPath: "/srv" }; + await ensureRoot(server); + expect(ipc.list).toHaveBeenLastCalledWith({ kind: "sftp", ssh: "ssh" }, "/srv"); + await ensureRoot(server); + expect(ipc.root).toHaveBeenCalledTimes(1); + const local = { ...server, kind: "local" as const }; + await ensureRoot(local); + expect(ipc.list).toHaveBeenLastCalledWith({ kind: "local" }, "/srv"); + ipc.root.mockResolvedValue({ status: "ok", data: "/mounted" }); + await ensureRoot({ ...local, rootPath: "/mounted" }); + expect(ipc.list).toHaveBeenLastCalledWith({ kind: "local" }, "/mounted"); + expect(useDeployment.getState().trees.existing?.root).toBe("/mounted"); +}); diff --git a/src/features/deployment/remoteHost.ts b/src/features/deployment/remoteHost.ts index 8069f87b1..34a90091b 100644 --- a/src/features/deployment/remoteHost.ts +++ b/src/features/deployment/remoteHost.ts @@ -2,7 +2,7 @@ import { txTemplate } from "@/platform/i18n"; import { call, commands, type RemoteEntry } from "@/platform/ipc"; import { useProject } from "@/platform/project"; -import type { DeploymentServerValid } from "@/platform/remoteSettings"; +import { deploymentConnection, type DeploymentServerValid } from "@/platform/remoteSettings"; import { copyText } from "@/ui"; import { editorService, notifications } from "@/workbench/contributions"; import { promptInput } from "@/workbench/inputDialogs"; @@ -12,7 +12,9 @@ import { joinRemote, localPathForRemote, remoteName, remoteParent } from "./mode import { withSsh } from "./ssh"; import { EMPTY_TREE, patchTree, useDeployment } from "./store"; -const errorText = (e: unknown) => (e instanceof Error ? e.message : String(e)); +const errorText = (e: unknown) => txTemplate(e instanceof Error ? e.message : String(e)); + +const sourceKey = (server: DeploymentServerValid) => JSON.stringify([deploymentConnection(server), server.rootPath]); const tree = (server: string) => useDeployment.getState().trees[server] ?? EMPTY_TREE; @@ -25,14 +27,19 @@ function fail(title: string, e: unknown) { } export async function loadFolder(server: DeploymentServerValid, path: string) { + const source = sourceKey(server); try { - const entries = await withSsh(server.sshConfig, () => call(commands.remoteList(server.sshConfig, path))); + const entries = await withSsh(server.kind === "sftp" ? server.sshConfig : "", () => + call(commands.remoteList(deploymentConnection(server), path)), + ); + if (tree(server.id).source !== source) return; patchTree(server.id, (t) => { const folderErrors = { ...t.folderErrors }; delete folderErrors[path]; return { listings: { ...t.listings, [path]: sortEntries(entries) }, folderErrors }; }); } catch (e) { + if (tree(server.id).source !== source) return; patchTree(server.id, (t) => path === t.root ? { error: errorText(e) } : { folderErrors: { ...t.folderErrors, [path]: errorText(e) } }, ); @@ -41,6 +48,8 @@ export async function loadFolder(server: DeploymentServerValid, path: string) { /** Resolves the server root once per session and lists it. */ export async function ensureRoot(server: DeploymentServerValid, force = false) { + const source = sourceKey(server); + if (tree(server.id).source !== source) patchTree(server.id, { ...EMPTY_TREE, source }); const current = tree(server.id); if (current.root && !force && current.listings[current.root]) return; patchTree(server.id, { error: null }); @@ -48,10 +57,14 @@ export async function ensureRoot(server: DeploymentServerValid, force = false) { const root = current.root && !force ? current.root - : await withSsh(server.sshConfig, () => call(commands.deployServerRoot(server.id))); + : 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); } catch (e) { + if (tree(server.id).source !== source) return; patchTree(server.id, { error: errorText(e) }); } } @@ -59,7 +72,7 @@ export async function ensureRoot(server: DeploymentServerValid, force = false) { /** Reloads the root and the expanded folders. */ export async function refreshAll(server: DeploymentServerValid) { const t = tree(server.id); - if (!t.root) return ensureRoot(server, true); + if (!t.root || t.source !== sourceKey(server)) return ensureRoot(server, true); patchTree(server.id, { listings: {}, folderErrors: {}, error: null }); await loadFolder(server, t.root); await Promise.all([...t.expanded].map((p) => loadFolder(server, p))); @@ -67,7 +80,9 @@ export async function refreshAll(server: DeploymentServerValid) { export async function openRemote(server: DeploymentServerValid, entry: RemoteEntry) { try { - const local = await withSsh(server.sshConfig, () => call(commands.remoteOpen(server.sshConfig, entry.path))); + const local = await withSsh(server.kind === "sftp" ? server.sshConfig : "", () => + call(commands.remoteOpen(deploymentConnection(server), entry.path)), + ); await editorService().openFile(local, { focus: true }); } catch (e) { fail(`Cannot open ${entry.name}`, e); @@ -82,7 +97,9 @@ export async function newFolder(server: DeploymentServerValid, parent: string) { }); if (!name) return; try { - await withSsh(server.sshConfig, () => call(commands.remoteMkdir(server.sshConfig, joinRemote(parent, name)))); + await withSsh(server.kind === "sftp" ? server.sshConfig : "", () => + call(commands.remoteMkdir(deploymentConnection(server), joinRemote(parent, name))), + ); patchTree(server.id, (t) => ({ expanded: new Set([...t.expanded, parent]) })); await loadFolder(server, parent); } catch (e) { @@ -100,8 +117,8 @@ export async function renameEntry(server: DeploymentServerValid, entry: RemoteEn if (!name || name === entry.name) return; const parent = remoteParent(entry.path); try { - await withSsh(server.sshConfig, () => - call(commands.remoteRename(server.sshConfig, entry.path, joinRemote(parent, name))), + await withSsh(server.kind === "sftp" ? server.sshConfig : "", () => + call(commands.remoteRename(deploymentConnection(server), entry.path, joinRemote(parent, name))), ); await loadFolder(server, parent); } catch (e) { @@ -127,7 +144,9 @@ export async function deleteEntries(server: DeploymentServerValid, entries: Remo const parents = new Set(); for (const entry of entries) { try { - await withSsh(server.sshConfig, () => call(commands.remoteDelete(server.sshConfig, entry.path))); + await withSsh(server.kind === "sftp" ? server.sshConfig : "", () => + call(commands.remoteDelete(deploymentConnection(server), entry.path)), + ); parents.add(remoteParent(entry.path)); } catch (e) { fail(`Cannot delete ${entry.name}`, e); diff --git a/src/features/deployment/store.ts b/src/features/deployment/store.ts index 279b6f152..d7edd3505 100644 --- a/src/features/deployment/store.ts +++ b/src/features/deployment/store.ts @@ -26,6 +26,7 @@ export interface SecretPrompt { /** Remote Host tool window, per server: resolved root, loaded folders, expanded folders. */ export interface RemoteTreeState { + source: string | null; root: string | null; error: string | null; listings: Record; @@ -52,6 +53,7 @@ export const useDeployment = create(() => ({ })); export const EMPTY_TREE: RemoteTreeState = { + source: null, root: null, error: null, listings: {}, diff --git a/src/features/remote-dev/ConnectDialog.tsx b/src/features/remote-dev/ConnectDialog.tsx index ef8426fd0..7e8eab8e8 100644 --- a/src/features/remote-dev/ConnectDialog.tsx +++ b/src/features/remote-dev/ConnectDialog.tsx @@ -38,7 +38,7 @@ function ConnectDialog() { setError(null); setBusy(true); try { - const entries = await call(commands.remoteList(ssh, dir)); + const entries = await call(commands.remoteBootstrapList(ssh, dir)); const shown = listedDirectory(dir, entries); setBrowse({ dir: shown, folders: browsableFolders(entries) }); setPath(shown); diff --git a/src/features/settings/pages/Deployment.module.css b/src/features/settings/pages/Deployment.module.css index 8f30054e0..43cdb3fea 100644 --- a/src/features/settings/pages/Deployment.module.css +++ b/src/features/settings/pages/Deployment.module.css @@ -54,12 +54,22 @@ } .wide { width: 320px; + max-width: 100%; } .field { display: flex; + flex: 1; + min-width: 0; align-items: center; gap: 6px; } +.field > input { + min-width: 0; + flex: 1; +} +.field > button { + flex-shrink: 0; +} .note { color: var(--fg-subtle); font-size: var(--fs-small); diff --git a/src/features/settings/pages/deployment.tsx b/src/features/settings/pages/deployment.tsx index 024180060..53378a7ee 100644 --- a/src/features/settings/pages/deployment.tsx +++ b/src/features/settings/pages/deployment.tsx @@ -5,10 +5,10 @@ */ import { useEffect, useId, useState, type KeyboardEvent } from "react"; import clsx from "clsx"; -import { call, commands, IpcError, type RemoteEntry } from "@/platform/ipc"; -import { useTx, tx } from "@/platform/i18n"; -import type { DeploymentServerValid } from "@/platform/remoteSettings"; -import { Badge, Button, Checkbox, Dialog, Icon, IconButton, Input, Spinner, T, Tabs } from "@/ui"; +import { call, commands, IpcError, type FsConnection, type RemoteEntry } from "@/platform/ipc"; +import { useTx, tx, txTemplate } from "@/platform/i18n"; +import { deploymentConnection, type DeploymentServerValid } from "@/platform/remoteSettings"; +import { Badge, Button, Checkbox, Dialog, Icon, IconButton, Input, Select, Spinner, T, Tabs } from "@/ui"; import { GlobListEditor, Row } from "../components/Form"; import { SshConfigSelect, useSshConfigs } from "../components/SshConfigSelect"; import { useSetting } from "../components/context"; @@ -29,7 +29,7 @@ import { PageLink } from "./workflowControls"; import workflow from "./Workflow.module.css"; import styles from "./Deployment.module.css"; -const errorText = (e: unknown) => (e instanceof Error ? e.message : String(e)); +const errorText = (e: unknown) => txTemplate(e instanceof Error ? e.message : String(e)); // --------------------------------------------------------------------------- // Connection problems: trust an unknown host key or enter the password. @@ -96,12 +96,12 @@ function ConnectionProblem({ ssh, error, onRetry }: { ssh: string; error: unknow // Remote folder chooser (Root path › Browse…) function RemoteFolderDialog({ - ssh, + connection, initial, onChoose, onClose, }: { - ssh: string; + connection: FsConnection; initial: string; onChoose: (path: string) => void; onClose: () => void; @@ -111,6 +111,7 @@ function RemoteFolderDialog({ const [attempt, setAttempt] = useState(0); const [result, setResult] = useState<{ key: string; folders?: RemoteEntry[]; error?: unknown } | null>(null); const [selected, setSelected] = useState(null); + const connectionKey = JSON.stringify(connection); const key = `${path}\n${attempt}`; const current = result?.key === key ? result : null; const folders = current ? (current.folders ?? []) : null; @@ -118,7 +119,7 @@ function RemoteFolderDialog({ useEffect(() => { let live = true; - call(commands.remoteList(ssh, path)).then( + call(commands.remoteList(JSON.parse(connectionKey) as FsConnection, path)).then( (entries) => { if (!live) return; // `~` lists the login folder: name it by the absolute path the entries report. @@ -136,7 +137,7 @@ function RemoteFolderDialog({ return () => { live = false; }; - }, [ssh, path, key]); + }, [connectionKey, path, key]); const go = (next: string) => { setDraft(next); @@ -166,7 +167,7 @@ function RemoteFolderDialog({ return ( {error ? ( - + ) : (
    (null); - const test = result?.ssh === server.sshConfig ? result : null; + const connection = deploymentConnection(server); + const testKey = `${server.kind}:${server.sshConfig}:${server.rootPath}`; + const test = result?.ssh === testKey ? result : null; const rootId = useId(); const runTest = async () => { - const ssh = server.sshConfig; + const ssh = testKey; setTest({ ssh, kind: "busy" }); try { - const entries = await call(commands.remoteList(ssh, server.rootPath.trim() || "~")); - setTest({ ssh, kind: "ok", count: entries.length }); + await call(commands.deployTestConnection(connection, server.rootPath.trim() || "~")); + setTest({ ssh, kind: "ok", count: 0 }); } catch (e) { setTest({ ssh, kind: "error", error: e }); } @@ -294,12 +297,25 @@ function ConnectionTab({ return (
    update({ name })} /> - - update({ sshConfig })} /> - - Configure… - + + update({ rootPath: e.target.value })} /> - @@ -322,7 +338,11 @@ function ConnectionTab({ placeholder="http://example.com" />
    - {test?.kind === "busy" && } @@ -332,12 +352,15 @@ function ConnectionTab({ )}
    - {test?.kind === "error" && ( - void runTest()} /> - )} + {test?.kind === "error" && + (server.kind === "sftp" ? ( + void runTest()} /> + ) : ( + {errorText(test.error)} + ))} {browsing && ( setBrowsing(false)} onChoose={(rootPath) => { @@ -608,7 +631,18 @@ export const DEPLOYMENT_PAGES: PageSpec[] = [ order: 365, projectScope: true, defaultScope: "project", - keywords: ["sftp", "ssh", "server", "upload", "remote host", "mappings", "web server", "deploy"], + keywords: [ + "local", + "mounted", + "sftp", + "ssh", + "server", + "upload", + "remote host", + "mappings", + "web server", + "deploy", + ], sections: [ { options: [ diff --git a/src/features/settings/pages/deploymentModel.test.ts b/src/features/settings/pages/deploymentModel.test.ts index 56fddec25..de0525904 100644 --- a/src/features/settings/pages/deploymentModel.test.ts +++ b/src/features/settings/pages/deploymentModel.test.ts @@ -54,6 +54,7 @@ describe("server list", () => { expect(nextServerName([a])).toBe("Server 2"); expect(nextServerName([{ ...a, name: "Server 2" }])).toBe("Server 3"); const fresh = newServer([a]); + expect(fresh.kind).toBe("sftp"); expect(fresh.name).toBe("Server 2"); expect(fresh.id).not.toBe(""); expect(fresh.mappings).toEqual([{ local: "", deployment: "", web: "" }]); @@ -63,6 +64,14 @@ describe("server list", () => { expect(copy.id).not.toBe("a"); }); + it("copies a local server without changing its type or mappings", () => { + const local = { ...a, kind: "local" as const, rootPath: "/mounted/www" }; + const copy = newServer([local], local); + expect(copy).toMatchObject({ kind: "local", rootPath: "/mounted/www", sshConfig: "" }); + expect(copy.mappings).toEqual(local.mappings); + expect(copy.mappings).not.toBe(local.mappings); + }); + it("keeps the default server valid", () => { expect(fixDefaultServer([a], "a")).toBe("a"); expect(fixDefaultServer([a], "gone")).toBe("a"); diff --git a/src/features/settings/pages/runTargets.tsx b/src/features/settings/pages/runTargets.tsx index b0e9aa492..88e24d532 100644 --- a/src/features/settings/pages/runTargets.tsx +++ b/src/features/settings/pages/runTargets.tsx @@ -52,7 +52,7 @@ function RemoteFolderDialog({ useEffect(() => { let live = true; - call(commands.remoteList(sshConfig, request.path)).then( + call(commands.remoteList({ kind: "sftp", ssh: sshConfig }, request.path)).then( (list) => { if (!live) return; setEntries(list.filter((e) => e.isDir).sort((a, b) => a.name.localeCompare(b.name))); diff --git a/src/platform/deploymentSettings.test.ts b/src/platform/deploymentSettings.test.ts new file mode 100644 index 000000000..ece25b9e3 --- /dev/null +++ b/src/platform/deploymentSettings.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; +import { deploymentConnection, validateDeployment } from "./remoteSettings"; + +describe("deployment types", () => { + it("reads existing servers as SFTP without changing their connection", () => { + const legacy = { id: "existing", name: "Staging", sshConfig: "ssh", rootPath: "/srv", webUrl: "http://localhost" }; + const server = validateDeployment({ servers: [legacy] }).servers[0]!; + expect(server).toMatchObject({ ...legacy, kind: "sftp" }); + expect(deploymentConnection(server)).toEqual({ kind: "sftp", ssh: "ssh" }); + }); + it.each(["sftp", "local"])("round trips %s settings", (kind) => { + const settings = validateDeployment({ servers: [{ kind, rootPath: "/mounted/www", sshConfig: "retained" }] }); + expect(validateDeployment(JSON.parse(JSON.stringify(settings)))).toEqual(settings); + expect(deploymentConnection(settings.servers[0]!)).toEqual( + kind === "local" ? { kind: "local" } : { kind: "sftp", ssh: "retained" }, + ); + }); + it("normalizes unsupported types to SFTP", () => { + for (const kind of [undefined, "ftp", "ftps", "webdav", "inPlace", null]) { + expect(validateDeployment({ servers: [{ kind }] }).servers[0]!.kind).toBe("sftp"); + } + }); +}); diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 3e6b4eadf..927270a01 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -4143,6 +4143,15 @@ "Mappings' deployment paths are relative to this folder.": "映射中的部署路径相对于此文件夹。", "Login folder": "登录目录", "Web server URL": "Web 服务器 URL", + "Choose Folder": "选择文件夹", + "Home folder is unavailable": "无法获取主目录", + "Folder does not exist: {path}": "文件夹不存在:{path}", + "Not a folder: {path}": "不是文件夹:{path}", + "Folder is not writable: {path}: {error}": "文件夹不可写:{path}:{error}", + "Source and destination are the same file: {path}": "源和目标是同一个文件:{path}", + "Local or mounted folder": "本地或挂载的文件夹", + "Folder path": "文件夹路径", + "SFTP": "SFTP 文件传输", "Connected successfully": "连接成功", "Trust Host Key and Retry": "信任主机密钥并重试", "Choose Remote Folder": "选择远程文件夹", diff --git a/src/platform/remoteSettings.ts b/src/platform/remoteSettings.ts index 04292116f..2f285c9ce 100644 --- a/src/platform/remoteSettings.ts +++ b/src/platform/remoteSettings.ts @@ -23,6 +23,7 @@ export interface DeploymentMappingValid { } export interface DeploymentServerValid { + kind: "sftp" | "local"; id: string; name: string; sshConfig: string; @@ -70,6 +71,7 @@ export const DEFAULT_SSH_CONFIG: SshConfigValid = { }; export const DEFAULT_DEPLOYMENT_SERVER: DeploymentServerValid = { + kind: "sftp", id: "", name: "", sshConfig: "", @@ -157,6 +159,7 @@ export function validateDeployment(raw: unknown): DeploymentSettingsValid { const o = obj(raw); const d = DEFAULT_DEPLOYMENT; const servers = withIds(o.servers, "server", (s) => ({ + kind: oneOf(s.kind, ["sftp", "local"] as const, "sftp"), id: text(s.id, ""), name: text(s.name, ""), sshConfig: text(s.sshConfig, ""), @@ -208,3 +211,8 @@ export function sshLabel(c: Pick `${prefix}-${Date.now().toString(36)}-${++seq}`; + +/** Backend connection descriptor for deployment and folder browsing. */ +export function deploymentConnection(server: DeploymentServerValid): { kind: "local" } | { kind: "sftp"; ssh: string } { + return server.kind === "local" ? { kind: "local" } : { kind: "sftp", ssh: server.sshConfig }; +}