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
Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-10-31 12:01:54 +08:00
1 джерело bde19959b9
коміт 290abd303e
26 files changed
+1127 -206

No files matched your search

+30 -1
Переглянути файл
@@ -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::<DeploymentServer>(&serde_json::to_string(&server).unwrap()).unwrap(),
server
);
}
}
}
+200
Переглянути файл
@@ -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<IpcPath>) -> Vec<PathBuf> {
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<IpcPath>,
server: Option<String>,
request: RequestId,
) -> IdeResult<TransferReport> {
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<IpcPath>,
server: Option<String>,
request: RequestId,
) -> IdeResult<TransferReport> {
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<IpcPath>,
server: Option<String>,
) -> IdeResult<TransferReport> {
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<String>,
request: RequestId,
) -> IdeResult<Vec<SyncEntry>> {
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<SyncStep>,
server: Option<String>,
request: RequestId,
) -> IdeResult<TransferReport> {
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<String>,
) -> IdeResult<Option<DeployedPath>> {
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<String> {
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<Vec<RemoteEntry>> {
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<IpcPath> {
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
});
}
+2
Переглянути файл
@@ -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 {
+66
Переглянути файл
@@ -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();
}
+2 -2
Переглянути файл
@@ -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<FileStamp>, remote: Option<FileStamp>) -> 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,
+58 -28
Переглянути файл
@@ -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<SshManager>,
/// Local copies of remote files: `<cache>/<ssh config>/<remote path>`.
/// Local copies of remote files, separated by filesystem connection.
cache: PathBuf,
edits: Mutex<HashMap<PathBuf, RemoteEdit>>,
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<RemoteFs> {
if ssh_config.is_empty() {
return Err(IdeError::invalid("the deployment server has no SSH configuration"));
async fn fs(&self, connection: &FsConnection) -> IdeResult<RemoteFs> {
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<T> {
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<String> {
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<Vec<RemoteEntry>> {
let fs = self.fs(ssh_config).await?;
pub async fn list(&self, connection: &FsConnection, path: &str) -> IdeResult<Vec<RemoteEntry>> {
let fs = self.fs(connection).await?;
let r = async { fs.list(&fs.resolve(path).await?).await }.await;
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<PathBuf> {
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<PathBuf> {
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}"),
}
}
+211
Переглянути файл
@@ -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:?}");
}
+274
Переглянути файл
@@ -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<String> {
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<String> {
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<Option<RemoteEntry>> {
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<Vec<RemoteEntry>> {
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<Vec<u8>> {
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<u32>) -> 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<RemoteEntry> {
#[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(())
}
}
+3 -1
Переглянути файл
@@ -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;
+3 -3
Переглянути файл
@@ -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 }
}
+52 -108
Переглянути файл
@@ -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<IpcPath>) -> Vec<PathBuf> {
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<String>,
request: RequestId,
) -> IdeResult<TransferReport> {
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<String>,
request: RequestId,
) -> IdeResult<TransferReport> {
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<IpcPath>,
server: Option<String>,
) -> IdeResult<TransferReport> {
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<String>,
request: RequestId,
) -> IdeResult<Vec<SyncEntry>> {
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<String>,
request: RequestId,
) -> IdeResult<TransferReport> {
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<String>,
) -> IdeResult<Option<DeployedPath>> {
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<String> {
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<Vec<RemoteEntry>> {
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<IpcPath> {
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<Vec<RemoteEntry>> {
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<IpcPath> {
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
}
+2
Переглянути файл
@@ -457,11 +457,13 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
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,
+11
Переглянути файл
@@ -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<Vec<ide_ssh::RemoteEntry>> {
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}")
+15 -17
Переглянути файл
@@ -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<SshTerminalCommand, IdeError>(__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<TransferReport, IdeError>(__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<TransferReport, IdeError>(__TAURI_INVOKE("deploy_download", { paths, server, request })),
/** Deletes the remote counterparts of files and folders. */
deployDeleteRemote: (paths: IpcPath[], server: string | null) => typedError<TransferReport, IdeError>(__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<SyncEntry[], IdeError>(__TAURI_INVOKE("deploy_compare", { path, server, request })),
/** Applies the Sync with Deployed choices. */
deploySync: (steps: SyncStep[], server: string | null, request: RequestId) => typedError<TransferReport, IdeError>(__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<string, IdeError>(__TAURI_INVOKE("deploy_server_root", { server })),
/** Entries of a remote folder of SSH configuration `ssh` (`~`: the login directory). */
remoteList: (ssh: string, path: string) => typedError<RemoteEntry[], IdeError>(__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<IpcPath, IdeError>(__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<null, IdeError>(__TAURI_INVOKE("remote_delete", { ssh, path })),
remoteMkdir: (ssh: string, path: string) => typedError<null, IdeError>(__TAURI_INVOKE("remote_mkdir", { ssh, path })),
remoteRename: (ssh: string, from: string, to: string) => typedError<null, IdeError>(__TAURI_INVOKE("remote_rename", { ssh, from, to })),
remoteList: (connection: FsConnection, path: string) => typedError<RemoteEntry[], IdeError>(__TAURI_INVOKE("remote_list", { connection, path })),
deployTestConnection: (connection: FsConnection, path: string) => typedError<null, IdeError>(__TAURI_INVOKE("deploy_test_connection", { connection, path })),
remoteOpen: (connection: FsConnection, path: string) => typedError<IpcPath, IdeError>(__TAURI_INVOKE("remote_open", { connection, path })),
remoteOf: (path: IpcPath) => __TAURI_INVOKE<[FsConnection, string] | null>("remote_of", { path }),
remoteDelete: (connection: FsConnection, path: string) => typedError<null, IdeError>(__TAURI_INVOKE("remote_delete", { connection, path })),
remoteMkdir: (connection: FsConnection, path: string) => typedError<null, IdeError>(__TAURI_INVOKE("remote_mkdir", { connection, path })),
remoteRename: (connection: FsConnection, from: string, to: string) => typedError<null, IdeError>(__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<RemoteEntry[], IdeError>(__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
+12 -6
Переглянути файл
@@ -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 }) {
<ul className={styles.reportErrors}>
{shown.map((e, i) => (
<li key={i}>
<span className={styles.mono}>{root ? relativePath(root, e.path) : e.path}</span>: {e.message}
<span className={styles.mono}>{root ? relativePath(root, e.path) : e.path}</span>: {txt(e.message)}
</li>
))}
</ul>
@@ -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) {
+32
Переглянути файл
@@ -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<typeof import("@/platform/ipc")>()),
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");
});
+29 -10
Переглянути файл
@@ -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<string>();
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);
+2
Переглянути файл
@@ -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<string, RemoteEntry[]>;
@@ -52,6 +53,7 @@ export const useDeployment = create<DeploymentState>(() => ({
}));
export const EMPTY_TREE: RemoteTreeState = {
source: null,
root: null,
error: null,
listings: {},
+1 -1
Переглянути файл
@@ -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);
+10
Переглянути файл
@@ -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);
+62 -28
Переглянути файл
@@ -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<number | null>(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 (
<Dialog
open
title="Choose Remote Folder"
title={connection.kind === "local" ? "Choose Folder" : "Choose Remote Folder"}
onClose={onClose}
width={520}
buttons={[
@@ -193,7 +194,7 @@ function RemoteFolderDialog({
<IconButton icon="refresh" size="small" title="Refresh" onClick={reload} />
</form>
{error ? (
<ConnectionProblem ssh={ssh} error={error} onRetry={reload} />
<ConnectionProblem ssh={connection.kind === "sftp" ? connection.ssh : ""} error={error} onRetry={reload} />
) : (
<ul
className={styles.folderList}
@@ -277,15 +278,17 @@ function ConnectionTab({
}) {
const [browsing, setBrowsing] = useState(false);
const [result, setTest] = useState<TestState | null>(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 (
<div className={styles.panel}>
<TextField label="Name" value={server.name} onChange={(name) => update({ name })} />
<Row label="SSH configuration">
<SshConfigSelect value={server.sshConfig} onChange={(sshConfig) => update({ sshConfig })} />
<PageLink page="tools.sshConfigurations">
<T>Configure…</T>
</PageLink>
<Row label="Type">
<Select
aria-label="Type"
value={server.kind}
options={[
{ value: "sftp", label: "SFTP" },
{ value: "local", label: "Local or mounted folder" },
]}
onChange={(kind) => update({ kind })}
/>
</Row>
{server.kind === "sftp" && (
<Row label="SSH configuration">
<SshConfigSelect value={server.sshConfig} onChange={(sshConfig) => update({ sshConfig })} />
<PageLink page="tools.sshConfigurations">
<T>Configure…</T>
</PageLink>
</Row>
)}
<Row label="Root path" htmlFor={rootId} hint="Mappings' deployment paths are relative to this folder.">
<span className={styles.field}>
<Input
@@ -307,10 +323,10 @@ function ConnectionTab({
mono
className={styles.wide}
value={server.rootPath}
placeholder="Login folder"
placeholder={server.kind === "local" ? tx("Folder path") : tx("Login folder")}
onChange={(e) => update({ rootPath: e.target.value })}
/>
<Button size="small" disabled={!server.sshConfig} onClick={() => setBrowsing(true)}>
<Button size="small" disabled={server.kind === "sftp" && !server.sshConfig} onClick={() => setBrowsing(true)}>
<T>Browse…</T>
</Button>
</span>
@@ -322,7 +338,11 @@ function ConnectionTab({
placeholder="http://example.com"
/>
<div className={styles.testRow}>
<Button size="small" disabled={!server.sshConfig || test?.kind === "busy"} onClick={() => void runTest()}>
<Button
size="small"
disabled={(server.kind === "sftp" ? !server.sshConfig : !server.rootPath.trim()) || test?.kind === "busy"}
onClick={() => void runTest()}
>
<T>Test Connection</T>
</Button>
{test?.kind === "busy" && <Spinner />}
@@ -332,12 +352,15 @@ function ConnectionTab({
</span>
)}
</div>
{test?.kind === "error" && (
<ConnectionProblem ssh={server.sshConfig} error={test.error} onRetry={() => void runTest()} />
)}
{test?.kind === "error" &&
(server.kind === "sftp" ? (
<ConnectionProblem ssh={server.sshConfig} error={test.error} onRetry={() => void runTest()} />
) : (
<span className={styles.error}>{errorText(test.error)}</span>
))}
{browsing && (
<RemoteFolderDialog
ssh={server.sshConfig}
connection={connection}
initial={server.rootPath}
onClose={() => 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: [
+9
Переглянути файл
@@ -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");
+1 -1
Переглянути файл
@@ -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)));
+23
Переглянути файл
@@ -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");
}
});
});
+9
Переглянути файл
@@ -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": "选择远程文件夹",
+8
Переглянути файл
@@ -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<SshConfigValid, "name" | "host" | "port" | "use
let seq = 0;
/** A fresh id for a new SSH configuration, server or target. */
export const newRemoteId = (prefix: string) => `${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 };
}