From 290abd303e0d879d1cf1e1446db4b050ed2d7df0 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sat, 31 Oct 2026 12:01:54 +0800 Subject: [PATCH] feat(deployment): deploy to a local or mounted folder (#521) Deployment only supported SFTP, so a web root on the same machine or on a mounted share still needed an SSH server. Servers now have a type: SFTP or Local or mounted folder. A local server has a folder and optional web URL and shares upload on save, upload and download, delete, compare and sync, excludes, mappings and the Remote Host view with SFTP through one backend filesystem. Copies never read the whole file into memory, a file is never copied onto itself, and Test Connection checks the folder exists and is writable. Existing servers stay SFTP. Fixes #467 --- crates/ide-core/src/remote_settings.rs | 31 +- crates/ide-host/src/ops/deploy.rs | 200 +++++++++++++ crates/ide-host/src/ops/mod.rs | 2 + crates/ide-host/tests/rpc.rs | 66 +++++ crates/ide-remote/src/deploy.rs | 4 +- crates/ide-remote/src/service.rs | 86 ++++-- crates/ide-remote/tests/local.rs | 211 ++++++++++++++ crates/ide-ssh/src/filesystem.rs | 274 ++++++++++++++++++ crates/ide-ssh/src/lib.rs | 4 +- crates/ide-ssh/src/sftp.rs | 6 +- src-tauri/src/commands/deploy.rs | 160 ++++------ src-tauri/src/commands/mod.rs | 2 + src-tauri/src/remote_dev.rs | 11 + src/bindings.ts | 32 +- src/features/deployment/actions.tsx | 18 +- src/features/deployment/remoteHost.test.ts | 32 ++ src/features/deployment/remoteHost.ts | 39 ++- src/features/deployment/store.ts | 2 + src/features/remote-dev/ConnectDialog.tsx | 2 +- .../settings/pages/Deployment.module.css | 10 + src/features/settings/pages/deployment.tsx | 90 ++++-- .../settings/pages/deploymentModel.test.ts | 9 + src/features/settings/pages/runTargets.tsx | 2 +- src/platform/deploymentSettings.test.ts | 23 ++ src/platform/locales/text/zh.json | 9 + src/platform/remoteSettings.ts | 8 + 26 files changed, 1127 insertions(+), 206 deletions(-) create mode 100644 crates/ide-host/src/ops/deploy.rs create mode 100644 crates/ide-remote/tests/local.rs create mode 100644 crates/ide-ssh/src/filesystem.rs create mode 100644 src/features/deployment/remoteHost.test.ts create mode 100644 src/platform/deploymentSettings.test.ts 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 }; +}