From aaff75f2f1956fb161d089274b91a4719d573c93 Mon Sep 17 00:00:00 2001 From: Shubhansh Bhushan Date: Wed, 30 Sep 2026 23:31:06 +0530 Subject: [PATCH 1/8] feat(gui): add SFTP file management and editor --- Cargo.lock | 9 + crates/omnyssh-core/Cargo.toml | 3 +- crates/omnyssh-core/src/config/app_config.rs | 34 + crates/omnyssh-core/src/event.rs | 31 +- crates/omnyssh-core/src/ssh/sftp.rs | 508 ++++++- crates/omnyssh-gui/Cargo.toml | 1 + crates/omnyssh-gui/src/bridge.rs | 91 +- crates/omnyssh-gui/src/commands/sftp.rs | 413 +++++- crates/omnyssh-gui/src/commands/update.rs | 37 +- crates/omnyssh-gui/src/dto.rs | 78 +- crates/omnyssh-gui/src/events.rs | 29 + crates/omnyssh-gui/src/main.rs | 35 +- crates/omnyssh-gui/ui/src/lib/bindings.ts | 214 ++- crates/omnyssh-gui/ui/src/lib/ipc/commands.ts | 252 +++- .../ui/src/lib/screens/FileEditor.svelte | 223 +++ .../ui/src/lib/screens/Settings.svelte | 113 +- .../ui/src/lib/screens/SftpPane.svelte | 69 +- .../ui/src/lib/screens/SftpView.svelte | 1271 ++++++++++++++--- .../ui/src/lib/screens/TerminalView.svelte | 35 +- .../ui/src/lib/stores/fileEditor.ts | 139 ++ .../ui/src/lib/stores/generalConfig.ts | 41 + .../ui/src/lib/stores/sftp.test.ts | 307 ++-- crates/omnyssh-gui/ui/src/lib/stores/sftp.ts | 108 +- .../omnyssh-gui/ui/src/lib/theme/Icon.svelte | 4 + crates/omnyssh-gui/ui/src/lib/theme/icons.ts | 1 + crates/omnyssh/src/app/action.rs | 9 + crates/omnyssh/src/app/actions.rs | 14 + crates/omnyssh/src/app/file_editor.rs | 110 ++ crates/omnyssh/src/app/file_manager.rs | 318 +++++ crates/omnyssh/src/app/input.rs | 7 + crates/omnyssh/src/app/mod.rs | 167 ++- crates/omnyssh/src/event.rs | 22 +- crates/omnyssh/src/ui/file_editor.rs | 295 ++++ crates/omnyssh/src/ui/file_manager.rs | 86 +- crates/omnyssh/src/ui/mod.rs | 19 +- crates/omnyssh/src/ui/status_bar.rs | 8 + 36 files changed, 4607 insertions(+), 494 deletions(-) create mode 100644 crates/omnyssh-gui/ui/src/lib/screens/FileEditor.svelte create mode 100644 crates/omnyssh-gui/ui/src/lib/stores/fileEditor.ts create mode 100644 crates/omnyssh-gui/ui/src/lib/stores/generalConfig.ts create mode 100644 crates/omnyssh/src/app/file_editor.rs create mode 100644 crates/omnyssh/src/ui/file_editor.rs diff --git a/Cargo.lock b/Cargo.lock index bd16236f..04947083 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3350,6 +3350,7 @@ name = "omnyssh-core" version = "1.1.4" dependencies = [ "anyhow", + "async-recursion", "async-trait", "chrono", "dirs 5.0.1", @@ -3369,6 +3370,7 @@ dependencies = [ "toml 0.8.23", "tracing", "vt100-omnyssh", + "walkdir", "windows-sys 0.61.2", ] @@ -3383,6 +3385,7 @@ dependencies = [ "proptest", "serde", "serde_json", + "shell-words", "specta", "specta-typescript", "tauri", @@ -5014,6 +5017,12 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + [[package]] name = "shlex" version = "1.3.0" diff --git a/crates/omnyssh-core/Cargo.toml b/crates/omnyssh-core/Cargo.toml index e54912ae..8e90196e 100644 --- a/crates/omnyssh-core/Cargo.toml +++ b/crates/omnyssh-core/Cargo.toml @@ -12,6 +12,7 @@ categories = ["network-programming"] [dependencies] # `net`: the local listeners of port forwarding. tokio = { workspace = true, features = ["net"] } +walkdir = "2" # SSH. The ring backend, not aws-lc-rs: aws-lc-sys needs cmake and NASM on # Windows. flate2 keeps zlib compression. @@ -31,7 +32,7 @@ thiserror = { workspace = true } # Async traits used as trait objects: password prompts, service providers async-trait = "0.1" - +async-recursion = "1.1" # Feature B: Auto SSH Key Setup — timestamp generation chrono = { workspace = true } diff --git a/crates/omnyssh-core/src/config/app_config.rs b/crates/omnyssh-core/src/config/app_config.rs index 4ad968ca..79f6131f 100644 --- a/crates/omnyssh-core/src/config/app_config.rs +++ b/crates/omnyssh-core/src/config/app_config.rs @@ -19,10 +19,30 @@ pub struct AppConfig { pub struct GeneralConfig { /// Seconds between automatic metric refreshes. pub refresh_interval: u64, + pub default_shell: String, + /// Path to the system SSH binary. pub ssh_command: String, + pub max_concurrent_connections: usize, + + /// External editor command. + /// Examples: + /// "zed" + /// "code --wait" + /// "vim" + /// Empty = use $VISUAL → $EDITOR → vim + pub external_editor: String, + + /// Files larger than this open externally. + pub large_file_mb: u64, + + /// Ask before downloading a large remote file. + pub prompt_large_files: bool, + + /// Upload remote temp files automatically after editing. + pub auto_upload_external: bool, } impl Default for GeneralConfig { @@ -32,6 +52,11 @@ impl Default for GeneralConfig { default_shell: String::from("/bin/bash"), ssh_command: String::from("ssh"), max_concurrent_connections: 10, + + external_editor: String::new(), + large_file_mb: 5, + prompt_large_files: true, + auto_upload_external: true, } } } @@ -261,6 +286,15 @@ pub fn save_theme_to_config(theme_name: &str) -> anyhow::Result<()> { persist_config(|config| config.ui.theme = theme_name.to_string()) } +/// Saves the entire `[general]` section to the config file. +/// Shared by both the TUI and GUI so editor preferences stay synchronized. +/// # Errors +/// Returns an error if the config file cannot be written or parsed. +pub fn save_general_config(general: &GeneralConfig) -> anyhow::Result<()> { + let general = general.clone(); + persist_config(move |config| config.general = general) +} + /// Saves the update-checker preferences to the config file's `[update]` /// section. /// diff --git a/crates/omnyssh-core/src/event.rs b/crates/omnyssh-core/src/event.rs index b193d472..4aa26ae7 100644 --- a/crates/omnyssh-core/src/event.rs +++ b/crates/omnyssh-core/src/event.rs @@ -64,6 +64,12 @@ impl Default for Metrics { /// Domain events produced by the SSH engine, config loaders, and the update /// checker. Background tasks send these over a dedicated channel; the /// frontend wraps them into its own event stream. +#[derive(Debug, Clone, Copy)] +pub enum TransferStage { + Preparing, + Transferring, +} + #[derive(Debug)] pub enum CoreEvent { /// SSH metrics received from a background task. @@ -71,7 +77,17 @@ pub enum CoreEvent { /// Connection status changed for a host (reported by metrics poller). HostStatusChanged(HostId, ConnectionStatus), /// File transfer progress: (transfer_id, bytes_done, bytes_total). - FileTransferProgress(TransferId, u64, u64), + FileTransferProgress { + transfer_id: TransferId, + stage: TransferStage, + root_name: String, + current_file: String, + bytes_done: u64, + bytes_total: u64, + files_done: u32, + files_total: u32, + }, + /// An error message surfaced to the user. Error(String), /// Host list loaded from disk / SSH config in a background task. @@ -110,6 +126,19 @@ pub enum CoreEvent { SftpDisconnected { reason: String }, /// Preview bytes available for a file. FilePreviewReady { path: String, content: String }, + /// Full remote file body for the in-app editor (size-capped in core). + FileContentReady { path: String, content: String }, + /// In-app editor save finished. + FileWriteDone { + path: String, + result: Result<(), String>, + }, + /// External editor process finished; frontend uploads if the temp file changed. + ExternalEditDone { + remote_path: String, + local_path: String, + result: Result<(), String>, + }, /// A mutating SFTP operation (delete, mkdir, rename, upload, download) finished. SftpOpDone { result: Result<(), String> }, diff --git a/crates/omnyssh-core/src/ssh/sftp.rs b/crates/omnyssh-core/src/ssh/sftp.rs index f92f5468..db556f0b 100644 --- a/crates/omnyssh-core/src/ssh/sftp.rs +++ b/crates/omnyssh-core/src/ssh/sftp.rs @@ -9,11 +9,17 @@ use std::time::Duration; use anyhow::Context; +use async_recursion::async_recursion; +use std::collections::HashSet; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::sync::mpsc; + use tokio::time; +use walkdir::WalkDir; -use crate::event::{CoreEvent, TransferId}; +use crate::event::{CoreEvent, TransferId, TransferStage}; use crate::ssh::client::Host; use crate::ssh::password::Prompter; use crate::ssh::session::{Passwords, SshSession}; @@ -26,6 +32,9 @@ const OPEN_TIMEOUT: Duration = Duration::from_secs(30); // --------------------------------------------------------------------------- /// Metadata for a single file or directory in a file panel. +/// Max size (bytes) for the in-app editor. Larger files use external editor + temp file. +pub const MAX_IN_APP_EDIT_BYTES: u64 = 64 * 1024 * 1024; + #[derive(Debug, Clone)] pub struct FileEntry { /// Base file name (not the full path). @@ -58,6 +67,20 @@ pub enum SftpCommand { remote: String, transfer_id: TransferId, }, + DownloadDir { + remote: String, + local: String, + transfer_id: TransferId, + }, + + UploadDir { + local: String, + remote: String, + transfer_id: TransferId, + }, + + /// Cancel an active upload/download. + CancelTransfer { transfer_id: TransferId }, /// Delete a remote file (falls back to removing an empty directory). Delete(String), /// Create a remote directory. @@ -66,6 +89,10 @@ pub enum SftpCommand { Rename { from: String, to: String }, /// Read the first 4 096 bytes of a remote file for preview. ReadPreview(String), + /// Read a remote file for the in-app editor (size-capped, full UTF-8 content). + ReadFile(String), + /// Overwrite a remote file with UTF-8 text from the in-app editor. + WriteFile { path: String, content: String }, /// Shut down the task gracefully. Disconnect, } @@ -81,6 +108,39 @@ pub enum SftpCommand { #[derive(Debug)] pub struct SftpManager { cmd_tx: mpsc::Sender, + cancelled: Arc>>, +} + +#[async_recursion] +async fn delete_recursive( + sftp: &russh_sftp::client::SftpSession, + path: &str, +) -> anyhow::Result<()> { + // Try deleting as a file first. + if sftp.remove_file(path).await.is_ok() { + return Ok(()); + } + + // Otherwise recurse into the directory. + let entries = sftp.read_dir(path).await?; + + for entry in entries { + let name = entry.file_name(); + + // Skip pseudo entries. + if name == "." || name == ".." { + continue; + } + + let child = format!("{}/{}", path.trim_end_matches('/'), name); + + delete_recursive(sftp, &child).await?; + } + + // Remove the now-empty directory. + sftp.remove_dir(path).await?; + + Ok(()) } impl SftpManager { @@ -115,6 +175,8 @@ impl SftpManager { .map_err(|_| anyhow::anyhow!("SFTP did not start within {}s", OPEN_TIMEOUT.as_secs()))??; let (cmd_tx, cmd_rx) = mpsc::channel::(64); + let cancelled = Arc::new(Mutex::new(HashSet::new())); + let cancelled_task = cancelled.clone(); let host_name = host.name.clone(); // `session` and `sftp` are owned by this async block. If the task @@ -127,15 +189,20 @@ impl SftpManager { host_name: host_name.clone(), }) .await; - sftp_task_loop(session, sftp, cmd_rx, event_tx.clone()).await; + sftp_task_loop(session, sftp, cmd_rx, event_tx.clone(), cancelled_task).await; tracing::info!("SFTP task for '{}' exited", host_name); }); - Ok(Self { cmd_tx }) + Ok(Self { cmd_tx, cancelled }) } /// Enqueues a command (fire-and-forget). Silently drops if the task exited. pub fn send(&self, cmd: SftpCommand) { + if let SftpCommand::CancelTransfer { transfer_id } = cmd { + self.cancelled.lock().unwrap().insert(transfer_id); + return; + } + let _ = self.cmd_tx.try_send(cmd); } @@ -150,10 +217,11 @@ impl SftpManager { // --------------------------------------------------------------------------- async fn sftp_task_loop( - _ssh: SshSession, // kept alive to hold the SSH connection open + _ssh: SshSession, sftp: russh_sftp::client::SftpSession, mut cmd_rx: mpsc::Receiver, event_tx: mpsc::Sender, + cancelled: Arc>>, ) { while let Some(cmd) = cmd_rx.recv().await { match cmd { @@ -177,9 +245,17 @@ async fn sftp_task_loop( local, transfer_id, } => { - let result = do_download(&sftp, &remote, &local, transfer_id, &event_tx) - .await - .map_err(|e| e.to_string()); + let result = do_download( + &sftp, + &remote, + &local, + transfer_id, + &event_tx, + true, + &cancelled, + ) + .await + .map_err(|e| e.to_string()); let _ = event_tx.send(CoreEvent::SftpOpDone { result }).await; } @@ -188,18 +264,51 @@ async fn sftp_task_loop( remote, transfer_id, } => { - let result = do_upload(&local, &sftp, &remote, transfer_id, &event_tx) - .await - .map_err(|e| e.to_string()); + let result = do_upload( + &local, + &sftp, + &remote, + transfer_id, + &event_tx, + true, + &cancelled, + ) + .await + .map_err(|e| e.to_string()); + let _ = event_tx.send(CoreEvent::SftpOpDone { result }).await; + } + + SftpCommand::DownloadDir { + remote, + local, + transfer_id, + } => { + let result = + do_download_dir(&sftp, &remote, &local, transfer_id, &event_tx, &cancelled) + .await + .map_err(|e| e.to_string()); + + let _ = event_tx.send(CoreEvent::SftpOpDone { result }).await; + } + + SftpCommand::UploadDir { + local, + remote, + transfer_id, + } => { + let result = + do_upload_dir(&local, &sftp, &remote, transfer_id, &event_tx, &cancelled) + .await + .map_err(|e| e.to_string()); + let _ = event_tx.send(CoreEvent::SftpOpDone { result }).await; } SftpCommand::Delete(path) => { - // Try remove_file first; on failure try remove_dir (empty dirs only). - let result = match sftp.remove_file(&path).await { - Ok(()) => Ok(()), - Err(_) => sftp.remove_dir(&path).await.map_err(|e| e.to_string()), - }; + let result = delete_recursive(&sftp, &path) + .await + .map_err(|e| e.to_string()); + let _ = event_tx.send(CoreEvent::SftpOpDone { result }).await; } @@ -221,6 +330,32 @@ async fn sftp_task_loop( } } + // In-app editor: full file read (capped by MAX_IN_APP_EDIT_BYTES). + SftpCommand::ReadFile(path) => match do_read_file(&sftp, &path).await { + Ok(content) => { + let _ = event_tx + .send(CoreEvent::FileContentReady { path, content }) + .await; + } + Err(e) => { + let _ = event_tx + .send(CoreEvent::Error(format!("edit-read:{e}"))) + .await; + } + }, + + // In-app editor: overwrite remote file with buffer contents. + SftpCommand::WriteFile { path, content } => { + let result = do_write_file(&sftp, &path, &content) + .await + .map_err(|e| e.to_string()); + let _ = event_tx + .send(CoreEvent::FileWriteDone { path, result }) + .await; + } + + // Never reaches here; SftpManager::send() intercepts it. + SftpCommand::CancelTransfer { .. } => {} SftpCommand::Disconnect => break, } } @@ -300,6 +435,8 @@ async fn do_download( local: &str, transfer_id: TransferId, event_tx: &mpsc::Sender, + report_progress: bool, + cancelled: &Arc>>, ) -> anyhow::Result<()> { // Guard against path traversal in the local destination. if std::path::Path::new(local) @@ -327,25 +464,56 @@ async fn do_download( .await .context("create local file")?; + let root_name = Path::new(remote) + .parent() + .and_then(|p| p.file_name()) + .unwrap_or_default() + .to_string_lossy() + .into_owned(); + + let current_file = Path::new(remote) + .file_name() + .unwrap_or_default() + .to_string_lossy() + .into_owned(); + let mut buf = vec![0u8; 65_536]; let mut done: u64 = 0; loop { + if cancelled.lock().unwrap().contains(&transfer_id) { + anyhow::bail!("Transfer cancelled"); + } let n = remote_file .read(&mut buf) .await .context("read remote file")?; + if n == 0 { break; } + local_file .write_all(&buf[..n]) .await .context("write local file")?; + done += n as u64; - let _ = event_tx - .send(CoreEvent::FileTransferProgress(transfer_id, done, total)) - .await; + + if report_progress { + let _ = event_tx + .send(CoreEvent::FileTransferProgress { + transfer_id, + stage: TransferStage::Transferring, + root_name: root_name.clone(), + current_file: current_file.clone(), + bytes_done: done, + bytes_total: total, + files_done: 1, + files_total: 1, + }) + .await; + } } Ok(()) @@ -357,6 +525,8 @@ async fn do_upload( remote: &str, transfer_id: TransferId, event_tx: &mpsc::Sender, + report_progress: bool, + cancelled: &Arc>>, ) -> anyhow::Result<()> { // Guard against path traversal in the local source. if std::path::Path::new(local) @@ -382,7 +552,18 @@ async fn do_upload( let mut buf = vec![0u8; 65_536]; let mut done: u64 = 0; + let root_name = Path::new(local) + .file_name() + .unwrap_or_default() + .to_string_lossy() + .into_owned(); + + let current_file = root_name.clone(); + loop { + if cancelled.lock().unwrap().contains(&transfer_id) { + anyhow::bail!("Transfer cancelled"); + } let n = local_file.read(&mut buf).await.context("read local file")?; if n == 0 { break; @@ -392,8 +573,244 @@ async fn do_upload( .await .context("write remote file")?; done += n as u64; + if report_progress { + let _ = event_tx + .send(CoreEvent::FileTransferProgress { + transfer_id, + stage: TransferStage::Transferring, + root_name: root_name.clone(), + current_file: current_file.clone(), + bytes_done: done, + bytes_total: total, + files_done: 1, + files_total: 1, + }) + .await; + } + } + + Ok(()) +} + +async fn do_upload_dir( + local_root: &str, + sftp: &russh_sftp::client::SftpSession, + remote_root: &str, + transfer_id: TransferId, + event_tx: &mpsc::Sender, + cancelled: &Arc>>, +) -> anyhow::Result<()> { + let local_root = PathBuf::from(local_root); + + let root_name = local_root + .file_name() + .unwrap_or(local_root.as_os_str()) + .to_string_lossy() + .into_owned(); + + // Show footer immediately + let _ = event_tx + .send(CoreEvent::FileTransferProgress { + transfer_id, + stage: TransferStage::Preparing, + root_name: root_name.clone(), + current_file: "Scanning files…".into(), + bytes_done: 0, + bytes_total: 0, + files_done: 0, + files_total: 0, + }) + .await; + + // ---------- Pre-scan ---------- + let mut files: Vec<(PathBuf, String, u64)> = Vec::new(); + let mut total_bytes = 0u64; + + for entry in WalkDir::new(&local_root) { + let entry = entry?; + let path = entry.path(); + + if path == local_root { + continue; + } + + let rel = path.strip_prefix(&local_root)?; + let remote_path = Path::new(remote_root) + .join(rel) + .to_string_lossy() + .replace('\\', "/"); + + if entry.file_type().is_file() { + let size = entry.metadata()?.len(); + total_bytes += size; + files.push((path.to_path_buf(), remote_path, size)); + } + } + + // Create destination directories + let _ = sftp.create_dir(remote_root).await; + + for entry in WalkDir::new(&local_root) { + let entry = entry?; + if !entry.file_type().is_dir() { + continue; + } + + let path = entry.path(); + if path == local_root { + continue; + } + + let rel = path.strip_prefix(&local_root)?; + let remote_path = Path::new(remote_root) + .join(rel) + .to_string_lossy() + .replace('\\', "/"); + + let _ = sftp.create_dir(&remote_path).await; + } + + // ---------- Upload ---------- + let total_files = files.len() as u32; + let mut uploaded = 0u64; + + for (index, (local_path, remote_path, size)) in files.into_iter().enumerate() { + if cancelled.lock().unwrap().contains(&transfer_id) { + anyhow::bail!("Transfer cancelled"); + } + do_upload( + local_path.to_str().unwrap(), + sftp, + &remote_path, + transfer_id, + event_tx, + false, + cancelled, + ) + .await?; + + uploaded += size; + + let _ = event_tx + .send(CoreEvent::FileTransferProgress { + transfer_id, + stage: TransferStage::Transferring, + root_name: root_name.clone(), + current_file: local_path + .file_name() + .unwrap() + .to_string_lossy() + .into_owned(), + bytes_done: uploaded, + bytes_total: total_bytes, + files_done: (index + 1) as u32, + files_total: total_files, + }) + .await; + } + + Ok(()) +} + +async fn do_download_dir( + sftp: &russh_sftp::client::SftpSession, + remote_root: &str, + local_root: &str, + transfer_id: TransferId, + event_tx: &mpsc::Sender, + cancelled: &Arc>>, +) -> anyhow::Result<()> { + let root_name = Path::new(remote_root) + .file_name() + .unwrap_or_default() + .to_string_lossy() + .into_owned(); + + // Show footer immediately + let _ = event_tx + .send(CoreEvent::FileTransferProgress { + transfer_id, + stage: TransferStage::Preparing, + root_name: root_name.clone(), + current_file: "Scanning remote files…".into(), + bytes_done: 0, + bytes_total: 0, + files_done: 0, + files_total: 0, + }) + .await; + // ---------- Pre-scan ---------- + let mut work: Vec<(String, PathBuf, u64)> = Vec::new(); + let mut total_bytes = 0u64; + + let mut scan: Vec<(String, PathBuf)> = + vec![(remote_root.to_string(), PathBuf::from(local_root))]; + + while let Some((remote_dir, local_dir)) = scan.pop() { + let entries = do_list_dir(sftp, &remote_dir).await?; + + for entry in entries { + if entry.name == ".." { + continue; + } + + let local_path = local_dir.join(&entry.name); + + if entry.is_dir { + scan.push((entry.path, local_path)); + } else { + total_bytes += entry.size; + work.push((entry.path, local_path, entry.size)); + } + } + } + + // ---------- Download ---------- + let root_name = Path::new(remote_root) + .file_name() + .unwrap_or_default() + .to_string_lossy() + .into_owned(); + + let total_files = work.len() as u32; + let mut downloaded = 0u64; + + for (index, (remote_path, local_path, size)) in work.into_iter().enumerate() { + if cancelled.lock().unwrap().contains(&transfer_id) { + anyhow::bail!("Transfer cancelled"); + } + if let Some(parent) = local_path.parent() { + tokio::fs::create_dir_all(parent).await?; + } + + do_download( + sftp, + &remote_path, + local_path.to_str().unwrap(), + transfer_id, + event_tx, + false, + cancelled, + ) + .await?; + + downloaded += size; + let _ = event_tx - .send(CoreEvent::FileTransferProgress(transfer_id, done, total)) + .send(CoreEvent::FileTransferProgress { + transfer_id, + stage: TransferStage::Transferring, + root_name: root_name.clone(), + current_file: local_path + .file_name() + .unwrap() + .to_string_lossy() + .into_owned(), + bytes_done: downloaded, + bytes_total: total_bytes, + files_done: (index + 1) as u32, + files_total: total_files, + }) .await; } @@ -411,6 +828,59 @@ async fn do_read_preview( Ok(String::from_utf8_lossy(&buf).into_owned()) } +/// Read a full remote text file for the in-app editor. +/// +/// Rejects paths with null bytes, enforces [`MAX_IN_APP_EDIT_BYTES`], and +/// requires valid UTF-8. Oversized files error with a `FILE_TOO_LARGE:` prefix. +async fn do_read_file( + sftp: &russh_sftp::client::SftpSession, + path: &str, +) -> anyhow::Result { + if path.contains('\0') { + anyhow::bail!("path contains null bytes"); + } + + let meta = sftp.metadata(path).await.context("stat remote file")?; + let size = meta.size.unwrap_or(0); + if size > MAX_IN_APP_EDIT_BYTES { + anyhow::bail!("FILE_TOO_LARGE:{size}"); + } + + let mut remote = sftp.open(path).await.context("open remote for edit")?; + let mut chunk = vec![0u8; 65_536]; + let mut data = Vec::with_capacity(size.min(MAX_IN_APP_EDIT_BYTES) as usize); + + loop { + let n = remote.read(&mut chunk).await.context("read remote")?; + if n == 0 { + break; + } + data.extend_from_slice(&chunk[..n]); + if data.len() as u64 > MAX_IN_APP_EDIT_BYTES { + anyhow::bail!("FILE_TOO_LARGE:{}", data.len()); + } + } + + String::from_utf8(data).context("file is not valid UTF-8") +} + +/// Truncate/create a remote file and write UTF-8 content (in-app editor save). +async fn do_write_file( + sftp: &russh_sftp::client::SftpSession, + path: &str, + content: &str, +) -> anyhow::Result<()> { + if path.contains('\0') { + anyhow::bail!("path contains null bytes"); + } + let mut remote = sftp.create(path).await.context("create/truncate remote")?; + remote + .write_all(content.as_bytes()) + .await + .context("write remote")?; + Ok(()) +} + // --------------------------------------------------------------------------- // Local filesystem helpers (called via inline tokio::spawn in App) // --------------------------------------------------------------------------- diff --git a/crates/omnyssh-gui/Cargo.toml b/crates/omnyssh-gui/Cargo.toml index 6f57b68e..c2af070d 100644 --- a/crates/omnyssh-gui/Cargo.toml +++ b/crates/omnyssh-gui/Cargo.toml @@ -26,6 +26,7 @@ tauri = { version = "2", features = ["specta", "tray-icon"] } omnyssh-core = { path = "../omnyssh-core" } serde = { workspace = true } tokio = { workspace = true } +shell-words = "1.1" # Timestamps the key-setup completion write onto the host, matching the TUI's # `key_setup_date` so both frontends round-trip the same shared hosts.toml (§4.2). chrono = { workspace = true } diff --git a/crates/omnyssh-gui/src/bridge.rs b/crates/omnyssh-gui/src/bridge.rs index 0e518645..84e32407 100644 --- a/crates/omnyssh-gui/src/bridge.rs +++ b/crates/omnyssh-gui/src/bridge.rs @@ -6,14 +6,13 @@ use std::collections::HashSet; -use omnyssh_core::event::{CoreEvent, SessionId}; -use tauri::{AppHandle, Manager}; -use tauri_specta::Event; -use tokio::sync::mpsc; - use crate::dto::{FileEntryDto, TransferProgressDto, TunnelStatusDto}; use crate::events; use crate::state::GuiState; +use omnyssh_core::event::{CoreEvent, SessionId, TransferStage}; +use tauri::{AppHandle, Manager}; +use tauri_specta::Event; +use tokio::sync::mpsc; pub async fn forward_core_events( app: AppHandle, @@ -256,6 +255,9 @@ enum SftpOutbound { OpDone(events::SftpOpDone), Disconnected(events::SftpDisconnected), Preview(events::FilePreview), + ContentReady(events::FileContentReady), + ContentReadFailed(events::FileContentReadFailed), + WriteDone(events::FileWriteDone), Progress(events::TransferProgress), } @@ -267,6 +269,9 @@ impl SftpOutbound { SftpOutbound::OpDone(e) => e.emit(app), SftpOutbound::Disconnected(e) => e.emit(app), SftpOutbound::Preview(e) => e.emit(app), + SftpOutbound::ContentReady(e) => e.emit(app), + SftpOutbound::ContentReadFailed(e) => e.emit(app), + SftpOutbound::WriteDone(e) => e.emit(app), SftpOutbound::Progress(e) => e.emit(app), }; } @@ -311,14 +316,58 @@ fn map_sftp_event(session_id: SessionId, event: CoreEvent) -> Option { - SftpOutbound::Progress(events::TransferProgress(TransferProgressDto { + CoreEvent::FileContentReady { path, content } => { + SftpOutbound::ContentReady(events::FileContentReady { + session_id, + path, + content, + }) + } + CoreEvent::FileWriteDone { path, result } => { + let (ok, error) = match result { + Ok(()) => (true, None), + Err(message) => (false, Some(message)), + }; + SftpOutbound::WriteDone(events::FileWriteDone { + session_id, + path, + ok, + error, + }) + } + CoreEvent::Error(message) if message.starts_with("edit-read:") => { + let error = message.trim_start_matches("edit-read:").to_string(); + // The core error currently carries no path. Keep this as a generic + // session error; the requested path is retained in the frontend editor state. + SftpOutbound::ContentReadFailed(events::FileContentReadFailed { session_id, - transfer_id, - done, - total, - })) + path: String::new(), + error, + }) } + CoreEvent::FileTransferProgress { + transfer_id, + stage, + root_name, + current_file, + bytes_done, + bytes_total, + files_done, + files_total, + } => SftpOutbound::Progress(events::TransferProgress(TransferProgressDto { + session_id: session_id as u32, + transfer_id: transfer_id as u32, + stage: match stage { + TransferStage::Preparing => "preparing".into(), + TransferStage::Transferring => "transferring".into(), + }, + root_name, + current_file, + bytes_done, + bytes_total, + files_done, + files_total, + })), // Not produced on a per-session SFTP channel (`SftpManagerReady` is TUI-only). _ => return None, }) @@ -337,7 +386,7 @@ pub async fn forward_sftp_events( // A transfer's owner comes from `transfer_owner`; every other event is stamped // with this forwarder's own session (§3.4). let owner = match &event { - CoreEvent::FileTransferProgress(transfer_id, _, _) => app + CoreEvent::FileTransferProgress { transfer_id, .. } => app .state::() .transfer_session(*transfer_id) .unwrap_or(session_id), @@ -504,11 +553,25 @@ mod tests { #[test] fn progress_carries_the_resolved_owner_and_transfer_id() { - match map_sftp_event(7, CoreEvent::FileTransferProgress(42, 512, 2048)).unwrap() { + match map_sftp_event( + 7, + CoreEvent::FileTransferProgress { + transfer_id: 42, + stage: TransferStage::Transferring, + root_name: "test.txt".into(), + current_file: "test.txt".into(), + bytes_done: 512, + bytes_total: 2048, + files_done: 1, + files_total: 1, + }, + ) + .unwrap() + { SftpOutbound::Progress(events::TransferProgress(dto)) => { assert_eq!(dto.session_id, 7); assert_eq!(dto.transfer_id, 42); - assert_eq!((dto.done, dto.total), (512, 2048)); + assert_eq!((dto.bytes_done, dto.bytes_total), (512, 2048)); } _ => panic!("expected Progress"), } diff --git a/crates/omnyssh-gui/src/commands/sftp.rs b/crates/omnyssh-gui/src/commands/sftp.rs index ab6d971d..3ac79c15 100644 --- a/crates/omnyssh-gui/src/commands/sftp.rs +++ b/crates/omnyssh-gui/src/commands/sftp.rs @@ -4,9 +4,6 @@ //! arrive as events. Local filesystem listing/preview return directly — the GUI never //! emits `LocalDirListed` (§4.3). -use tauri::{AppHandle, State}; -use tokio::sync::mpsc; - use omnyssh_core::event::CoreEvent; use omnyssh_core::ssh::identity; use omnyssh_core::ssh::password::Prompter; @@ -14,6 +11,10 @@ use omnyssh_core::ssh::sftp::{ list_local_dir as core_list_local_dir, local_roots, preview_local_file as core_preview_local_file, SftpCommand, SftpManager, }; +use std::path::PathBuf; +use tauri::Manager; +use tauri::{AppHandle, State}; +use tokio::sync::mpsc; use crate::bridge; use crate::dto::FileEntryDto; @@ -115,6 +116,67 @@ pub fn sftp_download( ); Ok(()) } +///Cancel a file/dir transfer +#[tauri::command] +#[specta::specta] +pub fn sftp_cancel( + state: State<'_, GuiState>, + session_id: u32, + transfer_id: u32, +) -> Result<(), CommandError> { + state.send_sftp( + session_id as u64, + SftpCommand::CancelTransfer { + transfer_id: transfer_id as u64, + }, + ); + + Ok(()) +} + +#[tauri::command] +#[specta::specta] +pub fn sftp_upload_dir( + state: State<'_, GuiState>, + session_id: u64, + local: String, + remote: String, +) -> Result<(), CommandError> { + let transfer_id = state.next_transfer(session_id); + + state.send_sftp( + session_id, + SftpCommand::UploadDir { + local, + remote, + transfer_id, + }, + ); + + Ok(()) +} + +#[tauri::command] +#[specta::specta] +pub fn sftp_download_dir( + state: State<'_, GuiState>, + session_id: u64, + remote: String, + local: String, +) -> Result<(), CommandError> { + let transfer_id = state.next_transfer(session_id); + + state.send_sftp( + session_id, + SftpCommand::DownloadDir { + remote, + local, + transfer_id, + }, + ); + + Ok(()) +} /// Create a remote directory (tech-gui.md §4.2); completion arrives as `sftp-op-done`. #[tauri::command] @@ -165,6 +227,32 @@ pub fn sftp_preview( Ok(()) } +/// Read a complete remote file for the in-app editor. The core enforces the +/// 64 MiB editor cap and UTF-8 validation; the result arrives as `file-content-ready`. +#[tauri::command] +#[specta::specta] +pub fn sftp_read_file( + state: State<'_, GuiState>, + session_id: u64, + path: String, +) -> Result<(), CommandError> { + state.send_sftp(session_id, SftpCommand::ReadFile(path)); + Ok(()) +} + +/// Write the in-app editor buffer back to the remote file. +#[tauri::command] +#[specta::specta] +pub fn sftp_write_file( + state: State<'_, GuiState>, + session_id: u64, + path: String, + content: String, +) -> Result<(), CommandError> { + state.send_sftp(session_id, SftpCommand::WriteFile { path, content }); + Ok(()) +} + /// Close an SFTP session and its connection (tech-gui.md §4.2). #[tauri::command] #[specta::specta] @@ -204,3 +292,322 @@ pub async fn preview_local_file(path: String) -> Result { message: e.to_string(), }) } + +/// Create a local directory (tech-gui.md §4.2). Mirrors `sftp_mkdir` for the +/// local filesystem so both panes expose the same toolbar actions. +#[tauri::command] +#[specta::specta] +pub async fn local_mkdir(path: String) -> Result<(), CommandError> { + tokio::fs::create_dir_all(path) + .await + .map_err(|e| CommandError { + message: e.to_string(), + }) +} + +/// Rename or move a local file or directory (tech-gui.md §4.2). Mirrors +/// `sftp_rename` and preserves the unified local/remote workflow. +#[tauri::command] +#[specta::specta] +pub async fn local_rename(from: String, to: String) -> Result<(), CommandError> { + tokio::fs::rename(from, to).await.map_err(|e| CommandError { + message: e.to_string(), + }) +} + +/// Delete a local file or directory recursively (tech-gui.md §4.2). Matches the +/// remote delete behaviour so the confirmation dialog is shared by both panes. +#[tauri::command] +#[specta::specta] +pub async fn local_delete(path: String) -> Result<(), CommandError> { + let meta = tokio::fs::metadata(&path).await.map_err(|e| CommandError { + message: e.to_string(), + })?; + + if meta.is_dir() { + tokio::fs::remove_dir_all(path) + .await + .map_err(|e| CommandError { + message: e.to_string(), + }) + } else { + tokio::fs::remove_file(path) + .await + .map_err(|e| CommandError { + message: e.to_string(), + }) + } +} + +/// Read a complete local UTF-8 text file for the in-app editor (tech-gui.md §4.2). +/// Mirrors `sftp_read_file` so the editor can switch backends transparently. +#[tauri::command] +#[specta::specta] +pub async fn local_read_file(path: String) -> Result { + tokio::fs::read_to_string(path) + .await + .map_err(|e| CommandError { + message: e.to_string(), + }) +} + +/// Overwrite a local file with the editor buffer (tech-gui.md §4.2). Mirrors +/// `sftp_write_file` and keeps the editor backend-agnostic. +#[tauri::command] +#[specta::specta] +pub async fn local_write_file(path: String, content: String) -> Result<(), CommandError> { + tokio::fs::write(path, content) + .await + .map_err(|e| CommandError { + message: e.to_string(), + }) +} + +/// Prepares external editors for local files +#[tauri::command] +#[specta::specta] +pub fn local_prepare_external_edit(app: tauri::AppHandle, path: String) -> Result { + let src = PathBuf::from(&path); + + if !src.exists() { + return Err(format!("File not found: {}", path)); + } + + let cache_dir = app + .path() + .app_cache_dir() + .map_err(|e| e.to_string())? + .join("external-edits"); + + std::fs::create_dir_all(&cache_dir).map_err(|e| e.to_string())?; + + let name = src.file_name().and_then(|n| n.to_str()).unwrap_or("file"); + + let temp = cache_dir.join(name); + + std::fs::copy(&src, &temp).map_err(|e| e.to_string())?; + + Ok(temp.to_string_lossy().to_string()) +} + +/// Create a unique local temporary path for a large-file external edit. The empty +/// file is created with `create_new` so a collision cannot overwrite an existing file. +#[tauri::command] +#[specta::specta] +pub fn sftp_prepare_external_edit(remote_path: String) -> Result { + use std::fs::{create_dir_all, OpenOptions}; + use std::path::Path; + use std::time::{SystemTime, UNIX_EPOCH}; + + let temp_root = std::env::temp_dir().join("omnyssh"); + create_dir_all(&temp_root).map_err(|e| CommandError { + message: e.to_string(), + })?; + + let filename = Path::new(&remote_path) + .file_name() + .and_then(|s| s.to_str()) + .unwrap_or("file"); + + let stamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|e| CommandError { + message: e.to_string(), + })? + .as_nanos(); + + let path = temp_root.join(format!("{}-{}-{}", std::process::id(), stamp, filename)); + + OpenOptions::new() + .write(true) + .create_new(true) + .open(&path) + .map_err(|e| CommandError { + message: e.to_string(), + })?; + + Ok(path.to_string_lossy().into_owned()) +} + +/// Hash a local edit file for change detection without sending its contents through IPC. +#[tauri::command] +#[specta::specta] +pub async fn sftp_local_file_hash(path: String) -> Result { + tokio::fs::read(&path) + .await + .map(|bytes| { + use std::collections::hash_map::DefaultHasher; + use std::hash::{Hash, Hasher}; + let mut hasher = DefaultHasher::new(); + bytes.hash(&mut hasher); + format!("{:016x}", hasher.finish()) + }) + .map_err(|e| CommandError { + message: e.to_string(), + }) +} + +/// Run the user's configured external editor and wait for it to exit. +/// GUI editors are launched directly in blocking mode, while terminal editors +/// are opened inside the platform's terminal application. +#[tauri::command] +#[specta::specta] +pub async fn sftp_open_external_editor(path: String, editor: String) -> Result { + tokio::task::spawn_blocking(move || { + use std::process::Command; + + let editor = if editor.trim().is_empty() { + std::env::var("OMNYSSH_EDITOR") + .ok() + .filter(|v| !v.trim().is_empty()) + .or_else(|| std::env::var("VISUAL").ok()) + .or_else(|| std::env::var("EDITOR").ok()) + .unwrap_or_else(|| "nano".to_string()) + } else { + editor + }; + + let mut parts = editor.split_whitespace(); + let program = parts.next().ok_or_else(|| CommandError { + message: "External editor is empty".into(), + })?; + + let mut args: Vec = parts.map(str::to_string).collect(); + + // Make common GUI editors block automatically. + match program { + "zed" => { + if !args.iter().any(|a| a == "--wait") { + args.push("--wait".into()); + } + } + "code" | "code-insiders" => { + if !args.iter().any(|a| a == "--wait") { + args.push("--wait".into()); + } + } + "gedit" => { + if !args.iter().any(|a| a == "--wait") { + args.push("--wait".into()); + } + } + "kate" => { + if !args.iter().any(|a| a == "--block") { + args.push("--block".into()); + } + } + _ => {} + } + + let terminal_editors = ["nano", "vim", "nvim", "hx", "helix", "kak", "fresh"]; + + let is_terminal = terminal_editors.contains(&program); + + let status = if is_terminal { + #[cfg(target_os = "linux")] + { + let terminals = [ + "x-terminal-emulator", + "kgx", + "gnome-terminal", + "kitty", + "konsole", + "xfce4-terminal", + "xterm", + ]; + + let terminal = terminals + .iter() + .find(|t| { + Command::new("which") + .arg(t) + .output() + .map(|o| o.status.success()) + .unwrap_or(false) + }) + .ok_or_else(|| CommandError { + message: "No terminal emulator found".into(), + })?; + + match *terminal { + "gnome-terminal" | "kgx" => Command::new(terminal) + .arg("--") + .arg(program) + .args(&args) + .arg(&path) + .status(), + + "konsole" => Command::new(terminal) + .arg("-e") + .arg(program) + .args(&args) + .arg(&path) + .status(), + + _ => Command::new(terminal) + .arg("-e") + .arg(program) + .args(&args) + .arg(&path) + .status(), + } + } + + #[cfg(target_os = "macos")] + { + let shell = format!( + "{} {} ; exit", + program, + std::iter::once(path.clone()) + .chain(args.clone()) + .map(|s| format!("'{}'", s.replace('\'', "'\\''"))) + .collect::>() + .join(" ") + ); + + Command::new("osascript") + .arg("-e") + .arg(format!( + r#"tell application "Terminal" to do script "{}""#, + shell.replace('"', "\\\"") + )) + .arg("-e") + .arg(r#"tell application "Terminal" to activate"#) + .status() + } + + #[cfg(target_os = "windows")] + { + Command::new("wt.exe") + .arg(program) + .args(&args) + .arg(&path) + .status() + } + } else { + Command::new(program).args(&args).arg(&path).status() + } + .map_err(|e| CommandError { + message: e.to_string(), + })?; + + Ok(status.success()) + }) + .await + .map_err(|e| CommandError { + message: e.to_string(), + })? +} + +/// Remove an external-editor temporary file. Cleanup is idempotent. +#[tauri::command] +#[specta::specta] +pub async fn sftp_remove_temp_file(path: String) -> Result<(), CommandError> { + match tokio::fs::remove_file(&path).await { + Ok(()) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(CommandError { + message: e.to_string(), + }), + } +} diff --git a/crates/omnyssh-gui/src/commands/update.rs b/crates/omnyssh-gui/src/commands/update.rs index 950dce61..efde6fb5 100644 --- a/crates/omnyssh-gui/src/commands/update.rs +++ b/crates/omnyssh-gui/src/commands/update.rs @@ -11,12 +11,13 @@ use tauri_plugin_updater::UpdaterExt; use tokio::sync::mpsc; use omnyssh_core::config::app_config::{ - load_app_config, save_update_config as core_save_update_config, + load_app_config, save_general_config as core_save_general_config, + save_update_config as core_save_update_config, }; use omnyssh_core::event::CoreEvent; use omnyssh_core::update; -use crate::dto::{UpdateConfigDto, UpdateInfoDto}; +use crate::dto::{GeneralConfigDto, UpdateConfigDto, UpdateInfoDto}; use crate::error::CommandError; /// Query GitHub for a newer release (tech-gui.md §4.2). `None` means up to date — the @@ -87,6 +88,38 @@ pub async fn save_update_config(config: UpdateConfigDto) -> Result<(), CommandEr }) } +/// Read the shared `[general]` configuration. +#[tauri::command] +#[specta::specta] +pub async fn load_general_config() -> Result { + let config = tauri::async_runtime::spawn_blocking(|| load_app_config(None)) + .await + .map_err(|e| CommandError { + message: format!("config load task failed: {e}"), + })? + .map_err(|e| CommandError { + message: e.to_string(), + })?; + + Ok((&config.general).into()) +} + +/// Persist the shared `[general]` configuration. +#[tauri::command] +#[specta::specta] +pub async fn save_general_config(config: GeneralConfigDto) -> Result<(), CommandError> { + let general = config.into(); + + tauri::async_runtime::spawn_blocking(move || core_save_general_config(&general)) + .await + .map_err(|e| CommandError { + message: format!("config save task failed: {e}"), + })? + .map_err(|e| CommandError { + message: e.to_string(), + }) +} + /// The startup update check (tech-gui.md §3.4/§4.3): honour `check_on_startup` and the /// user's skipped version, then emit `UpdateAvailable` on the shared engine channel (the /// bridge maps it to `update-available`). Errors are swallowed — a failed check never diff --git a/crates/omnyssh-gui/src/dto.rs b/crates/omnyssh-gui/src/dto.rs index cf86c283..047ee315 100644 --- a/crates/omnyssh-gui/src/dto.rs +++ b/crates/omnyssh-gui/src/dto.rs @@ -4,7 +4,7 @@ use serde::{Deserialize, Serialize}; -use omnyssh_core::config::app_config::UpdateConfig; +use omnyssh_core::config::app_config::{GeneralConfig, UpdateConfig}; use omnyssh_core::config::snippets::{Snippet, SnippetScope}; use omnyssh_core::event::{ DetectedService, MetricValue, Metrics, ProcessInfo, ServiceKind, ServiceMetric, @@ -259,10 +259,15 @@ pub struct FileEntryDto { #[derive(Debug, Clone, Serialize, Deserialize, specta::Type)] #[serde(rename_all = "camelCase")] pub struct TransferProgressDto { - pub session_id: u64, - pub transfer_id: u64, - pub done: u64, - pub total: u64, + pub session_id: u32, + pub transfer_id: u32, + pub root_name: String, + pub current_file: String, + pub stage: String, + pub bytes_done: u64, + pub bytes_total: u64, + pub files_done: u32, + pub files_total: u32, } /// A newer release the app can offer (tech-gui.md §4.1). `version` is the latest @@ -278,6 +283,21 @@ pub struct UpdateInfoDto { pub can_self_update: bool, } +/// General application preferences shared by both TUI and GUI. +#[derive(Debug, Clone, Serialize, Deserialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct GeneralConfigDto { + pub refresh_interval: u64, + pub default_shell: String, + pub ssh_command: String, + pub max_concurrent_connections: usize, + + pub external_editor: String, + pub large_file_mb: u64, + pub prompt_large_files: bool, + pub auto_upload_external: bool, +} + /// Update-checker preferences, mirrors core `UpdateConfig` (tech-gui.md §4.3). Crosses /// both ways: outbound for the settings screen, inbound for `save_update_config`. #[derive(Debug, Clone, Serialize, Deserialize, specta::Type)] @@ -594,6 +614,38 @@ impl From for UpdateConfig { } } +impl From<&GeneralConfig> for GeneralConfigDto { + fn from(config: &GeneralConfig) -> Self { + Self { + refresh_interval: config.refresh_interval, + default_shell: config.default_shell.clone(), + ssh_command: config.ssh_command.clone(), + max_concurrent_connections: config.max_concurrent_connections, + + external_editor: config.external_editor.clone(), + large_file_mb: config.large_file_mb, + prompt_large_files: config.prompt_large_files, + auto_upload_external: config.auto_upload_external, + } + } +} + +impl From for GeneralConfig { + fn from(dto: GeneralConfigDto) -> Self { + Self { + refresh_interval: dto.refresh_interval, + default_shell: dto.default_shell, + ssh_command: dto.ssh_command, + max_concurrent_connections: dto.max_concurrent_connections, + + external_editor: dto.external_editor, + large_file_mb: dto.large_file_mb, + prompt_large_files: dto.prompt_large_files, + auto_upload_external: dto.auto_upload_external, + } + } +} + impl From for KeySetupStepDto { fn from(step: KeySetupStep) -> Self { Self { @@ -1050,15 +1102,21 @@ mod tests { #[test] fn transfer_progress_dto_carries_session_transfer_and_byte_counts() { let json = serde_json::to_string(&TransferProgressDto { - session_id: 3, - transfer_id: 7, - done: 512, - total: 2048, + session_id: 7, + transfer_id: 42, + root_name: "test.txt".into(), + current_file: "test.txt".into(), + stage: "transferring".into(), + bytes_done: 512, + bytes_total: 2048, + files_done: 1, + files_total: 1, }) .expect("serialise TransferProgressDto"); + assert_eq!( json, - r#"{"sessionId":3,"transferId":7,"done":512,"total":2048}"# + r#"{"sessionId":7,"transferId":42,"rootName":"test.txt","currentFile":"test.txt","stage":"transferring","bytesDone":512,"bytesTotal":2048,"filesDone":1,"filesTotal":1}"# ); } diff --git a/crates/omnyssh-gui/src/events.rs b/crates/omnyssh-gui/src/events.rs index 8ced3da3..b4808af0 100644 --- a/crates/omnyssh-gui/src/events.rs +++ b/crates/omnyssh-gui/src/events.rs @@ -131,6 +131,35 @@ pub struct FilePreview { pub content: String, } +/// Full UTF-8 remote file content for the in-app editor. +#[derive(Debug, Clone, Serialize, Deserialize, specta::Type, tauri_specta::Event)] +#[serde(rename_all = "camelCase")] +pub struct FileContentReady { + pub session_id: u64, + pub path: String, + pub content: String, +} + +/// Result of an in-app editor save. +#[derive(Debug, Clone, Serialize, Deserialize, specta::Type, tauri_specta::Event)] +#[serde(rename_all = "camelCase")] +pub struct FileWriteDone { + pub session_id: u64, + pub path: String, + pub ok: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +/// A full-file editor read failed for a specific SFTP tab. +#[derive(Debug, Clone, Serialize, Deserialize, specta::Type, tauri_specta::Event)] +#[serde(rename_all = "camelCase")] +pub struct FileContentReadFailed { + pub session_id: u64, + pub path: String, + pub error: String, +} + /// Live transfer progress (tech-gui.md §4.3). The payload is `TransferProgressDto`, /// routed to its owning session via `transfer_owner` (§3.4/§4.1). #[derive(Debug, Clone, Serialize, Deserialize, specta::Type, tauri_specta::Event)] diff --git a/crates/omnyssh-gui/src/main.rs b/crates/omnyssh-gui/src/main.rs index 0148f6c9..2d0c13c7 100644 --- a/crates/omnyssh-gui/src/main.rs +++ b/crates/omnyssh-gui/src/main.rs @@ -18,8 +18,11 @@ use commands::auth::{answer_password, unlock_identity}; use commands::hosts::{delete_host, list_hosts, refresh_metrics, reload_hosts, save_host}; use commands::keysetup::start_key_setup; use commands::sftp::{ - list_local_dir, list_local_roots, preview_local_file, sftp_close, sftp_delete, sftp_download, - sftp_list, sftp_mkdir, sftp_open, sftp_preview, sftp_rename, sftp_upload, + list_local_dir, list_local_roots, local_delete, local_mkdir, local_prepare_external_edit, + local_read_file, local_rename, local_write_file, preview_local_file, sftp_cancel, sftp_close, + sftp_delete, sftp_download, sftp_download_dir, sftp_list, sftp_local_file_hash, sftp_mkdir, + sftp_open, sftp_open_external_editor, sftp_prepare_external_edit, sftp_preview, sftp_read_file, + sftp_remove_temp_file, sftp_rename, sftp_upload, sftp_upload_dir, sftp_write_file, }; use commands::snippets::{delete_snippet, execute_snippet, list_snippets, save_snippet}; use commands::terminal::{ @@ -27,7 +30,11 @@ use commands::terminal::{ }; use commands::tray::set_tray_behavior; use commands::tunnels::{tunnel_start, tunnel_stop}; -use commands::update::{check_update, install_update, load_update_config, save_update_config}; +use commands::update::{ + check_update, install_update, load_general_config, load_update_config, save_general_config, + save_update_config, +}; + use omnyssh_core::event::{CoreEvent, SessionId}; use omnyssh_core::ssh::pty::PtyManager; use state::GuiState; @@ -108,14 +115,29 @@ fn specta_builder() -> Builder { sftp_list, sftp_upload, sftp_download, + sftp_cancel, + sftp_upload_dir, + sftp_download_dir, sftp_mkdir, sftp_rename, sftp_delete, sftp_preview, + sftp_read_file, + sftp_write_file, + sftp_prepare_external_edit, + sftp_local_file_hash, + sftp_open_external_editor, + sftp_remove_temp_file, sftp_close, list_local_dir, list_local_roots, + local_delete, + local_mkdir, + local_read_file, + local_rename, + local_write_file, preview_local_file, + local_prepare_external_edit, start_key_setup, tunnel_start, tunnel_stop, @@ -126,7 +148,9 @@ fn specta_builder() -> Builder { check_update, install_update, load_update_config, - save_update_config + save_update_config, + load_general_config, + save_general_config, ]) .events(collect_events![ events::HostsLoaded, @@ -142,6 +166,9 @@ fn specta_builder() -> Builder { events::SftpOpDone, events::SftpDisconnected, events::FilePreview, + events::FileContentReady, + events::FileContentReadFailed, + events::FileWriteDone, events::TransferProgress, events::KeySetupProgress, events::KeySetupComplete, diff --git a/crates/omnyssh-gui/ui/src/lib/bindings.ts b/crates/omnyssh-gui/ui/src/lib/bindings.ts index e497bf6c..9dcf9d51 100644 --- a/crates/omnyssh-gui/ui/src/lib/bindings.ts +++ b/crates/omnyssh-gui/ui/src/lib/bindings.ts @@ -219,6 +219,33 @@ async sftpDownload(sessionId: number, local: string, remote: string) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("sftp_cancel", { sessionId, transferId }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +async sftpUploadDir(sessionId: number, local: string, remote: string) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("sftp_upload_dir", { sessionId, local, remote }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +async sftpDownloadDir(sessionId: number, remote: string, local: string) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("sftp_download_dir", { sessionId, remote, local }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, /** * Create a remote directory (tech-gui.md §4.2); completion arrives as `sftp-op-done`. */ @@ -263,6 +290,76 @@ async sftpPreview(sessionId: number, path: string) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("sftp_read_file", { sessionId, path }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +/** + * Write the in-app editor buffer back to the remote file. + */ +async sftpWriteFile(sessionId: number, path: string, content: string) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("sftp_write_file", { sessionId, path, content }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +/** + * Create a unique local temporary path for a large-file external edit. The empty + * file is created with `create_new` so a collision cannot overwrite an existing file. + */ +async sftpPrepareExternalEdit(remotePath: string) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("sftp_prepare_external_edit", { remotePath }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +/** + * Hash a local edit file for change detection without sending its contents through IPC. + */ +async sftpLocalFileHash(path: string) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("sftp_local_file_hash", { path }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +/** + * Run the user's configured external editor and wait for it to exit. + * GUI editors are launched directly in blocking mode, while terminal editors + * are opened inside the platform's terminal application. + */ +async sftpOpenExternalEditor(path: string, editor: string) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("sftp_open_external_editor", { path, editor }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +/** + * Remove an external-editor temporary file. Cleanup is idempotent. + */ +async sftpRemoveTempFile(path: string) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("sftp_remove_temp_file", { path }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, /** * Close an SFTP session and its connection (tech-gui.md §4.2). */ @@ -294,6 +391,66 @@ async listLocalDir(path: string) : Promise> async listLocalRoots() : Promise { return await TAURI_INVOKE("list_local_roots"); }, +/** + * Delete a local file or directory recursively (tech-gui.md §4.2). Matches the + * remote delete behaviour so the confirmation dialog is shared by both panes. + */ +async localDelete(path: string) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("local_delete", { path }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +/** + * Create a local directory (tech-gui.md §4.2). Mirrors `sftp_mkdir` for the + * local filesystem so both panes expose the same toolbar actions. + */ +async localMkdir(path: string) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("local_mkdir", { path }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +/** + * Read a complete local UTF-8 text file for the in-app editor (tech-gui.md §4.2). + * Mirrors `sftp_read_file` so the editor can switch backends transparently. + */ +async localReadFile(path: string) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("local_read_file", { path }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +/** + * Rename or move a local file or directory (tech-gui.md §4.2). Mirrors + * `sftp_rename` and preserves the unified local/remote workflow. + */ +async localRename(from: string, to: string) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("local_rename", { from, to }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +/** + * Overwrite a local file with the editor buffer (tech-gui.md §4.2). Mirrors + * `sftp_write_file` and keeps the editor backend-agnostic. + */ +async localWriteFile(path: string, content: string) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("local_write_file", { path, content }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, /** * Read up to 4 KiB of a local file as UTF-8 for preview (tech-gui.md §4.2). */ @@ -305,6 +462,17 @@ async previewLocalFile(path: string) : Promise> { else return { status: "error", error: e as any }; } }, +/** + * Prepares external editors for local files + */ +async localPrepareExternalEdit(path: string) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("local_prepare_external_edit", { path }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, /** * Start auto key-setup for `host_name` (tech-gui.md §4.2). Fire-and-forget: the flow * runs on a background task and reports via `key-setup-*` events, mirroring the core's @@ -442,6 +610,28 @@ async saveUpdateConfig(config: UpdateConfigDto) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("load_general_config") }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +/** + * Persist the shared `[general]` configuration. + */ +async saveGeneralConfig(config: GeneralConfigDto) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("save_general_config", { config }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} } } @@ -450,7 +640,10 @@ async saveUpdateConfig(config: UpdateConfigDto) : Promise({ error: "error", +fileContentReadFailed: "file-content-read-failed", +fileContentReady: "file-content-ready", filePreview: "file-preview", +fileWriteDone: "file-write-done", hostStatusChanged: "host-status-changed", hostsLoaded: "hosts-loaded", keyPassphraseRequired: "key-passphrase-required", @@ -512,6 +708,14 @@ export type ConnectionStatusDto = { kind: "unknown" } | { kind: "connecting" } | * A background error surfaced to the user. */ export type Error = { message: string } +/** + * A full-file editor read failed for a specific SFTP tab. + */ +export type FileContentReadFailed = { sessionId: number; path: string; error: string } +/** + * Full UTF-8 remote file content for the in-app editor. + */ +export type FileContentReady = { sessionId: number; path: string; content: string } /** * A file or directory in an SFTP panel listing (tech-gui.md §4.1). Maps from the * core `FileEntry`; `path` is the absolute path the frontend marks entries by. @@ -522,6 +726,14 @@ export type FileEntryDto = { name: string; path: string; size: number; isDir: bo * core `FilePreviewReady` carries only the path + content (§3.4). */ export type FilePreview = { sessionId: number; path: string; content: string } +/** + * Result of an in-app editor save. + */ +export type FileWriteDone = { sessionId: number; path: string; ok: boolean; error?: string | null } +/** + * General application preferences shared by both TUI and GUI. + */ +export type GeneralConfigDto = { refreshInterval: number; defaultShell: string; sshCommand: string; maxConcurrentConnections: number; externalEditor: string; largeFileMb: number; promptLargeFiles: boolean; autoUploadExternal: boolean } /** * A host as the frontend sees it — password and private-key material omitted * (tech-gui.md §3.4). `hasKey` reports whether an identity file is configured; @@ -704,7 +916,7 @@ export type TransferProgress = TransferProgressDto * `transfer_owner` (§3.4); `done`/`total` are byte counts (`total` is `0` when the * remote size could not be determined). */ -export type TransferProgressDto = { sessionId: number; transferId: number; done: number; total: number } +export type TransferProgressDto = { sessionId: number; transferId: number; rootName: string; currentFile: string; stage: string; bytesDone: number; bytesTotal: number; filesDone: number; filesTotal: number } /** * What this desktop allows the tray (tech-gui.md §4.2 `set_tray_behavior`): an icon * at all, and hiding a minimized window into it. diff --git a/crates/omnyssh-gui/ui/src/lib/ipc/commands.ts b/crates/omnyssh-gui/ui/src/lib/ipc/commands.ts index cf40818d..3dcc112c 100644 --- a/crates/omnyssh-gui/ui/src/lib/ipc/commands.ts +++ b/crates/omnyssh-gui/ui/src/lib/ipc/commands.ts @@ -1,70 +1,72 @@ // Thin typed wrappers over the generated command bindings (tech-gui.md §3.5). // Components call these, never `invoke` directly. -import type { Channel } from '@tauri-apps/api/core'; -import { commands } from '$lib/bindings'; +import type { Channel } from "@tauri-apps/api/core"; +import { invoke } from "@tauri-apps/api/core"; +import { commands } from "$lib/bindings"; import type { FileEntryDto, + GeneralConfigDto, HostDto, HostInputDto, SnippetDto, TerminalBytes, TraySupportDto, UpdateConfigDto, - UpdateInfoDto -} from '$lib/bindings'; + UpdateInfoDto, +} from "$lib/bindings"; export async function listHosts(): Promise { const res = await commands.listHosts(); - if (res.status === 'error') throw new Error(res.error.message); + if (res.status === "error") throw new Error(res.error.message); return res.data; } /** Reload hosts from disk and (re)start the pollers; broadcasts `hosts-loaded`. */ export async function reloadHosts(): Promise { const res = await commands.reloadHosts(); - if (res.status === 'error') throw new Error(res.error.message); + if (res.status === "error") throw new Error(res.error.message); } /** Add or edit a manual host in `hosts.toml`. Call `reloadHosts` after to refresh. */ export async function saveHost(input: HostInputDto): Promise { const res = await commands.saveHost(input); - if (res.status === 'error') throw new Error(res.error.message); + if (res.status === "error") throw new Error(res.error.message); } /** Delete a manual host by name. A missing / SSH-config name is a no-op success. */ export async function deleteHost(name: string): Promise { const res = await commands.deleteHost(name); - if (res.status === 'error') throw new Error(res.error.message); + if (res.status === "error") throw new Error(res.error.message); } /** Read the saved snippets from the shared `snippets.toml`. */ export async function listSnippets(): Promise { const res = await commands.listSnippets(); - if (res.status === 'error') throw new Error(res.error.message); + if (res.status === "error") throw new Error(res.error.message); return res.data; } /** Upsert one snippet by name and persist the whole list. */ export async function saveSnippet(snippet: SnippetDto): Promise { const res = await commands.saveSnippet(snippet); - if (res.status === 'error') throw new Error(res.error.message); + if (res.status === "error") throw new Error(res.error.message); } /** Delete the snippet named `name` and persist. */ export async function deleteSnippet(name: string): Promise { const res = await commands.deleteSnippet(name); - if (res.status === 'error') throw new Error(res.error.message); + if (res.status === "error") throw new Error(res.error.message); } /** Run a snippet on one or more hosts; results arrive as `snippet-result` events. */ export async function executeSnippet( snippetName: string, hostNames: string[], - params: Record + params: Record, ): Promise { const res = await commands.executeSnippet(snippetName, hostNames, params); - if (res.status === 'error') throw new Error(res.error.message); + if (res.status === "error") throw new Error(res.error.message); } /** Open a terminal for `hostName`, streaming raw output into `onOutput`; returns the @@ -73,95 +75,183 @@ export async function terminalOpen( hostName: string, cols: number, rows: number, - onOutput: Channel + onOutput: Channel, ): Promise { const res = await commands.terminalOpen(hostName, cols, rows, onOutput); - if (res.status === 'error') throw new Error(res.error.message); + if (res.status === "error") throw new Error(res.error.message); return res.data; } /** Send keystrokes (UTF-8 bytes) to a terminal. */ -export async function terminalWrite(sessionId: number, data: number[]): Promise { +export async function terminalWrite( + sessionId: number, + data: number[], +): Promise { const res = await commands.terminalWrite(sessionId, data); - if (res.status === 'error') throw new Error(res.error.message); + if (res.status === "error") throw new Error(res.error.message); } /** Reflow a terminal to `cols` x `rows`. */ -export async function terminalResize(sessionId: number, cols: number, rows: number): Promise { +export async function terminalResize( + sessionId: number, + cols: number, + rows: number, +): Promise { const res = await commands.terminalResize(sessionId, cols, rows); - if (res.status === 'error') throw new Error(res.error.message); + if (res.status === "error") throw new Error(res.error.message); } /** Close a terminal and its connection. Idempotent for an already-closed id. */ export async function terminalClose(sessionId: number): Promise { const res = await commands.terminalClose(sessionId); - if (res.status === 'error') throw new Error(res.error.message); + if (res.status === "error") throw new Error(res.error.message); } /** Open an SFTP session for `hostName`; returns the public session id the sftp_* * wrappers use, and the tab's `sftp-*` events carry (tech-gui.md §3.4/§4.2). */ export async function sftpOpen(hostName: string): Promise { const res = await commands.sftpOpen(hostName); - if (res.status === 'error') throw new Error(res.error.message); + if (res.status === "error") throw new Error(res.error.message); return res.data; } /** List a remote directory; the result arrives as `sftp-dir-listed`. */ export async function sftpList(sessionId: number, path: string): Promise { const res = await commands.sftpList(sessionId, path); - if (res.status === 'error') throw new Error(res.error.message); + if (res.status === "error") throw new Error(res.error.message); } /** Upload a local file to a remote path; progress arrives as `transfer-progress`. */ -export async function sftpUpload(sessionId: number, local: string, remote: string): Promise { +export async function sftpUpload( + sessionId: number, + local: string, + remote: string, +): Promise { const res = await commands.sftpUpload(sessionId, local, remote); - if (res.status === 'error') throw new Error(res.error.message); + if (res.status === "error") throw new Error(res.error.message); } /** Download a remote file to a local path; progress arrives as `transfer-progress`. */ export async function sftpDownload( sessionId: number, local: string, - remote: string + remote: string, ): Promise { const res = await commands.sftpDownload(sessionId, local, remote); - if (res.status === 'error') throw new Error(res.error.message); + if (res.status === "error") throw new Error(res.error.message); } +/** Upload a remote Directory to a local path; progress arrives as `transfer-progress`. */ +export const sftpUploadDir = async ( + sessionId: number, + local: string, + remote: string, +): Promise => { + const result = await commands.sftpUploadDir(sessionId, local, remote); + if (result.status === "error") throw new Error(result.error.message); +}; +/** Download a remote Directory to a local path; progress arrives as `transfer-progress`. */ +export const sftpDownloadDir = async ( + sessionId: number, + remote: string, + local: string, +): Promise => { + const result = await commands.sftpDownloadDir(sessionId, remote, local); + if (result.status === "error") throw new Error(result.error.message); +}; + +// Allocate a unique temporary file path for an external edit. The Rust backend +// creates the empty file inside the system temp directory and preserves the +// original filename so editors display a meaningful tab name. +export const sftpPrepareExternalEdit = async (remotePath: string) => { + const result = await commands.sftpPrepareExternalEdit(remotePath); + if (result.status === "error") throw new Error(result.error.message); + return result.data; +}; + +// Launch the user's external editor and wait for it to exit. +// The editor command comes from Settings (e.g. "zed --wait", "nano"). +// the file was modified while it was open. + +export const sftpOpenExternalEditor = async ( + path: string, + editor: string, +): Promise => { + const result = await commands.sftpOpenExternalEditor(path, editor); + if (result.status === "error") throw new Error(result.error.message); + return result.data; +}; + +// Remove the temporary file created for an external edit. Cleanup is +// idempotent, so calling this on an already-deleted file is safe. +export const sftpRemoveTempFile = async (path: string) => { + const result = await commands.sftpRemoveTempFile(path); + if (result.status === "error") throw new Error(result.error.message); +}; /** Create a remote directory; completion arrives as `sftp-op-done`. */ -export async function sftpMkdir(sessionId: number, path: string): Promise { +export async function sftpMkdir( + sessionId: number, + path: string, +): Promise { const res = await commands.sftpMkdir(sessionId, path); - if (res.status === 'error') throw new Error(res.error.message); + if (res.status === "error") throw new Error(res.error.message); } /** Rename / move a remote path; completion arrives as `sftp-op-done`. */ -export async function sftpRename(sessionId: number, from: string, to: string): Promise { +export async function sftpRename( + sessionId: number, + from: string, + to: string, +): Promise { const res = await commands.sftpRename(sessionId, from, to); - if (res.status === 'error') throw new Error(res.error.message); + if (res.status === "error") throw new Error(res.error.message); } /** Delete a remote file (or empty directory); completion arrives as `sftp-op-done`. */ -export async function sftpDelete(sessionId: number, path: string): Promise { +export async function sftpDelete( + sessionId: number, + path: string, +): Promise { const res = await commands.sftpDelete(sessionId, path); - if (res.status === 'error') throw new Error(res.error.message); + if (res.status === "error") throw new Error(res.error.message); } /** Request a remote file preview; the bytes arrive as `file-preview`. */ -export async function sftpPreview(sessionId: number, path: string): Promise { +export async function sftpPreview( + sessionId: number, + path: string, +): Promise { const res = await commands.sftpPreview(sessionId, path); - if (res.status === 'error') throw new Error(res.error.message); + if (res.status === "error") throw new Error(res.error.message); +} + +/** Prepare the external editor for local files. */ +export async function localPrepareExternalEdit(path: string): Promise { + return await invoke("local_prepare_external_edit", { path }); } /** Close an SFTP session and its connection. Idempotent for an already-closed id. */ export async function sftpClose(sessionId: number): Promise { const res = await commands.sftpClose(sessionId); - if (res.status === 'error') throw new Error(res.error.message); + if (res.status === "error") throw new Error(res.error.message); +} + +/** Cancels a file transfer */ +export async function sftpCancel( + sessionId: number, + transferId: number, +): Promise { + const result = await commands.sftpCancel(sessionId, transferId); + + if (result.status === "error") { + throw new Error(result.error.message); + } } /** List a local directory (returns directly — no event). */ export async function listLocalDir(path: string): Promise { const res = await commands.listLocalDir(path); - if (res.status === 'error') throw new Error(res.error.message); + if (res.status === "error") throw new Error(res.error.message); return res.data; } @@ -173,57 +263,91 @@ export async function listLocalRoots(): Promise { /** Read up to 4 KiB of a local file as UTF-8 for preview. */ export async function previewLocalFile(path: string): Promise { const res = await commands.previewLocalFile(path); - if (res.status === 'error') throw new Error(res.error.message); + if (res.status === "error") throw new Error(res.error.message); return res.data; } +/** Create a local directory. */ +export async function localMkdir(path: string): Promise { + const res = await commands.localMkdir(path); + if (res.status === "error") throw new Error(res.error.message); +} + +/** Rename or move a local file/folder. */ +export async function localRename(from: string, to: string): Promise { + const res = await commands.localRename(from, to); + if (res.status === "error") throw new Error(res.error.message); +} + +/** Delete a local file or directory recursively. */ +export async function localDelete(path: string): Promise { + const res = await commands.localDelete(path); + if (res.status === "error") throw new Error(res.error.message); +} + +/** Read a local UTF-8 text file. */ +export async function localReadFile(path: string): Promise { + const res = await commands.localReadFile(path); + if (res.status === "error") throw new Error(res.error.message); + return res.data; +} + +/** Write a local UTF-8 text file. */ +export async function localWriteFile( + path: string, + content: string, +): Promise { + const res = await commands.localWriteFile(path, content); + if (res.status === "error") throw new Error(res.error.message); +} + /** Start auto SSH-key setup for a host; progress + the outcome arrive as `key-setup-*` * events (tech-gui.md §4.2). Fire-and-forget — only an unknown host rejects here. */ export async function startKeySetup(hostName: string): Promise { const res = await commands.startKeySetup(hostName); - if (res.status === 'error') throw new Error(res.error.message); + if (res.status === "error") throw new Error(res.error.message); } /** Start (or restart) a host's tunnel; its progress arrives as `tunnel-status-changed` * (tech-gui.md §4.2). Rejects an unknown host or one without port forwards. */ export async function tunnelStart(hostName: string): Promise { const res = await commands.tunnelStart(hostName); - if (res.status === 'error') throw new Error(res.error.message); + if (res.status === "error") throw new Error(res.error.message); } /** Stop a host's tunnel; a no-op when none runs. */ export async function tunnelStop(hostName: string): Promise { const res = await commands.tunnelStop(hostName); - if (res.status === 'error') throw new Error(res.error.message); + if (res.status === "error") throw new Error(res.error.message); } /** Paste into the focused terminal through the webview's own paste, for the Ctrl+Shift+V * WebKitGTK misses under a non-Latin layout. */ export async function terminalPaste(): Promise { const res = await commands.terminalPaste(); - if (res.status === 'error') throw new Error(res.error.message); + if (res.status === "error") throw new Error(res.error.message); } /** Minimize and close to the tray, or not; resolves to what this desktop allows. */ export async function setTrayBehavior( minimizeToTray: boolean, - closeToTray: boolean + closeToTray: boolean, ): Promise { const res = await commands.setTrayBehavior(minimizeToTray, closeToTray); - if (res.status === 'error') throw new Error(res.error.message); + if (res.status === "error") throw new Error(res.error.message); return res.data; } /** Force an immediate metric poll of every host (tech-gui.md §4.2). */ export async function refreshMetrics(): Promise { const res = await commands.refreshMetrics(); - if (res.status === 'error') throw new Error(res.error.message); + if (res.status === "error") throw new Error(res.error.message); } /** Check GitHub for a newer release; `null` means up to date (tech-gui.md §4.2). */ export async function checkUpdate(): Promise { const res = await commands.checkUpdate(); - if (res.status === 'error') throw new Error(res.error.message); + if (res.status === "error") throw new Error(res.error.message); return res.data; } @@ -231,30 +355,54 @@ export async function checkUpdate(): Promise { * Stage 5 configures the updater endpoints; until then it reports "not available yet". */ export async function installUpdate(): Promise { const res = await commands.installUpdate(); - if (res.status === 'error') throw new Error(res.error.message); + if (res.status === "error") throw new Error(res.error.message); } /** Read the update-checker preferences from the shared config (tech-gui.md §4.3). */ export async function loadUpdateConfig(): Promise { const res = await commands.loadUpdateConfig(); - if (res.status === 'error') throw new Error(res.error.message); + if (res.status === "error") throw new Error(res.error.message); return res.data; } /** Persist the update-checker preferences to the shared config (tech-gui.md §4.3). */ export async function saveUpdateConfig(config: UpdateConfigDto): Promise { const res = await commands.saveUpdateConfig(config); - if (res.status === 'error') throw new Error(res.error.message); + if (res.status === "error") throw new Error(res.error.message); } /** Decrypt an identity file with `passphrase` and cache it for this process. */ -export async function unlockIdentity(keyPath: string, passphrase: string): Promise { +export async function unlockIdentity( + keyPath: string, + passphrase: string, +): Promise { const res = await commands.unlockIdentity(keyPath, passphrase); - if (res.status === 'error') throw new Error(res.error.message); + if (res.status === "error") throw new Error(res.error.message); } /** Answer a login's password prompt; `null` cancels the login. */ -export async function answerPassword(requestId: number, password: string | null): Promise { +export async function answerPassword( + requestId: number, + password: string | null, +): Promise { const res = await commands.answerPassword(requestId, password); - if (res.status === 'error') throw new Error(res.error.message); + if (res.status === "error") throw new Error(res.error.message); +} + +/** Read the general application preferences from the shared config. */ +export async function loadGeneralConfig(): Promise { + const res = await commands.loadGeneralConfig(); + + if (res.status === "error") throw new Error(res.error.message); + + return res.data; +} + +/** Persist the general application preferences. */ +export async function saveGeneralConfig( + config: GeneralConfigDto, +): Promise { + const res = await commands.saveGeneralConfig(config); + + if (res.status === "error") throw new Error(res.error.message); } diff --git a/crates/omnyssh-gui/ui/src/lib/screens/FileEditor.svelte b/crates/omnyssh-gui/ui/src/lib/screens/FileEditor.svelte new file mode 100644 index 00000000..61a2afeb --- /dev/null +++ b/crates/omnyssh-gui/ui/src/lib/screens/FileEditor.svelte @@ -0,0 +1,223 @@ + + +{#if !tab} +
+ Loading... +
+{:else} +
+
+
+ {tab.name} + {#if tab.dirty} + ● Modified + {/if} +
+ + +
+ + {#if tab.error} +
+ {tab.error} +
+ {/if} + + {#if tab.loading} +
+ {#if local} + Reading local file... + {:else} + Reading remote file... + {/if} +
+ {:else} +
+
+ {#each Array(lines) as _, i} +
{i + 1}
+ {/each} +
+ + +
+ {/if} +
+{/if} diff --git a/crates/omnyssh-gui/ui/src/lib/screens/Settings.svelte b/crates/omnyssh-gui/ui/src/lib/screens/Settings.svelte index bc2e198a..6c3064d0 100644 --- a/crates/omnyssh-gui/ui/src/lib/screens/Settings.svelte +++ b/crates/omnyssh-gui/ui/src/lib/screens/Settings.svelte @@ -13,12 +13,26 @@ import { isMac } from '$lib/platform'; import { offerUpdate } from '$lib/stores/update'; import { lastError } from '$lib/stores/notifications'; - import { checkUpdate, loadUpdateConfig, saveUpdateConfig } from '$lib/ipc/commands'; +import { + checkUpdate, + loadUpdateConfig, + saveUpdateConfig, + saveGeneralConfig +} from '$lib/ipc/commands'; + +import { generalConfigStore } from '$lib/stores/generalConfig'; const message = (e: unknown): string => (e instanceof Error ? e.message : String(e)); const formatInterval = (secs: number): string => (secs < 60 ? `${secs}s` : `${secs / 60}m`); let updateConfig = $state(null); + // Raw external editor command used for large remote files. + // Examples: zed --wait, code --wait, nvim, hx + let editorSelection = $state(''); + let largeFileMb = $state(5); + let promptLargeFiles = $state(true); + let autoUploadExternal = $state(false); + type CheckState = | { kind: 'idle' } | { kind: 'checking' } @@ -30,6 +44,11 @@ onMount(async () => { try { updateConfig = await loadUpdateConfig(); + const config = await generalConfigStore.load(); + editorSelection = config.externalEditor ?? ''; + largeFileMb = config.largeFileMb; + promptLargeFiles = config.promptLargeFiles; + autoUploadExternal = config.autoUploadExternal; } catch (e) { lastError.set(message(e)); } @@ -55,6 +74,27 @@ } } +// Save the GUI editor preferences immediately when the user leaves the field. +async function saveGeneral() { + const current = generalConfigStore.value; + if (!current) return; + + const next = { + ...current, + externalEditor: editorSelection.trim(), + largeFileMb, + promptLargeFiles, + autoUploadExternal, + }; + + try { + await saveGeneralConfig(next); + generalConfigStore.set(next); + } catch (e) { + lastError.set(message(e)); + } +} + async function checkNow(): Promise { check = { kind: 'checking' }; try { @@ -177,6 +217,7 @@ () => trayBehavior.update({ minimizeToTray: !$trayBehavior.minimizeToTray }) )} {/if} + {@render traySwitch( isMac ? 'Close to the menu bar' : 'Close to tray', 'Closing the window keeps OmnySSH running — terminals, transfers and tunnels stay connected. Quit from the icon.', @@ -184,6 +225,7 @@ $traySupport.available, () => trayBehavior.update({ closeToTray: !$trayBehavior.closeToTray }) )} + {#if !$traySupport.available}

This desktop has no system tray, so the window closes and minimizes as usual. @@ -198,6 +240,75 @@ + + +

File Editing

+ + {#if generalConfigStore.value} +
+
+ + + e.key === 'Enter' && saveGeneral()} + placeholder="zed --wait" + class="w-full rounded-lg border border-default bg-surface px-3 py-2 font-mono text-sm" + /> + +

+ Command used for editing large remote files. +

+ +

+ Examples: zed --wait · code --wait · nvim · hx · fresh +

+
+ +
+ + +
+ + MB +
+ +
+ +
+
+
+ {/if} + +

Updates

diff --git a/crates/omnyssh-gui/ui/src/lib/screens/SftpPane.svelte b/crates/omnyssh-gui/ui/src/lib/screens/SftpPane.svelte index 0a74a3cd..11ad4b7b 100644 --- a/crates/omnyssh-gui/ui/src/lib/screens/SftpPane.svelte +++ b/crates/omnyssh-gui/ui/src/lib/screens/SftpPane.svelte @@ -14,6 +14,7 @@ onNavigate, onToggleMark, onPreview, + onEdit, toolbar }: { title: string; @@ -21,9 +22,21 @@ onNavigate: (entry: FileEntryDto) => void; onToggleMark: (path: string) => void; onPreview: (entry: FileEntryDto) => void; + onEdit: (entry: FileEntryDto) => void; toolbar?: Snippet; } = $props(); + function isTextFile(name: string): boolean { + const ext = name.split('.').pop()?.toLowerCase() ?? ''; + + return [ + 'txt', 'md', 'rs', 'toml', 'json', 'yaml', 'yml', + 'js', 'ts', 'jsx', 'tsx', 'css', 'scss', 'html', + 'php', 'py', 'sh', 'sql', 'xml', 'ini', 'conf', + 'env', 'log', 'c', 'cpp', 'h', 'hpp' + ].includes(ext); + } + const rowBase = 'flex w-full min-w-0 items-center gap-2 rounded px-2 py-1.5 text-left text-sm transition ' + 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-focus'; @@ -38,10 +51,12 @@ > {title} +
{@render toolbar?.()}
+
{pane.path || '—'}
@@ -50,15 +65,19 @@
{#if pane.error}

{pane.error}

+ {:else if pane.loading && pane.entries.length === 0}

Loading…

+ {:else if pane.entries.length === 0}

Empty directory

+ {:else}
    {#each pane.entries as entry, i (i)} {@const isParent = entry.name === '..'} {@const marked = pane.marked.has(entry.path)} +
  • {#if isParent} @@ -70,26 +89,44 @@ aria-label="Mark {entry.name}" class="grid h-4 w-4 shrink-0 place-items-center rounded border transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-focus - {marked ? 'border-accent bg-accent text-accent-fg' : 'border-strong text-transparent'}" + {marked + ? 'border-accent bg-accent text-accent-fg' + : 'border-strong text-transparent'}" onclick={() => onToggleMark(entry.path)} > - {#if marked}{/if} + {#if marked} + + {/if} {/if} - + +
    + + +
  • {/each}
diff --git a/crates/omnyssh-gui/ui/src/lib/screens/SftpView.svelte b/crates/omnyssh-gui/ui/src/lib/screens/SftpView.svelte index 6954dbed..d806691b 100644 --- a/crates/omnyssh-gui/ui/src/lib/screens/SftpView.svelte +++ b/crates/omnyssh-gui/ui/src/lib/screens/SftpView.svelte @@ -17,18 +17,34 @@ import { lastError } from '$lib/stores/notifications'; import { sftpOpen, - sftpList, - sftpClose, - sftpUpload, - sftpDownload, - sftpMkdir, - sftpRename, - sftpDelete, - sftpPreview, - listLocalDir, - listLocalRoots, - previewLocalFile - } from '$lib/ipc/commands'; + sftpList, + sftpClose, + sftpUpload, + sftpDownload, + sftpMkdir, + sftpRename, + sftpDelete, + sftpPreview, + sftpCancel, + listLocalDir, + localDelete, + localMkdir, + localReadFile, + localRename, + localWriteFile, + listLocalRoots, + previewLocalFile, + localPrepareExternalEdit, + sftpPrepareExternalEdit, + sftpOpenExternalEditor, + sftpRemoveTempFile, + sftpUploadDir, + sftpDownloadDir + } from '$lib/ipc/commands'; + + import FileEditor from './FileEditor.svelte'; + import { fileEditors } from '$lib/stores/fileEditor'; + import { generalConfigStore } from '$lib/stores/generalConfig'; let { session, active }: { session: Session; active: boolean } = $props(); @@ -36,6 +52,40 @@ let openError = $state(undefined); let destroyed = false; let mirrored: string | undefined; + let activeEditorPath = $state(null); + let infoPrompt = $state(null); + let deleteDialog = $state<{ + side: 'local' | 'remote'; + items: FileEntryDto[]; + } | null>(null); + + let activeEditorSide = $state('remote'); + + let mkdirDialog = $state<{ + side: 'local' | 'remote'; + parent: string; + name: string; + } | null>(null); + + let largeFilePrompt = $state<{ + entry: FileEntryDto; + side: PaneSide; + } | null>(null); + + let renameDialog = $state<{ + side: 'local' | 'remote'; + item: FileEntryDto; + name: string; + } | null>(null); + + let editorOpen = $state(false); + let split = $state(100); // Browser starts full width + let draggingSplit = $state(false); + type RightPaneMode = 'preview' | 'editor'; + let rightPaneMode = $state('preview'); + + let externalEdit = $state<{ side: PaneSide; path: string; tempPath: string;} | null>(null); + let uploadPrompt = $state<{ side: PaneSide; path: string; tempPath: string;} | null>(null); // Queued mutations, dispatched one at a time (see the pump effect). The core's SFTP // command channel is bounded and drops on overflow, so a large batch fired at once @@ -67,11 +117,17 @@ } const transfer = $derived(view?.transfer); - const localMarkedFiles = $derived(view ? markedEntries(view.local).filter((e) => !e.isDir) : []); - const remoteMarked = $derived(view ? markedEntries(view.remote) : []); - const remoteMarkedFiles = $derived(remoteMarked.filter((e) => !e.isDir)); + const localMarked = $derived( + view ? markedEntries(view.local) : [] + ); + + const remoteMarked = $derived( + view ? markedEntries(view.remote) : [] + ); const singleRemoteMark = $derived(remoteMarked.length === 1 ? remoteMarked[0] : undefined); + + function errMsg(err: unknown): string { return err instanceof Error ? err.message : String(err); } @@ -117,8 +173,167 @@ void sftpList(id, path).catch((err) => sftp.paneError(id, 'remote', errMsg(err))); } + function requestMkdir(side: 'local' | 'remote') { + if (!view) return; + + mkdirDialog = { + side, + parent: side === 'local' ? view.local.path : view.remote.path, + name: '' + }; + } + + async function confirmMkdir() { + if (!view || !mkdirDialog) return; + + const { side, parent, name } = mkdirDialog; + if (!name.trim()) return; + + const path = + side === 'local' + ? joinLocal(parent, name) + : joinRemote(parent, name); + + if (side === 'local') { + await localMkdir(path); + await refreshLocal(view.local.path); + } else { + sftp.pushOp(session.id, { + kind: 'mkdir', + name, + refresh: 'remote' + }); + + await sftpMkdir(session.id, path); + } + + mkdirDialog = null; + } + + function requestRename(side: 'local' | 'remote') { + if (!view) return; + + const pane = side === 'local' ? view.local : view.remote; + const items = markedEntries(pane); + + if (items.length !== 1) return; + + renameDialog = { + side, + item: items[0], + name: items[0].name + }; + } + + async function confirmRename() { + if (!view || !renameDialog) return; + + const { side, item, name } = renameDialog; + + let parent: string; + let dest: string; + + if (side === 'local') { + const separator = + item.path.includes('\\') && !item.path.includes('/') + ? '\\' + : '/'; + const separatorIndex = item.path.lastIndexOf(separator); + + if (separatorIndex < 0) { + return; + } + + parent = item.path.substring(0, separatorIndex); + dest = joinLocal(parent, name); + } else { + parent = item.path.substring(0, item.path.lastIndexOf('/')); + dest = joinRemote(parent, name); + } + + if (side === 'local') { + await localRename(item.path, dest); + await refreshLocal(view.local.path); + } else { + sftp.pushOp(session.id, { + kind: 'rename', + name, + refresh: 'remote' + }); + + await sftpRename(session.id, item.path, dest); + } + + renameDialog = null; + } + + function requestDelete(side: 'local' | 'remote') { + if (!view) return; + + const pane = side === 'local' ? view.local : view.remote; + const items = markedEntries(pane); + + if (items.length === 0) return; + + deleteDialog = { side, items }; + } + + async function confirmDelete() { + if (!view || !deleteDialog) return; + + const { side, items } = deleteDialog; + + for (const item of items) { + if (side === 'remote') { + sftp.pushOp(session.id, { + kind: 'delete', + name: item.name, + refresh: 'remote' + }); + + await sftpDelete(session.id, item.path); + } else { + await localDelete(item.path); + } + } + + if (side === 'local') { + await refreshLocal(view.local.path); + } + + deleteDialog = null; + } + + function cancelDelete() { + deleteDialog = null; + } + + function startSplitDrag() { + draggingSplit = true; + } + + function stopSplitDrag() { + draggingSplit = false; + } + + function dragSplit(event: PointerEvent) { + if (!draggingSplit) return; + + const root = (event.currentTarget as HTMLElement).getBoundingClientRect(); + const pct = ((event.clientX - root.left) / root.width) * 100; + + split = Math.max(25, Math.min(75, pct)); + } + + + onMount(() => { void (async () => { + try { + await generalConfigStore.load(); + } catch { + // Fall back to defaults if the config cannot be loaded. + } let home = '/'; try { home = await homeDir(); @@ -183,6 +398,51 @@ if (target === 'remote' || target === 'both') refreshRemote(view.remote.path); }); + + // When the active download completes, launch the external editor. + $effect(() => { + if (!view || !externalEdit || !view.transfer) return; + + const transfer = view.transfer; + + if ( + transfer.kind !== 'download' || + transfer.bytesTotal <= 0 || + transfer.bytesDone < transfer.bytesTotal + ) { + return; + } + + const pending = externalEdit; + externalEdit = null; + + void (async () => { + const modified = await sftpOpenExternalEditor( + pending.tempPath, + generalConfigStore.value?.externalEditor ?? "vim", + ); + + if (!modified) { + await sftpRemoveTempFile(pending.tempPath); + infoPrompt = "The file was closed without any modifications."; + return; + } + + const prompt = { + side: pending.side, + path: pending.path, + tempPath: pending.tempPath, + }; + + if (generalConfigStore.value?.autoUploadExternal) { + uploadPrompt = prompt; + await uploadExternalEdit(); + } else { + uploadPrompt = prompt; + } + })(); + }); + function navigate(side: PaneSide, entry: FileEntryDto): void { if (side === 'local') void refreshLocal(entry.path); else refreshRemote(entry.path); @@ -192,21 +452,34 @@ if (backendId != null) sftp.toggleMark(backendId, side, path); } - async function preview(side: PaneSide, entry: FileEntryDto): Promise { - const id = backendId; - if (id == null) return; - if (side === 'local') { - try { - const content = await previewLocalFile(entry.path); - sftp.setPreview(id, { path: entry.path, content }); - } catch (err) { - lastError.set(errMsg(err)); - } - } else { - void sftpPreview(id, entry.path).catch((err) => lastError.set(errMsg(err))); - } +async function preview(side: PaneSide, entry: FileEntryDto): Promise { + if (entry.isDir) { + editorOpen = false; + return; } + editorOpen = true; + rightPaneMode = 'preview'; + split = 55; + + const id = backendId; + if (id == null) return; + + if (side === 'local') { + try { + const content = await previewLocalFile(entry.path); + sftp.setPreview(id, { path: entry.path, content }); + } catch (err) { + lastError.set(errMsg(err)); + } + } else { + try { + await sftpPreview(id, entry.path); + } catch (err) { + lastError.set(errMsg(err)); + } + } +} // If a mutating invoke itself rejects (it never does for a normal enqueue, but an IPC // failure could), pop its pending op so the dispatch pump does not wedge. function onDispatchError(id: number): (err: unknown) => void { @@ -226,40 +499,69 @@ outbox = [...outbox, ...actions]; } - function upload(): void { - const id = backendId; - if (id == null || !view) return; - const dir = view.remote.path; - enqueue( - ...localMarkedFiles.map((file) => () => { - sftp.pushOp(id, { kind: 'upload', name: file.name, refresh: 'remote' }); - void sftpUpload(id, file.path, joinRemote(dir, file.name)).catch(onDispatchError(id)); - }) - ); - } +function upload(): void { + const id = backendId; + if (id == null || !view) return; + + const dir = view.remote.path; + + enqueue( + ...markedEntries(view.local).map((entry) => () => { + sftp.pushOp(id, { + kind: 'upload', + name: entry.name, + refresh: 'remote' + }); + + if (entry.isDir) { + void sftpUploadDir( + id, + entry.path, + joinRemote(dir, entry.name) + ).catch(onDispatchError(id)); + } else { + void sftpUpload( + id, + entry.path, + joinRemote(dir, entry.name) + ).catch(onDispatchError(id)); + } + }) + ); +} + +function download(): void { + const id = backendId; + if (id == null || !view) return; + + const dir = view.local.path; + + enqueue( + ...markedEntries(view.remote).map((entry) => () => { + sftp.pushOp(id, { + kind: 'download', + name: entry.name, + refresh: 'local' + }); + + if (entry.isDir) { + void sftpDownloadDir( + id, + entry.path, + joinLocal(dir, entry.name) + ).catch(onDispatchError(id)); + } else { + void sftpDownload( + id, + joinLocal(dir, entry.name), + entry.path + ).catch(onDispatchError(id)); + } + }) + ); +} - function download(): void { - const id = backendId; - if (id == null || !view) return; - const dir = view.local.path; - enqueue( - ...remoteMarkedFiles.map((file) => () => { - sftp.pushOp(id, { kind: 'download', name: file.name, refresh: 'local' }); - void sftpDownload(id, joinLocal(dir, file.name), file.path).catch(onDispatchError(id)); - }) - ); - } - function remove(): void { - const id = backendId; - if (id == null) return; - enqueue( - ...remoteMarked.map((entry) => () => { - sftp.pushOp(id, { kind: 'delete', name: entry.name, refresh: 'remote' }); - void sftpDelete(id, entry.path).catch(onDispatchError(id)); - }) - ); - } function openPrompt(kind: 'mkdir' | 'rename'): void { if (kind === 'rename' && singleRemoteMark) { @@ -294,8 +596,134 @@ if (backendId != null) sftp.clearPreview(backendId); } - function transferPercent(done: number, total: number): number { - return total > 0 ? Math.min(100, Math.round((done / total) * 100)) : 0; + function shouldUseExternal(entry: FileEntryDto): boolean { + const limitMb = generalConfigStore.value?.largeFileMb ?? 5; + return entry.size >= limitMb * 1024 * 1024; +} + +async function editEntry(entry: FileEntryDto, side: PaneSide) { + if (entry.isDir) return; + + // Large REMOTE files → external editor prompt + if (shouldUseExternal(entry)) { + largeFilePrompt = { entry, side }; + return; + } + + activeEditorPath = entry.path; + activeEditorSide = side; + + if (side === 'local') { + fileEditors.openLocal(entry.path); + } else { + if (backendId == null) return; + fileEditors.open(backendId, entry.path); + } + + rightPaneMode = 'editor'; + editorOpen = true; + split = 55; +} + +function openBuiltinEditor(): void { + // Large files are external only. +} + +async function openExternalEditor(): Promise { + if (!largeFilePrompt) return; + + const { entry, side } = largeFilePrompt; + + if (side === "remote" && backendId == null) return; + + const tempPath = + side === "local" + ? await localPrepareExternalEdit(entry.path) + : await sftpPrepareExternalEdit(entry.path); + + externalEdit = { + side, + path: entry.path, + tempPath, + }; + + largeFilePrompt = null; + + // Remote: download first, editor opens after download completes + if (side === "remote") { + const id = backendId; + + if (id == null) return; + + sftp.pushOp(id, { + kind: "download", + name: entry.path.split("/").pop() ?? entry.path, + refresh: "local", + }); + + await sftpDownload(id, tempPath, entry.path); + return; + } + + // Local: open immediately + const editor = generalConfigStore.value?.externalEditor ?? "vim"; + const modified = await sftpOpenExternalEditor(tempPath, editor); + + if (!modified) { + await sftpRemoveTempFile(tempPath); + return; + } + + const prompt = { + side, + path: entry.path, + tempPath, + }; + + if (generalConfigStore.value?.autoUploadExternal) { + uploadPrompt = prompt; + await uploadExternalEdit(); + } else { + uploadPrompt = prompt; + } +} + +async function uploadExternalEdit(): Promise { + if (!uploadPrompt) return; + + const { side, path, tempPath } = uploadPrompt; + + uploadPrompt = null; + + // Local file: write back directly + if (side === 'local') { + const text = await localReadFile(tempPath); + await localWriteFile(path, text); + await sftpRemoveTempFile(tempPath); + externalEdit = null; + return; + } + + // Remote file requires an active session + if (backendId == null) return; + + sftp.pushOp(backendId, { + kind: 'upload', + name: path.split('/').pop() ?? path, + refresh: 'remote', + }); + + await sftpUpload(backendId, tempPath, path); + await sftpRemoveTempFile(tempPath); + externalEdit = null; +} + + + + function transferPercent(bytesDone: number, bytesTotal: number): number { + return bytesTotal > 0 + ? Math.min(100, Math.round((bytesDone / bytesTotal) * 100)) + : 0; } const toolBtn = @@ -321,141 +749,384 @@

Could not open SFTP on {session.hostName}

{openError}

+ {:else if !view}

Connecting to {session.hostName}…

+ {:else} -
- navigate('local', e)} - onToggleMark={(p) => toggleMark('local', p)} - onPreview={(e) => preview('local', e)} + +
+ +
+ +
- {#snippet toolbar()} - {#if roots.length > 1} - switchDrive(e.currentTarget.value)} + > + {#if !drive} + + {/if} + + {#each roots as root (root)} + + {/each} + + {/if} + + + + + + + + + + + + + {/snippet} + + + + navigate('remote', e)} + onToggleMark={(p) => toggleMark('remote', p)} + onPreview={(e) => preview('remote', e)} + onEdit={(entry) => editEntry(entry, 'remote')} + > + {#snippet toolbar()} + + + + + + + + + + + + {/snippet} + +
+ + {#if editorOpen} + + + + +
+ +
+
+
+ {#if rightPaneMode === 'preview'} + {view.preview?.path?.split('/').pop() ?? 'Preview'} + {:else} + {activeEditorPath?.split('/').pop() ?? 'Editor'} + {/if} +
+ +
+ {#if rightPaneMode === 'preview'} + {view.preview?.path ?? 'No file selected'} + {:else} + {activeEditorPath ?? 'No file selected'} + {/if} +
+
+ +
+ +
+
+ + +
+ {#if rightPaneMode === 'preview'} + {#if view.preview} +
+
+ {#if view.preview.content.length === 0} +
+

Empty file.

+
+ {:else} +
{view.preview.content}
+ {/if} +
+
+ {:else} +
+

Select a file to preview.

+
{/if} - {#each roots as root (root)} - - {/each} - - {/if} - - - {/snippet} - - - navigate('remote', e)} - onToggleMark={(p) => toggleMark('remote', p)} - onPreview={(e) => preview('remote', e)} - > - {#snippet toolbar()} - - - - - - {/snippet} - + {:else} + {#if activeEditorPath} +
+ {#if activeEditorSide === 'local'} + + {:else if backendId != null} + + {/if} +
+ {:else} +
+

Select a file to edit.

+
+ {/if} + {/if} +
+
+ {/if}
- {#if transfer} -
-
- - {transfer.kind === 'upload' ? 'Uploading' : 'Downloading'} - {transfer.name} - - - {formatBytes(transfer.done)}{transfer.total > 0 - ? ` / ${formatBytes(transfer.total)}` - : ''} - + + + + {#if transfer} +
+
+
+ {#if transfer.stage === 'preparing'} +
+ Preparing {transfer.kind}… +
+ +
+ {transfer.rootName} +
+ +
+ {transfer.currentFile} +
+ {:else} +
+ {transfer.kind === 'upload' ? 'Uploading' : 'Downloading'} + {transfer.rootName} +
+ +
+ {transfer.currentFile} +
+ {/if} +
+ +
+ {#if transfer.stage === 'preparing'} +
Scanning…
+ {:else} +
+ {transfer.filesDone} / {transfer.filesTotal} files +
+ +
+ {formatBytes(transfer.bytesDone)} / {formatBytes(transfer.bytesTotal)} +
+ + + {/if} +
+
+ +
+
+
-
-
+ {:else if view.error} +
+ {view.error}
-
- {:else if view.error} -
- {view.error} -
- {/if} + {/if} +
{/if}
@@ -502,29 +1173,235 @@ {/if} -{#if active && view?.preview} - + +{#if active && largeFilePrompt} + (largeFilePrompt = null)} + > +
+

Open large file?

+
+ +
+

{largeFilePrompt.entry.name}

+

Size: {formatBytes(largeFilePrompt.entry.size)}

+

+ This exceeds your configured threshold of {generalConfigStore.value?.largeFileMb ?? 5} MB. +

+

+ {#if largeFilePrompt.side === 'remote'} + This file will be downloaded to a temporary location and opened in your configured external editor. + {:else} + This file will be copied to a temporary location and opened in your configured external editor. + {/if} +

+
+ +
+ + +
+
+{/if} + +{#if active && uploadPrompt} + (uploadPrompt = null)}>
-

- {view.preview.path} +

+ {#if uploadPrompt.side === 'local'} + Save modified file? + {:else} + Upload modified file? + {/if}

-
- {#if view.preview.content.length === 0} -

Empty file.

+ +
+

{uploadPrompt.path.split('/').pop()}

+ + {#if uploadPrompt.side === 'local'} +

+ The file was modified in your external editor and will be saved back to its original location. +

{:else} -
{view.preview
-            .content}
+

+ The file was modified in your external editor and will be uploaded back to the server. +

{/if}
+ +
+ + + +
+ +{/if} + +{#if active && infoPrompt} + (infoPrompt = null)} + > +
+

{infoPrompt}

+
+
{/if} + +{#if deleteDialog} +
+
+

+ Confirm deletion +

+ + {#if deleteDialog.items.length === 1} +

+ {#if deleteDialog.items[0].isDir} + Delete folder + {:else} + Delete file + {/if} +

+ +

+ {deleteDialog.items[0].name} +

+ + {#if deleteDialog.items[0].isDir} +

+ This will permanently remove the folder and all of its contents. +

+ {/if} + {:else} +

+ Permanently delete these items? +

+ +

+ {deleteDialog.items.length} selected items +

+ {/if} + + + + +
+
+{/if} + +{#if mkdirDialog} +
+
+

New Folder

+ +
+ + +
+ +
+ + + +
+
+
+{/if} + +{#if renameDialog} +
+
+

Rename

+ +
+ + +
+ +
+ + + +
+
+
+{/if} diff --git a/crates/omnyssh-gui/ui/src/lib/screens/TerminalView.svelte b/crates/omnyssh-gui/ui/src/lib/screens/TerminalView.svelte index 975f9b17..81bcf62e 100644 --- a/crates/omnyssh-gui/ui/src/lib/screens/TerminalView.svelte +++ b/crates/omnyssh-gui/ui/src/lib/screens/TerminalView.svelte @@ -257,23 +257,32 @@ }); - -
- -
-
+ +
+ +
+
diff --git a/crates/omnyssh-gui/ui/src/lib/stores/fileEditor.ts b/crates/omnyssh-gui/ui/src/lib/stores/fileEditor.ts new file mode 100644 index 00000000..fcb51b5d --- /dev/null +++ b/crates/omnyssh-gui/ui/src/lib/stores/fileEditor.ts @@ -0,0 +1,139 @@ +import { writable } from "svelte/store"; +import { commands } from "$lib/bindings"; + +export type EditorSource = + { kind: "remote"; sessionId: number } | { kind: "local" }; + +export interface EditorTab { + source: EditorSource; + path: string; + name: string; + content: string; + original: string; + loading: boolean; + saving: boolean; + dirty: boolean; + error?: string; +} + +const sameTab = (tab: EditorTab, source: EditorSource, path: string) => { + if (tab.path !== path) return false; + + if (source.kind === "local") { + return tab.source.kind === "local"; + } + + return ( + tab.source.kind === "remote" && tab.source.sessionId === source.sessionId + ); +}; + +function createFileEditors() { + const { subscribe, update } = writable([]); + + const openInternal = (source: EditorSource, path: string) => { + update((tabs) => { + if (tabs.some((t) => sameTab(t, source, path))) return tabs; + + return [ + ...tabs, + { + source, + path, + name: path.split(/[\\/]/).pop() ?? path, + content: "", + original: "", + loading: true, + saving: false, + dirty: false, + }, + ]; + }); + }; + + const mutate = ( + source: EditorSource, + path: string, + fn: (tab: EditorTab) => EditorTab, + ) => { + update((tabs) => tabs.map((t) => (sameTab(t, source, path) ? fn(t) : t))); + }; + + return { + subscribe, + + async open(sessionId: number, path: string) { + const source: EditorSource = { kind: "remote", sessionId }; + + openInternal(source, path); + + const r = await commands.sftpReadFile(sessionId, path); + + if (r.status === "error") { + this.error(source, path, r.error.message); + } + }, + + async openLocal(path: string) { + const source: EditorSource = { kind: "local" }; + + openInternal(source, path); + + const r = await commands.localReadFile(path); + + if (r.status === "error") { + this.error(source, path, r.error.message); + return; + } + + this.setContent(source, path, r.data); + }, + + close(source: EditorSource, path: string) { + update((tabs) => tabs.filter((t) => !sameTab(t, source, path))); + }, + + setContent(source: EditorSource, path: string, content: string) { + mutate(source, path, (t) => ({ + ...t, + content, + original: content, + loading: false, + dirty: false, + error: undefined, + })); + }, + + edit(source: EditorSource, path: string, content: string) { + mutate(source, path, (t) => ({ + ...t, + content, + dirty: content !== t.original, + })); + }, + + saving(source: EditorSource, path: string, saving: boolean) { + mutate(source, path, (t) => ({ ...t, saving })); + }, + + saved(source: EditorSource, path: string) { + mutate(source, path, (t) => ({ + ...t, + saving: false, + original: t.content, + dirty: false, + })); + }, + + error(source: EditorSource, path: string, error: string) { + mutate(source, path, (t) => ({ + ...t, + loading: false, + saving: false, + error, + })); + }, + }; +} + +export const fileEditors = createFileEditors(); diff --git a/crates/omnyssh-gui/ui/src/lib/stores/generalConfig.ts b/crates/omnyssh-gui/ui/src/lib/stores/generalConfig.ts new file mode 100644 index 00000000..f53c9289 --- /dev/null +++ b/crates/omnyssh-gui/ui/src/lib/stores/generalConfig.ts @@ -0,0 +1,41 @@ +import { get, writable } from "svelte/store"; +import type { GeneralConfigDto } from "$lib/bindings"; +import { loadGeneralConfig } from "$lib/ipc/commands"; + +const config = writable(null); + +let loading: Promise | null = null; + +export const generalConfigStore = { + subscribe: config.subscribe, + + get value(): GeneralConfigDto | null { + return get(config); + }, + + async load(): Promise { + const current = get(config); + + if (current) { + return current; + } + + if (loading) { + return loading; + } + + loading = loadGeneralConfig(); + + try { + const next = await loading; + config.set(next); + return next; + } finally { + loading = null; + } + }, + + set(next: GeneralConfigDto): void { + config.set(next); + }, +}; diff --git a/crates/omnyssh-gui/ui/src/lib/stores/sftp.test.ts b/crates/omnyssh-gui/ui/src/lib/stores/sftp.test.ts index 5e8f3111..246805bf 100644 --- a/crates/omnyssh-gui/ui/src/lib/stores/sftp.test.ts +++ b/crates/omnyssh-gui/ui/src/lib/stores/sftp.test.ts @@ -1,6 +1,6 @@ -import { describe, expect, it } from 'vitest'; -import { get } from 'svelte/store'; -import type { FileEntryDto } from '$lib/bindings'; +import { describe, expect, it } from "vitest"; +import { get } from "svelte/store"; +import type { FileEntryDto } from "$lib/bindings"; import { sftp, newSession, @@ -13,209 +13,288 @@ import { formatBytes, rootOf, type Pane, - type SftpSession -} from './sftp'; - -// The dual-pane browser's navigation/marking/transfer logic lives as pure reducers so -// it is unit-testable without a Tauri runtime (tech-gui.md §3.2, §6.4). + type SftpSession, +} from "./sftp"; function entry(name: string, isDir = false, size = 0): FileEntryDto { return { name, path: `/srv/${name}`, size, isDir }; } function paneWith(entries: FileEntryDto[], marked: string[] = []): Pane { - return { path: '/srv', entries, loading: false, marked: new Set(marked) }; + return { path: "/srv", entries, loading: false, marked: new Set(marked) }; } -describe('sftp reducers', () => { - it('starts a session connecting with both panes empty and loading', () => { - const s = newSession('web-1'); - expect(s.status).toBe('connecting'); +describe("sftp reducers", () => { + it("starts a session connecting with both panes empty and loading", () => { + const s = newSession("web-1"); + expect(s.status).toBe("connecting"); expect(s.local.loading).toBe(true); expect(s.remote.loading).toBe(true); expect(s.local.entries).toEqual([]); expect(s.pending).toEqual([]); }); - it('applyListing replaces entries at a path and clears marks + loading', () => { - const pane = paneWith([entry('a')], ['/srv/a']); - const next = applyListing(pane, '/etc', [entry('b'), entry('c')]); - expect(next.path).toBe('/etc'); - expect(next.entries.map((e) => e.name)).toEqual(['b', 'c']); + it("applyListing replaces entries at a path and clears marks + loading", () => { + const pane = paneWith([entry("a")], ["/srv/a"]); + const next = applyListing(pane, "/etc", [entry("b"), entry("c")]); + expect(next.path).toBe("/etc"); + expect(next.entries.map((e) => e.name)).toEqual(["b", "c"]); expect(next.loading).toBe(false); - // Navigating clears the previous directory's marks. expect(next.marked.size).toBe(0); }); - it('toggleMark marks and unmarks, and markedEntries keeps listing order', () => { - let pane = paneWith([entry('a'), entry('b'), entry('c')]); - pane = toggleMark(pane, '/srv/c'); - pane = toggleMark(pane, '/srv/a'); - expect(markedEntries(pane).map((e) => e.name)).toEqual(['a', 'c']); - // Toggling an already-marked path removes it. - pane = toggleMark(pane, '/srv/a'); - expect(markedEntries(pane).map((e) => e.name)).toEqual(['c']); + it("toggleMark marks and unmarks, and markedEntries keeps listing order", () => { + let pane = paneWith([entry("a"), entry("b"), entry("c")]); + pane = toggleMark(pane, "/srv/c"); + pane = toggleMark(pane, "/srv/a"); + + expect(markedEntries(pane).map((e) => e.name)).toEqual(["a", "c"]); + + pane = toggleMark(pane, "/srv/a"); + expect(markedEntries(pane).map((e) => e.name)).toEqual(["c"]); }); - it('mergeRefresh widens two different sides to both', () => { - expect(mergeRefresh(undefined, 'remote')).toBe('remote'); - expect(mergeRefresh('remote', 'remote')).toBe('remote'); - expect(mergeRefresh('local', 'remote')).toBe('both'); - expect(mergeRefresh('both', 'local')).toBe('both'); + it("mergeRefresh widens two different sides to both", () => { + expect(mergeRefresh(undefined, "remote")).toBe("remote"); + expect(mergeRefresh("remote", "remote")).toBe("remote"); + expect(mergeRefresh("local", "remote")).toBe("both"); + expect(mergeRefresh("both", "local")).toBe("both"); }); - it('applyProgress binds a tick to the front pending transfer op', () => { + it("applyProgress binds a tick to the front pending transfer op", () => { const s: SftpSession = { - ...newSession('web-1'), - pending: [{ kind: 'upload', name: 'a.txt', refresh: 'remote' }] + ...newSession("web-1"), + pending: [{ kind: "upload", name: "a.txt", refresh: "remote" }], }; - const next = applyProgress(s, { sessionId: 1, transferId: 9, done: 50, total: 100 }); - expect(next.transfer).toEqual({ kind: 'upload', name: 'a.txt', done: 50, total: 100 }); + + const next = applyProgress(s, { + sessionId: 1, + transferId: 9, + rootName: "folder", + currentFile: "a.txt", + stage: "transferring", + bytesDone: 50, + bytesTotal: 100, + filesDone: 1, + filesTotal: 1, + }); + + expect(next.transfer).toEqual({ + transferId: 9, + kind: "upload", + rootName: "folder", + currentFile: "a.txt", + stage: "transferring", + bytesDone: 50, + bytesTotal: 100, + filesDone: 1, + filesTotal: 1, + }); }); - it('applyProgress ignores a tick when the front op is not a transfer', () => { + it("applyProgress ignores a tick when the front op is not a transfer", () => { const s: SftpSession = { - ...newSession('web-1'), - pending: [{ kind: 'mkdir', refresh: 'remote' }] + ...newSession("web-1"), + pending: [{ kind: "mkdir", refresh: "remote" }], }; - expect(applyProgress(s, { sessionId: 1, transferId: 9, done: 1, total: 2 }).transfer).toBeUndefined(); + + expect( + applyProgress(s, { + sessionId: 1, + transferId: 9, + rootName: "", + currentFile: "", + stage: "transferring", + bytesDone: 1, + bytesTotal: 2, + filesDone: 1, + filesTotal: 1, + }).transfer, + ).toBeUndefined(); }); - it('applyOpDone pops the front op (FIFO), records its refresh, and clears a transfer', () => { + it("applyOpDone pops the front op (FIFO), records its refresh, and clears a transfer", () => { const s: SftpSession = { - ...newSession('web-1'), + ...newSession("web-1"), pending: [ - { kind: 'upload', name: 'a', refresh: 'remote' }, - { kind: 'mkdir', refresh: 'remote' } + { kind: "upload", name: "a", refresh: "remote" }, + { kind: "mkdir", refresh: "remote" }, ], - transfer: { kind: 'upload', name: 'a', done: 100, total: 100 } + transfer: { + transferId: 9, + kind: "upload", + rootName: "folder", + currentFile: "a", + stage: "transferring", + bytesDone: 100, + bytesTotal: 100, + filesDone: 1, + filesTotal: 1, + }, }; + const next = applyOpDone(s, true); - expect(next.pending.map((p) => p.kind)).toEqual(['mkdir']); - expect(next.refresh).toBe('remote'); - // The finished op was the transfer, so its bar is cleared. + + expect(next.pending.map((p) => p.kind)).toEqual(["mkdir"]); + expect(next.refresh).toBe("remote"); expect(next.transfer).toBeUndefined(); expect(next.error).toBeUndefined(); }); - it('applyOpDone surfaces the error message on failure', () => { + it("applyOpDone surfaces the error message on failure", () => { const s: SftpSession = { - ...newSession('web-1'), - pending: [{ kind: 'delete', name: 'x', refresh: 'remote' }] + ...newSession("web-1"), + pending: [{ kind: "delete", name: "x", refresh: "remote" }], }; - const next = applyOpDone(s, false, 'permission denied'); - expect(next.error).toBe('permission denied'); + + const next = applyOpDone(s, false, "permission denied"); + + expect(next.error).toBe("permission denied"); expect(next.pending).toEqual([]); }); - it('applyOpDone keeps a prior op error on a later success (no mid-batch masking)', () => { - // A batch of [delete non-empty folder (fails), delete sibling (ok)] must not let the - // sibling's success hide the folder's failure — the error persists. + it("applyOpDone keeps a prior op error on a later success", () => { let s: SftpSession = { - ...newSession('web-1'), + ...newSession("web-1"), pending: [ - { kind: 'delete', name: 'logs', refresh: 'remote' }, - { kind: 'delete', name: 'notes.txt', refresh: 'remote' } - ] + { kind: "delete", name: "logs", refresh: "remote" }, + { kind: "delete", name: "notes.txt", refresh: "remote" }, + ], }; - s = applyOpDone(s, false, 'directory not empty'); - expect(s.error).toBe('directory not empty'); + + s = applyOpDone(s, false, "directory not empty"); + expect(s.error).toBe("directory not empty"); + s = applyOpDone(s, true); - expect(s.error).toBe('directory not empty'); + expect(s.error).toBe("directory not empty"); expect(s.pending).toEqual([]); }); - it('correlates a two-file batch by FIFO order across progress + op-done', () => { - // The core is sequential, so the front pending op is always the one running: A's - // progress shows A; A's op-done pops it; then B's progress shows B (§3.2/§4.3). + it("correlates a two-file batch by FIFO order", () => { let s: SftpSession = { - ...newSession('web-1'), + ...newSession("web-1"), pending: [ - { kind: 'upload', name: 'A', refresh: 'remote' }, - { kind: 'upload', name: 'B', refresh: 'remote' } - ] + { kind: "upload", name: "A", refresh: "remote" }, + { kind: "upload", name: "B", refresh: "remote" }, + ], }; - s = applyProgress(s, { sessionId: 1, transferId: 1, done: 5, total: 10 }); - expect(s.transfer?.name).toBe('A'); + + s = applyProgress(s, { + sessionId: 1, + transferId: 1, + rootName: "folder", + currentFile: "A", + stage: "transferring", + bytesDone: 5, + bytesTotal: 10, + filesDone: 1, + filesTotal: 2, + }); + + expect(s.transfer?.currentFile).toBe("A"); + s = applyOpDone(s, true); expect(s.transfer).toBeUndefined(); - s = applyProgress(s, { sessionId: 1, transferId: 2, done: 3, total: 3 }); - expect(s.transfer?.name).toBe('B'); + + s = applyProgress(s, { + sessionId: 1, + transferId: 2, + rootName: "folder", + currentFile: "B", + stage: "transferring", + bytesDone: 10, + bytesTotal: 10, + filesDone: 2, + filesTotal: 2, + }); + + expect(s.transfer?.currentFile).toBe("B"); + s = applyOpDone(s, true); + expect(s.pending).toEqual([]); - expect(s.refresh).toBe('remote'); + expect(s.refresh).toBe("remote"); }); - it('formatBytes is human readable', () => { - expect(formatBytes(512)).toBe('512 B'); - expect(formatBytes(2048)).toBe('2.0 KB'); - expect(formatBytes(5 * 1024 * 1024)).toBe('5.0 MB'); + it("formatBytes is human readable", () => { + expect(formatBytes(512)).toBe("512 B"); + expect(formatBytes(2048)).toBe("2.0 KB"); + expect(formatBytes(5 * 1024 * 1024)).toBe("5.0 MB"); }); }); -describe('rootOf', () => { - const drives = ['C:\\', 'D:\\']; +describe("rootOf", () => { + const drives = ["C:\\", "D:\\"]; - it('finds the drive a local path is on, whatever the letter case', () => { - expect(rootOf('C:\\Users\\me', drives)).toBe('C:\\'); - expect(rootOf('d:\\Media', drives)).toBe('D:\\'); - expect(rootOf('D:\\', drives)).toBe('D:\\'); + it("finds the drive a local path is on, whatever the letter case", () => { + expect(rootOf("C:\\Users\\me", drives)).toBe("C:\\"); + expect(rootOf("d:\\Media", drives)).toBe("D:\\"); + expect(rootOf("D:\\", drives)).toBe("D:\\"); }); - it('has no root for a network share or an unknown drive', () => { - expect(rootOf('\\\\nas\\share', drives)).toBe(''); - expect(rootOf('E:\\x', drives)).toBe(''); + it("has no root for a network share or an unknown drive", () => { + expect(rootOf("\\\\nas\\share", drives)).toBe(""); + expect(rootOf("E:\\x", drives)).toBe(""); }); - it('puts every path under / on a single-root system', () => { - expect(rootOf('/home/me', ['/'])).toBe('/'); + it("puts every path under / on a single-root system", () => { + expect(rootOf("/home/me", ["/"])).toBe("/"); }); }); -describe('sftp store', () => { - it('keeps concurrent sessions isolated and prunes on close', () => { - sftp.open(1, 'web-1'); - sftp.open(2, 'db-1'); - sftp.listing(1, 'remote', '/a', [entry('one')]); - sftp.listing(2, 'remote', '/b', [entry('two'), entry('three')]); +describe("sftp store", () => { + it("keeps concurrent sessions isolated and prunes on close", () => { + sftp.open(1, "web-1"); + sftp.open(2, "db-1"); + + sftp.listing(1, "remote", "/a", [entry("one")]); + sftp.listing(2, "remote", "/b", [entry("two"), entry("three")]); - expect(get(sftp).get(1)?.remote.path).toBe('/a'); + expect(get(sftp).get(1)?.remote.path).toBe("/a"); expect(get(sftp).get(1)?.remote.entries).toHaveLength(1); - // A listing for tab 1 never leaks into tab 2 — the store is keyed by session id. - expect(get(sftp).get(2)?.remote.path).toBe('/b'); + + expect(get(sftp).get(2)?.remote.path).toBe("/b"); expect(get(sftp).get(2)?.remote.entries).toHaveLength(2); sftp.remove(1); expect(get(sftp).has(1)).toBe(false); expect(get(sftp).has(2)).toBe(true); + sftp.remove(2); }); - it('ignores mutations targeting an unknown (closed) session', () => { - sftp.listing(999, 'remote', '/gone', [entry('x')]); + it("ignores mutations targeting an unknown session", () => { + sftp.listing(999, "remote", "/gone", [entry("x")]); expect(get(sftp).has(999)).toBe(false); }); - it('clearError drops a lingering batch error when a new batch is enqueued', () => { - sftp.open(1, 'web-1'); - sftp.pushOp(1, { kind: 'delete', name: 'logs', refresh: 'remote' }); - sftp.opDone(1, false, 'directory not empty'); - expect(get(sftp).get(1)?.error).toBe('directory not empty'); + it("clearError drops a lingering batch error", () => { + sftp.open(1, "web-1"); + sftp.pushOp(1, { kind: "delete", name: "logs", refresh: "remote" }); + + sftp.opDone(1, false, "directory not empty"); + expect(get(sftp).get(1)?.error).toBe("directory not empty"); + sftp.clearError(1); expect(get(sftp).get(1)?.error).toBeUndefined(); + sftp.remove(1); }); - it('sessionError clears the remote pane loading so a failed listing never sticks, but leaves a live local load', () => { - sftp.open(1, 'web-1'); - sftp.beginLoading(1, 'remote'); - sftp.beginLoading(1, 'local'); - sftp.sessionError(1, 'ListDir failed: connection reset'); + it("sessionError clears only the remote loading state", () => { + sftp.open(1, "web-1"); + + sftp.beginLoading(1, "remote"); + sftp.beginLoading(1, "local"); + + sftp.sessionError(1, "ListDir failed"); + const s = get(sftp).get(1); - expect(s?.error).toBe('ListDir failed: connection reset'); + + expect(s?.error).toBe("ListDir failed"); expect(s?.remote.loading).toBe(false); - // A remote failure must not drop a legitimately in-flight local listing's spinner. expect(s?.local.loading).toBe(true); + sftp.remove(1); }); }); diff --git a/crates/omnyssh-gui/ui/src/lib/stores/sftp.ts b/crates/omnyssh-gui/ui/src/lib/stores/sftp.ts index a03fee6d..99afa757 100644 --- a/crates/omnyssh-gui/ui/src/lib/stores/sftp.ts +++ b/crates/omnyssh-gui/ui/src/lib/stores/sftp.ts @@ -1,14 +1,14 @@ -import { writable } from 'svelte/store'; -import type { FileEntryDto, TransferProgressDto } from '$lib/bindings'; +import { writable } from "svelte/store"; +import type { FileEntryDto, TransferProgressDto } from "$lib/bindings"; // Per-session SFTP state for the dual-pane browser (tech-gui.md §3.2, §3.5), keyed by // the backend public session id — the same id the `sftp-*` events carry, so the router // routes each event to the right tab. Navigation/marking/transfer logic lives here as // pure reducers so it is unit-testable; `SftpView.svelte` is a thin view + dispatcher. -export type PaneSide = 'local' | 'remote'; -type SftpStatus = 'connecting' | 'connected' | 'failed'; -type OpKind = 'upload' | 'download' | 'mkdir' | 'rename' | 'delete'; +export type PaneSide = "local" | "remote"; +type SftpStatus = "connecting" | "connected" | "failed"; +type OpKind = "upload" | "download" | "mkdir" | "rename" | "delete"; /** One side's browsing state: current directory, its entries, and the marked set. */ export interface Pane { @@ -22,10 +22,15 @@ export interface Pane { /** The transfer currently reporting progress (one at a time — the core is sequential). */ interface Transfer { - kind: 'upload' | 'download'; - name: string; - done: number; - total: number; + transferId: number; + stage: "preparing" | "transferring"; + kind: "upload" | "download"; + rootName: string; + currentFile: string; + bytesDone: number; + bytesTotal: number; + filesDone: number; + filesTotal: number; } /** A file preview (a remote `file-preview` event, or a local read) shown in a modal. */ @@ -56,27 +61,38 @@ export interface SftpSession { error?: string; /** Pane(s) to re-list once `pending` drains (a mutation changed the FS); the * component performs the listing and clears this. */ - refresh?: PaneSide | 'both'; + refresh?: PaneSide | "both"; } function emptyPane(): Pane { - return { path: '', entries: [], loading: true, marked: new Set() }; + return { path: "", entries: [], loading: true, marked: new Set() }; } /** A fresh session in the connecting state, both panes empty. */ export function newSession(hostName: string): SftpSession { return { hostName, - status: 'connecting', + status: "connecting", local: emptyPane(), remote: emptyPane(), - pending: [] + pending: [], }; } /** A directory listing landed for a pane: replace entries at `path`, clear marks. */ -export function applyListing(pane: Pane, path: string, entries: FileEntryDto[]): Pane { - return { ...pane, path, entries, loading: false, marked: new Set(), error: undefined }; +export function applyListing( + pane: Pane, + path: string, + entries: FileEntryDto[], +): Pane { + return { + ...pane, + path, + entries, + loading: false, + marked: new Set(), + error: undefined, + }; } /** Toggle an entry's marked state (the batch transfer/delete set). */ @@ -96,13 +112,13 @@ export function markedEntries(pane: Pane): FileEntryDto[] { * Drive letters compare case-insensitively, as Windows does. */ export function rootOf(path: string, roots: string[]): string { const lower = path.toLowerCase(); - return roots.find((root) => lower.startsWith(root.toLowerCase())) ?? ''; + return roots.find((root) => lower.startsWith(root.toLowerCase())) ?? ""; } /** Human-readable byte size for a listing row or a transfer bar. */ export function formatBytes(bytes: number): string { if (bytes < 1024) return `${bytes} B`; - const units = ['KB', 'MB', 'GB', 'TB']; + const units = ["KB", "MB", "GB", "TB"]; let value = bytes / 1024; let unit = 0; while (value >= 1024 && unit < units.length - 1) { @@ -114,31 +130,52 @@ export function formatBytes(bytes: number): string { /** Widen the pending refresh target: two different sides collapse to `both`. */ export function mergeRefresh( - current: PaneSide | 'both' | undefined, - next: PaneSide -): PaneSide | 'both' { + current: PaneSide | "both" | undefined, + next: PaneSide, +): PaneSide | "both" { if (!current || current === next) return next; - return 'both'; + return "both"; } /** Fold a `transfer-progress` tick in. The transfer's kind/name come from the front * pending op — which, because the core is sequential, is always the op now running — * so the frontend never needs to know the backend-allocated transfer id in advance. */ -export function applyProgress(session: SftpSession, p: TransferProgressDto): SftpSession { +export function applyProgress( + session: SftpSession, + p: TransferProgressDto, +): SftpSession { const front = session.pending[0]; - if (!front || (front.kind !== 'upload' && front.kind !== 'download')) return session; + + if (!front || (front.kind !== "upload" && front.kind !== "download")) { + return session; + } + return { ...session, - transfer: { kind: front.kind, name: front.name ?? '', done: p.done, total: p.total } + transfer: { + transferId: p.transferId, + stage: p.stage as "preparing" | "transferring", + kind: front.kind, + rootName: p.rootName, + currentFile: p.currentFile, + bytesDone: p.bytesDone, + bytesTotal: p.bytesTotal, + filesDone: p.filesDone, + filesTotal: p.filesTotal, + }, }; } /** Fold an `sftp-op-done` in: pop the front pending op (FIFO), record its refresh * target, clear the transfer display if it was a transfer, and surface any error. */ -export function applyOpDone(session: SftpSession, ok: boolean, error?: string): SftpSession { +export function applyOpDone( + session: SftpSession, + ok: boolean, + error?: string, +): SftpSession { if (session.pending.length === 0) return session; const [front, ...rest] = session.pending; - const wasTransfer = front.kind === 'upload' || front.kind === 'download'; + const wasTransfer = front.kind === "upload" || front.kind === "download"; return { ...session, pending: rest, @@ -146,8 +183,8 @@ export function applyOpDone(session: SftpSession, ok: boolean, error?: string): // A later op's success must NOT wipe an earlier op's failure in the same batch — that // silently masks e.g. a non-empty-folder delete beside a deleted sibling. The error // persists until the next batch clears it (`clearError`, called on enqueue). - error: ok ? session.error : (error ?? 'Operation failed'), - transfer: wasTransfer ? undefined : session.transfer + error: ok ? session.error : (error ?? "Operation failed"), + transfer: wasTransfer ? undefined : session.transfer, }; } @@ -178,7 +215,12 @@ function createSftp() { beginLoading(id: number, side: PaneSide): void { mut(id, (s) => ({ ...s, [side]: { ...s[side], loading: true } })); }, - listing(id: number, side: PaneSide, path: string, entries: FileEntryDto[]): void { + listing( + id: number, + side: PaneSide, + path: string, + entries: FileEntryDto[], + ): void { mut(id, (s) => ({ ...s, [side]: applyListing(s[side], path, entries) })); }, paneError(id: number, side: PaneSide, error: string): void { @@ -215,7 +257,11 @@ function createSftp() { * (leaving it set strands it on "Loading…"); a legit in-flight local listing keeps * its own spinner. */ sessionError(id: number, error: string): void { - mut(id, (s) => ({ ...s, error, remote: { ...s.remote, loading: false } })); + mut(id, (s) => ({ + ...s, + error, + remote: { ...s.remote, loading: false }, + })); }, remove(id: number): void { update((m) => { @@ -224,7 +270,7 @@ function createSftp() { next.delete(id); return next; }); - } + }, }; } diff --git a/crates/omnyssh-gui/ui/src/lib/theme/Icon.svelte b/crates/omnyssh-gui/ui/src/lib/theme/Icon.svelte index b0b45f87..b8880511 100644 --- a/crates/omnyssh-gui/ui/src/lib/theme/Icon.svelte +++ b/crates/omnyssh-gui/ui/src/lib/theme/Icon.svelte @@ -57,6 +57,10 @@ {:else if name === 'check'} + {:else if name === 'save'} + + + {:else if name === 'play'} {:else if name === 'edit'} diff --git a/crates/omnyssh-gui/ui/src/lib/theme/icons.ts b/crates/omnyssh-gui/ui/src/lib/theme/icons.ts index c4916eaa..17fa5b0e 100644 --- a/crates/omnyssh-gui/ui/src/lib/theme/icons.ts +++ b/crates/omnyssh-gui/ui/src/lib/theme/icons.ts @@ -13,6 +13,7 @@ export type IconName = | 'collapse' | 'expand' | 'check' + | 'save' | 'play' | 'edit' | 'trash' diff --git a/crates/omnyssh/src/app/action.rs b/crates/omnyssh/src/app/action.rs index e0e28948..467d4dde 100644 --- a/crates/omnyssh/src/app/action.rs +++ b/crates/omnyssh/src/app/action.rs @@ -145,6 +145,15 @@ pub enum AppAction { /// Navigate the cursor inside the host-picker popup (j/k). FmHostPickerNav(i8), // +1 = down, -1 = up + /// Edit the remote file under the cursor (in-app or external). + FmEditFile, + /// User confirmed opening a large file in the external editor. + FmConfirmExternalEdit, + /// Save the in-app editor buffer to the remote path. + EditorSave, + /// Quit the in-app editor (second Esc discards a dirty buffer). + EditorQuit, + // ----------------------------------------------------------------------- // Terminal multi-session actions // ----------------------------------------------------------------------- diff --git a/crates/omnyssh/src/app/actions.rs b/crates/omnyssh/src/app/actions.rs index 51b4c40b..77ac5951 100644 --- a/crates/omnyssh/src/app/actions.rs +++ b/crates/omnyssh/src/app/actions.rs @@ -641,6 +641,20 @@ impl App { } } + // ---- Remote file editing ---- + AppAction::FmEditFile => { + self.fm_start_edit(); + } + AppAction::FmConfirmExternalEdit => { + self.fm_confirm_external_edit(); + } + AppAction::EditorSave => { + self.editor_save(); + } + AppAction::EditorQuit => { + self.editor_quit(); + } + // --------------------------------------------------------------- // Terminal multi-session actions // --------------------------------------------------------------- diff --git a/crates/omnyssh/src/app/file_editor.rs b/crates/omnyssh/src/app/file_editor.rs new file mode 100644 index 00000000..45995e83 --- /dev/null +++ b/crates/omnyssh/src/app/file_editor.rs @@ -0,0 +1,110 @@ +//! In-app remote file editor state (ViewState only). +//! +//! Small files are edited here; large files use [`PendingExternalEdit`] + `$EDITOR`. + +// --------------------------------------------------------------------------- +// UTF-8 helpers (byte index ↔ char boundary) +// --------------------------------------------------------------------------- + +/// Previous char boundary before or at `idx`. +fn prev_char_boundary(s: &str, idx: usize) -> usize { + if idx == 0 { + return 0; + } + + let mut i = idx.min(s.len()).saturating_sub(1); + + while i > 0 && !s.is_char_boundary(i) { + i -= 1; + } + + i +} + +/// Clamp a byte index to a valid UTF-8 character boundary. +fn clamp_col(s: &str, col: usize) -> usize { + let col = col.min(s.len()); + + if s.is_char_boundary(col) { + col + } else { + prev_char_boundary(s, col) + } +} + +/// Open editor session for a remote file under the in-app size cap. +#[derive(Debug, Clone)] +pub struct FileEditorView { + pub remote_path: String, + + /// Buffer as individual lines (v1 simple model). + pub lines: Vec, + + pub cursor_row: usize, + pub cursor_col: usize, + + pub dirty: bool, + pub saving: bool, +} + +impl FileEditorView { + pub fn from_content(path: String, content: String) -> Self { + let lines: Vec = if content.is_empty() { + vec![String::new()] + } else { + content.lines().map(str::to_owned).collect() + }; + + Self { + remote_path: path, + lines, + cursor_row: 0, + cursor_col: 0, + dirty: false, + saving: false, + } + } + + /// Serialize buffer back to a single string (always ends with `\n` if non-empty). + pub fn to_content(&self) -> String { + let mut s = self.lines.join("\n"); + + if !self.lines.is_empty() { + s.push('\n'); + } + + s + } + + /// Keep cursor row and column within the current buffer. + /// + /// Cursor positions are byte indexes, but they must always point + /// to valid UTF-8 character boundaries. + pub fn clamp_cursor(&mut self) { + if self.lines.is_empty() { + self.lines.push(String::new()); + } + + self.cursor_row = self.cursor_row.min(self.lines.len() - 1); + + let line = &self.lines[self.cursor_row]; + self.cursor_col = clamp_col(line, self.cursor_col); + } +} + +/// Pending external-edit session: download → user editor → upload if changed. +#[derive(Debug, Clone)] +pub struct PendingExternalEdit { + pub remote_path: String, + pub local_path: String, + + /// Transfer identifier used by the external transfer workflow. + /// + /// Currently stored for the transfer workflow but not read directly + /// by this module. + #[allow(dead_code)] + pub transfer_id: u64, + + /// Bytes right after download. `None` means download still in progress. + pub pre_bytes: Option>, +} diff --git a/crates/omnyssh/src/app/file_manager.rs b/crates/omnyssh/src/app/file_manager.rs index d5d1ac80..67f97d41 100644 --- a/crates/omnyssh/src/app/file_manager.rs +++ b/crates/omnyssh/src/app/file_manager.rs @@ -2,6 +2,11 @@ //! directory navigation and SFTP transfers. use std::collections::HashSet; +use std::io::Stdout; +use std::sync::atomic::Ordering; +use std::time::Duration; + +use ratatui::{backend::CrosstermBackend, Terminal}; use super::*; use omnyssh_core::ssh::identity; @@ -159,6 +164,8 @@ pub enum FileManagerPopup { done: u64, total: u64, }, + /// Large file: open with the configured external editor? + ExternalEditConfirm { path: String, size: u64 }, } /// All UI state for the File Manager screen. @@ -188,6 +195,32 @@ pub struct FileManagerView { pub pending_ops: usize, } +fn resolve_external_editor(config_editor: &str) -> String { + if !config_editor.trim().is_empty() { + return config_editor.trim().to_string(); + } + + if let Ok(editor) = std::env::var("OMNYSSH_EDITOR") { + if !editor.trim().is_empty() { + return editor; + } + } + + if let Ok(editor) = std::env::var("VISUAL") { + if !editor.trim().is_empty() { + return editor; + } + } + + if let Ok(editor) = std::env::var("EDITOR") { + if !editor.trim().is_empty() { + return editor; + } + } + + "vim".to_string() +} + impl App { // ----------------------------------------------------------------------- // File Manager private helper methods @@ -645,6 +678,291 @@ impl App { }); } } + + // ----------------------------------------------------------------------- + // Remote file editing + // ----------------------------------------------------------------------- + + /// Start edit for the entry under the cursor on the remote panel. + pub(crate) fn fm_start_edit(&mut self) { + if self.view.file_manager.active_panel != FmPanel::Remote { + self.view.status_message = + Some("Switch to the remote panel (Tab) to edit a remote file.".into()); + return; + } + + let Some(entry) = self.view.file_manager.remote.cursor_entry().cloned() else { + return; + }; + + if entry.is_dir || entry.name == ".." { + self.view.status_message = Some("Cannot edit a directory.".into()); + return; + } + + if self.sftp_manager.is_none() { + self.view.status_message = Some("Not connected to a remote host.".into()); + return; + } + + if entry.size > omnyssh_core::ssh::sftp::MAX_IN_APP_EDIT_BYTES { + self.view.file_manager.popup = Some(FileManagerPopup::ExternalEditConfirm { + path: entry.path, + size: entry.size, + }); + return; + } + + if let Some(mgr) = &self.sftp_manager { + mgr.send(SftpCommand::ReadFile(entry.path.clone())); + self.view.status_message = Some(format!("Loading {}…", entry.name)); + } + } + + /// User confirmed external edit (Enter / y on ExternalEditConfirm). + pub(crate) fn fm_confirm_external_edit(&mut self) { + let path = match self.view.file_manager.popup.take() { + Some(FileManagerPopup::ExternalEditConfirm { path, .. }) => path, + other => { + self.view.file_manager.popup = other; + return; + } + }; + self.start_external_edit(path); + } + + /// Save in-app editor buffer to the remote path. + pub(crate) fn editor_save(&mut self) { + let Some(ed) = self.view.file_editor.as_mut() else { + return; + }; + if ed.saving { + return; + } + ed.saving = true; + let path = ed.remote_path.clone(); + let content = ed.to_content(); + if let Some(mgr) = &self.sftp_manager { + mgr.send(SftpCommand::WriteFile { path, content }); + self.view.status_message = Some("Saving…".into()); + } else { + ed.saving = false; + self.view.status_message = Some("Not connected — cannot save.".into()); + } + } + + /// Quit in-app editor. Dirty buffer requires a second Esc to discard. + pub(crate) fn editor_quit(&mut self) { + let Some(ed) = &self.view.file_editor else { + return; + }; + if ed.dirty { + let warn = "Unsaved changes — press Esc again to discard"; + if self.view.status_message.as_deref() == Some(warn) { + self.view.file_editor = None; + self.view.status_message = None; + } else { + self.view.status_message = Some(warn.into()); + } + } else { + self.view.file_editor = None; + self.view.status_message = None; + } + } + + /// Download remote file to a temp path, then open the configured editor. + pub(crate) fn start_external_edit(&mut self, remote_path: String) { + if self.sftp_manager.is_none() { + self.view.status_message = Some("Not connected.".into()); + return; + } + + let file_name = std::path::Path::new(&remote_path) + .file_name() + .and_then(|s| s.to_str()) + .unwrap_or("omnyssh-edit"); + + let stamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis()) + .unwrap_or(0); + + let local_path = std::env::temp_dir().join(format!( + "omnyssh-{}-{}-{}", + std::process::id(), + stamp, + file_name + )); + let local = local_path.to_string_lossy().into_owned(); + + let transfer_id = self.next_transfer_id; + self.next_transfer_id = self.next_transfer_id.wrapping_add(1); + + self.view.pending_external_edit = Some(PendingExternalEdit { + remote_path: remote_path.clone(), + local_path: local.clone(), + transfer_id, + pre_bytes: None, + }); + + if let Some(mgr) = &self.sftp_manager { + mgr.send(SftpCommand::Download { + remote: remote_path, + local, + transfer_id, + }); + } + self.view.status_message = Some("Downloading for external editor…".into()); + } + + /// Called when download for a pending external edit succeeds. + /// + /// The TUI temporarily gives exclusive ownership of the terminal to the + /// external editor. The crossterm input thread is paused first, so Vim + /// receives all keyboard input. Once the editor exits, the TUI is + /// restored and the local file is compared with `pre_bytes`. + pub(crate) fn spawn_external_editor_after_download( + &mut self, + terminal: &mut Terminal>, + ) -> anyhow::Result<()> { + let Some(pending) = self.view.pending_external_edit.as_mut() else { + return Ok(()); + }; + + let local = pending.local_path.clone(); + let remote = pending.remote_path.clone(); + + match std::fs::read(&local) { + Ok(bytes) => pending.pre_bytes = Some(bytes), + Err(e) => { + self.view.status_message = + Some(format!("Cannot read temp file after download: {e}")); + let _ = std::fs::remove_file(&local); + self.view.pending_external_edit = None; + return Ok(()); + } + } + + let editor_cmd = resolve_external_editor(&self.config.general.external_editor); + + self.view.status_message = Some(format!("Opening external editor ({editor_cmd})…")); + + // Tell the crossterm input thread to stop polling the terminal and + // wait until it has acknowledged the pause. This prevents it from + // consuming Vim's first keystrokes. + self.terminal_input_pause_requested + .store(true, Ordering::Release); + + while !self.terminal_input_paused.load(Ordering::Acquire) { + std::thread::sleep(Duration::from_millis(5)); + } + + // Return the terminal to normal shell mode before starting Vim. + crossterm::terminal::disable_raw_mode()?; + crossterm::execute!( + terminal.backend_mut(), + crossterm::terminal::LeaveAlternateScreen, + crate::utils::mouse::DisableMinimalMouseCapture, + crossterm::event::DisableBracketedPaste, + )?; + terminal.show_cursor()?; + + let editor_result = { + let mut parts = editor_cmd.split_whitespace(); + let program = parts.next().unwrap_or("vi"); + let mut cmd = std::process::Command::new(program); + + for arg in parts { + cmd.arg(arg); + } + + cmd.arg(&local); + + match cmd.status() { + Ok(status) if status.success() => Ok(()), + Ok(status) => Err(format!("editor exited with status {status}")), + Err(e) => Err(format!("failed to start editor: {e}")), + } + }; + + // Always restore the TUI terminal before doing anything else. + let restore_result = (|| -> anyhow::Result<()> { + crossterm::terminal::enable_raw_mode()?; + crossterm::execute!( + terminal.backend_mut(), + crossterm::terminal::EnterAlternateScreen, + crate::utils::mouse::EnableMinimalMouseCapture, + crossterm::event::EnableBracketedPaste, + )?; + terminal.clear()?; + terminal.show_cursor()?; + Ok(()) + })(); + + // Resume terminal input after the terminal has been restored. + self.terminal_input_pause_requested + .store(false, Ordering::Release); + + restore_result?; + + self.finish_external_edit(remote, local, editor_result); + + Ok(()) + } + + /// After external editor exits: upload if content changed, then clean temp later. + pub(crate) fn finish_external_edit( + &mut self, + remote_path: String, + local_path: String, + result: Result<(), String>, + ) { + if let Err(e) = result { + self.view.status_message = Some(format!("External editor failed: {e}")); + let _ = std::fs::remove_file(&local_path); + self.view.pending_external_edit = None; + return; + } + + let pre = self + .view + .pending_external_edit + .as_ref() + .and_then(|p| p.pre_bytes.as_ref()); + + let post = std::fs::read(&local_path).ok(); + let changed = match (pre, post.as_ref()) { + (Some(before), Some(after)) => before.as_slice() != after.as_slice(), + _ => true, + }; + + if !changed { + self.view.status_message = Some("No changes — skipped upload.".into()); + let _ = std::fs::remove_file(&local_path); + self.view.pending_external_edit = None; + return; + } + + let transfer_id = self.next_transfer_id; + self.next_transfer_id = self.next_transfer_id.wrapping_add(1); + + if let Some(mgr) = &self.sftp_manager { + mgr.send(SftpCommand::Upload { + local: local_path.clone(), + remote: remote_path, + transfer_id, + }); + } + + if let Some(p) = self.view.pending_external_edit.as_mut() { + // Reuse the pending-edit transfer slot for the upload so progress + // events can be associated with this external edit as well. + p.local_path = local_path; + p.transfer_id = transfer_id; + } + + self.view.status_message = Some("Uploading changes… 0%".into()); + } } /// Extracts the file name from an absolute path string. diff --git a/crates/omnyssh/src/app/input.rs b/crates/omnyssh/src/app/input.rs index 3e3f60df..650d4f21 100644 --- a/crates/omnyssh/src/app/input.rs +++ b/crates/omnyssh/src/app/input.rs @@ -45,6 +45,13 @@ impl App { return Ok(None); } + // In-app file editor captures all keys while open. + if self.view.file_editor.is_some() { + return Ok(ui::file_editor::handle_input(key, &mut self.view)); + } + + let screen = self.state.read().await.screen.clone(); + // ---------------------------------------------------------------- // Terminal screen intercepts ALL keys — including Ctrl+C which must // be forwarded to the PTY rather than quitting the application. diff --git a/crates/omnyssh/src/app/mod.rs b/crates/omnyssh/src/app/mod.rs index 7e83b458..d5bb5bf6 100644 --- a/crates/omnyssh/src/app/mod.rs +++ b/crates/omnyssh/src/app/mod.rs @@ -9,7 +9,7 @@ use std::collections::HashMap; use std::io::Stdout; -use std::sync::Arc; +use std::sync::{atomic::AtomicBool, Arc}; use std::time::Duration; use ratatui::{backend::CrosstermBackend, Terminal}; @@ -31,6 +31,7 @@ use omnyssh_core::ssh::tunnel::{TunnelManager, TunnelStatus}; mod action; mod actions; +mod file_editor; mod file_manager; mod host; mod input; @@ -39,12 +40,36 @@ mod terminal; mod update; pub use action::*; +pub use file_editor::*; pub use file_manager::*; pub use host::*; pub use snippets::*; pub use terminal::*; pub use update::*; +/// Format byte counts for transfer status messages. +fn format_transfer_size(bytes: u64) -> String { + const UNITS: [&str; 5] = ["B", "KiB", "MiB", "GiB", "TiB"]; + + let mut value = bytes as f64; + let mut unit = 0usize; + + while value >= 1024.0 && unit < UNITS.len() - 1 { + value /= 1024.0; + unit += 1; + } + + if unit == 0 { + format!("{} {}", bytes, UNITS[unit]) + } else if value >= 100.0 { + format!("{value:.0} {}", UNITS[unit]) + } else if value >= 10.0 { + format!("{value:.1} {}", UNITS[unit]) + } else { + format!("{value:.2} {}", UNITS[unit]) + } +} + // --------------------------------------------------------------------------- // Screen // --------------------------------------------------------------------------- @@ -145,6 +170,10 @@ pub struct ViewState { pub snippets_view: SnippetsView, /// State for the File Manager screen. pub file_manager: FileManagerView, + /// In-app remote file editor (None when closed). + pub file_editor: Option, + /// External-editor session in progress (download / upload lifecycle). + pub pending_external_edit: Option, /// State for the Terminal multi-session screen. pub terminal_view: TerminalView, /// Active colour theme — loaded from config on startup. @@ -172,6 +201,8 @@ impl ViewState { host_list: HostListView::default(), snippets_view: SnippetsView::default(), file_manager: FileManagerView::default(), + file_editor: None, + pending_external_edit: None, terminal_view: TerminalView::default(), theme: Theme::default(), keybindings: ParsedKeybindings::default(), @@ -252,6 +283,11 @@ pub struct App { /// Consumed at the top of the next main-loop iteration before blocking /// on `event_rx.recv()`. pending_event: Option, + /// Prevents the crossterm input thread from consuming keyboard input while + /// an external editor owns the terminal. + pub(crate) terminal_input_pause_requested: Arc, + /// Set by the input thread once it has stopped polling crossterm. + pub(crate) terminal_input_paused: Arc, /// Application config — retained so update preferences can be persisted. config: AppConfig, } @@ -285,6 +321,8 @@ impl App { tunnel_manager: Some(tunnel_manager), hosts_loaded: false, pending_event: None, + terminal_input_pause_requested: Arc::new(AtomicBool::new(false)), + terminal_input_paused: Arc::new(AtomicBool::new(false)), config, } } @@ -313,7 +351,11 @@ impl App { let mut terminal = Terminal::new(backend)?; // Background event thread (keyboard + tick). - spawn_event_thread(self.event_tx.clone())?; + spawn_event_thread( + self.event_tx.clone(), + Arc::clone(&self.terminal_input_pause_requested), + Arc::clone(&self.terminal_input_paused), + )?; // Forward domain events from the core channel into the main event // channel so the main loop consumes a single stream. @@ -535,7 +577,7 @@ impl App { } } - AppEvent::Core(event) => self.handle_core_event(event).await?, + AppEvent::Core(event) => self.handle_core_event(event, terminal).await?, AppEvent::PassphraseUnlocked { key_path, result } => { self.finish_unlock(key_path, result); } @@ -616,7 +658,11 @@ impl App { /// Applies a domain event produced by the SSH engine or a background /// task. Split out of `main_loop` so the input/domain event split stays /// mechanical. - async fn handle_core_event(&mut self, event: CoreEvent) -> anyhow::Result<()> { + async fn handle_core_event( + &mut self, + event: CoreEvent, + terminal: &mut Terminal>, + ) -> anyhow::Result<()> { match event { CoreEvent::HostsLoaded(hosts) => { let n = hosts.len(); @@ -951,13 +997,50 @@ impl App { } CoreEvent::Error(msg) => { - self.view.status_message = Some(msg); + if let Some(rest) = msg.strip_prefix("edit-read:") { + self.view.status_message = Some(format!("Cannot open file: {rest}")); + } else { + self.view.status_message = Some(msg); + } } // ---------------------------------------------------------------- // File Manager events // ---------------------------------------------------------------- - CoreEvent::FileTransferProgress(tid, done, total) => { + CoreEvent::FileTransferProgress { + transfer_id: tid, + root_name: _, + current_file: _, + bytes_done: done, + bytes_total: total, + .. + } => { + // External-editor transfers do not use the normal file-manager + // progress popup, so surface their progress through the global + // status bar. This covers both the initial download and the + // upload after the user finishes editing. + if let Some(pending) = self.view.pending_external_edit.as_ref() { + if tid == pending.transfer_id { + let percent = done + .saturating_mul(100) + .checked_div(total) + .unwrap_or(0) + .min(100); + + let direction = if pending.pre_bytes.is_some() { + "Uploading changes" + } else { + "Downloading for external editor" + }; + + self.view.status_message = Some(format!( + "{direction}… {percent}% ({}/{})", + format_transfer_size(done), + format_transfer_size(total), + )); + } + } + if let Some(FileManagerPopup::TransferProgress { transfer_id, done: d, @@ -1037,7 +1120,79 @@ impl App { self.view.file_manager.preview_path = Some(path); } + CoreEvent::FileContentReady { path, content } => { + self.view.file_editor = Some(FileEditorView::from_content(path, content)); + self.view.status_message = None; + } + + CoreEvent::FileWriteDone { path, result } => { + if let Some(ed) = self.view.file_editor.as_mut() { + ed.saving = false; + match result { + Ok(()) => { + ed.dirty = false; + self.view.status_message = Some(format!("Saved {path}")); + } + Err(e) => { + self.view.status_message = Some(format!("Save failed: {e}")); + } + } + } + } + + CoreEvent::ExternalEditDone { + remote_path, + local_path, + result, + } => { + self.finish_external_edit(remote_path, local_path, result); + } + CoreEvent::SftpOpDone { result } => { + // External-edit download finished (pre_bytes still None). + if self + .view + .pending_external_edit + .as_ref() + .is_some_and(|p| p.pre_bytes.is_none()) + { + match result { + Ok(()) => self.spawn_external_editor_after_download(terminal)?, + Err(e) => { + self.view.status_message = + Some(format!("Download for edit failed: {e}")); + if let Some(pend) = self.view.pending_external_edit.take() { + let _ = std::fs::remove_file(&pend.local_path); + } + } + } + return Ok(()); + } + + // External-edit upload finished (pre_bytes is Some). + if self + .view + .pending_external_edit + .as_ref() + .is_some_and(|p| p.pre_bytes.is_some()) + { + match &result { + Ok(()) => { + if let Some(pend) = self.view.pending_external_edit.take() { + let _ = std::fs::remove_file(&pend.local_path); + } + self.view.status_message = Some("External edit uploaded.".into()); + } + Err(e) => { + self.view.status_message = + Some(format!("Upload after edit failed: {e}")); + if let Some(pend) = self.view.pending_external_edit.take() { + let _ = std::fs::remove_file(&pend.local_path); + } + } + } + } + self.view.file_manager.pending_ops = self.view.file_manager.pending_ops.saturating_sub(1); let remaining = self.view.file_manager.pending_ops; diff --git a/crates/omnyssh/src/event.rs b/crates/omnyssh/src/event.rs index 4c0a4a70..1dedb402 100644 --- a/crates/omnyssh/src/event.rs +++ b/crates/omnyssh/src/event.rs @@ -1,6 +1,10 @@ //! TUI event stream: crossterm input events plus wrapped domain events. use crossterm::event::{self, Event, KeyEvent, KeyEventKind, MouseEventKind}; +use std::sync::{ + atomic::{AtomicBool, Ordering}, + Arc, +}; use std::time::Duration; use tokio::sync::mpsc; @@ -45,10 +49,26 @@ fn should_forward_key(kind: KeyEventKind) -> bool { /// /// # Errors /// Returns an error if the background thread fails to spawn. -pub fn spawn_event_thread(tx: mpsc::Sender) -> anyhow::Result<()> { +pub fn spawn_event_thread( + tx: mpsc::Sender, + pause_requested: Arc, + paused_ack: Arc, +) -> anyhow::Result<()> { std::thread::spawn(move || { let tick = Duration::from_millis(33); loop { + // External editors need exclusive ownership of the terminal. + // Once requested, acknowledge the pause and do not call + // crossterm::event::poll/read until the request is released. + if pause_requested.load(Ordering::Acquire) { + paused_ack.store(true, Ordering::Release); + while pause_requested.load(Ordering::Acquire) { + std::thread::sleep(Duration::from_millis(10)); + } + paused_ack.store(false, Ordering::Release); + continue; + } + if event::poll(tick).unwrap_or(false) { match event::read() { Ok(Event::Key(key)) => { diff --git a/crates/omnyssh/src/ui/file_editor.rs b/crates/omnyssh/src/ui/file_editor.rs new file mode 100644 index 00000000..499dc50b --- /dev/null +++ b/crates/omnyssh/src/ui/file_editor.rs @@ -0,0 +1,295 @@ +//! Full-screen in-app editor for remote files under the size cap. +//! +//! Cursor positions are **byte indexes** into UTF-8 strings, but all movement +//! and edits snap to **character boundaries** so multi-byte glyphs (e.g. ╔═║) +//! do not panic on `split_at` / `insert` / `remove`. + +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use ratatui::{ + layout::{Constraint, Direction, Layout, Rect}, + style::{Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Paragraph, Wrap}, + Frame, +}; + +use crate::app::{AppAction, ViewState}; + +// --------------------------------------------------------------------------- +// UTF-8 helpers (byte index ↔ char boundary) +// --------------------------------------------------------------------------- + +fn next_char_boundary(s: &str, idx: usize) -> usize { + if idx >= s.len() { + return s.len(); + } + + let mut i = idx + 1; + + while i < s.len() && !s.is_char_boundary(i) { + i += 1; + } + + i +} + +fn prev_char_boundary(s: &str, idx: usize) -> usize { + if idx == 0 { + return 0; + } + + let mut i = idx.min(s.len()).saturating_sub(1); + + while i > 0 && !s.is_char_boundary(i) { + i -= 1; + } + + i +} + +fn clamp_col(s: &str, col: usize) -> usize { + let col = col.min(s.len()); + + if s.is_char_boundary(col) { + col + } else { + prev_char_boundary(s, col) + } +} + +// --------------------------------------------------------------------------- +// Render +// --------------------------------------------------------------------------- + +pub fn render(frame: &mut Frame, area: Rect, view: &ViewState) { + let Some(ed) = &view.file_editor else { + return; + }; + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), + Constraint::Min(3), + Constraint::Length(1), + ]) + .split(area); + + let title = format!( + " EDIT {}{} ", + ed.remote_path, + if ed.dirty { " [modified]" } else { "" } + ); + + frame.render_widget( + Paragraph::new(title).style(Style::default().add_modifier(Modifier::BOLD)), + chunks[0], + ); + + // Number of actual text rows available inside the bordered editor. + let inner_h = chunks[1].height.saturating_sub(2) as usize; + let visible_rows = inner_h.max(1); + + // --------------------------------------------------------------- + // Keep the cursor visible. + // + // If the cursor is near the bottom of the file, move the displayed + // window down so the cursor remains on screen. + // --------------------------------------------------------------- + let start = ed.cursor_row.saturating_sub(visible_rows.saturating_sub(1)); + + let end = (start + visible_rows).min(ed.lines.len()); + + let text: Vec = ed.lines[start..end] + .iter() + .enumerate() + .map(|(i, line)| { + let row = start + i; + let mut spans = Vec::new(); + + if row == ed.cursor_row { + let col = clamp_col(line, ed.cursor_col); + let (left, rest) = line.split_at(col); + + let (mid, right) = if rest.is_empty() { + (" ", "") + } else { + let ch = rest.chars().next().unwrap(); + let n = ch.len_utf8(); + (&rest[..n], &rest[n..]) + }; + + spans.push(Span::raw(left.to_string())); + + spans.push(Span::styled( + mid.to_string(), + Style::default().add_modifier(Modifier::REVERSED), + )); + + spans.push(Span::raw(right.to_string())); + } else { + spans.push(Span::raw(line.clone())); + } + + Line::from(spans) + }) + .collect(); + + frame.render_widget( + Paragraph::new(text) + .block(Block::default().borders(Borders::ALL).title(" buffer ")) + .wrap(Wrap { trim: false }), + chunks[1], + ); + + let hints = if ed.saving { + "Saving…".to_string() + } else { + "Ctrl+S save · Esc close · arrows move · type to insert".to_string() + }; + + frame.render_widget(Paragraph::new(hints), chunks[2]); +} + +// --------------------------------------------------------------------------- +// Input +// --------------------------------------------------------------------------- + +pub fn handle_input(key: KeyEvent, view: &mut ViewState) -> Option { + let ed = view.file_editor.as_mut()?; + + if ed.saving { + return None; + } + + match key.code { + KeyCode::Esc => Some(AppAction::EditorQuit), + + KeyCode::Char('s') if key.modifiers.contains(KeyModifiers::CONTROL) => { + Some(AppAction::EditorSave) + } + + KeyCode::Up => { + ed.cursor_row = ed.cursor_row.saturating_sub(1); + ed.clamp_cursor(); + None + } + + KeyCode::Down => { + if ed.cursor_row + 1 < ed.lines.len() { + ed.cursor_row += 1; + } + + ed.clamp_cursor(); + None + } + + KeyCode::Left => { + ed.clamp_cursor(); + + let line = &ed.lines[ed.cursor_row]; + + if ed.cursor_col > 0 { + ed.cursor_col = prev_char_boundary(line, ed.cursor_col); + } else if ed.cursor_row > 0 { + ed.cursor_row -= 1; + ed.cursor_col = ed.lines[ed.cursor_row].len(); + } + + None + } + + KeyCode::Right => { + ed.clamp_cursor(); + + let line = &ed.lines[ed.cursor_row]; + + if ed.cursor_col < line.len() { + ed.cursor_col = next_char_boundary(line, ed.cursor_col); + } else if ed.cursor_row + 1 < ed.lines.len() { + ed.cursor_row += 1; + ed.cursor_col = 0; + } + + None + } + + KeyCode::Home => { + ed.cursor_col = 0; + None + } + + KeyCode::End => { + ed.clamp_cursor(); + ed.cursor_col = ed.lines[ed.cursor_row].len(); + None + } + + KeyCode::Enter => { + ed.clamp_cursor(); + + let row = ed.cursor_row; + let col = ed.cursor_col; + + let rest = ed.lines[row].split_off(col); + + ed.lines.insert(row + 1, rest); + + ed.cursor_row += 1; + ed.cursor_col = 0; + ed.dirty = true; + + None + } + + KeyCode::Backspace => { + ed.clamp_cursor(); + + if ed.cursor_col > 0 { + let row = ed.cursor_row; + let col = ed.cursor_col; + + let line = &mut ed.lines[row]; + let start = prev_char_boundary(line, col); + + line.replace_range(start..col, ""); + + ed.cursor_col = start; + ed.dirty = true; + } else if ed.cursor_row > 0 { + let row = ed.cursor_row; + + let cur = ed.lines.remove(row); + + ed.cursor_row -= 1; + ed.cursor_col = ed.lines[ed.cursor_row].len(); + + ed.lines[ed.cursor_row].push_str(&cur); + + ed.dirty = true; + } + + None + } + + KeyCode::Char(c) + if !key.modifiers.contains(KeyModifiers::CONTROL) + && !key.modifiers.contains(KeyModifiers::ALT) => + { + ed.clamp_cursor(); + + let row = ed.cursor_row; + let col = ed.cursor_col; + let ch_len = c.len_utf8(); + + ed.lines[row].insert(col, c); + + ed.cursor_col = col + ch_len; + ed.dirty = true; + + None + } + + _ => None, + } +} diff --git a/crates/omnyssh/src/ui/file_manager.rs b/crates/omnyssh/src/ui/file_manager.rs index 85f69a23..d900729a 100644 --- a/crates/omnyssh/src/ui/file_manager.rs +++ b/crates/omnyssh/src/ui/file_manager.rs @@ -114,6 +114,7 @@ enum PopupKind { MkDir, Rename, TransferProgress, + ExternalEditConfirm, } fn popup_kind(view: &ViewState) -> Option { @@ -123,6 +124,7 @@ fn popup_kind(view: &ViewState) -> Option { Some(FileManagerPopup::MkDir(_)) => Some(PopupKind::MkDir), Some(FileManagerPopup::Rename { .. }) => Some(PopupKind::Rename), Some(FileManagerPopup::TransferProgress { .. }) => Some(PopupKind::TransferProgress), + Some(FileManagerPopup::ExternalEditConfirm { .. }) => Some(PopupKind::ExternalEditConfirm), None => None, } } @@ -150,6 +152,7 @@ pub fn handle_input(key: KeyEvent, view: &mut ViewState) -> Option { KeyCode::Char('.') => Some(AppAction::FmToggleHidden), KeyCode::Char('R') => Some(AppAction::FmOpenRename), KeyCode::Char('H') => Some(AppAction::FmOpenHostPicker), + KeyCode::Char('e') => Some(AppAction::FmEditFile), KeyCode::Esc => Some(AppAction::FmClosePopup), _ => None, } @@ -240,6 +243,14 @@ fn render_hints_header(frame: &mut Frame, area: Rect, theme: &crate::ui::theme:: .add_modifier(Modifier::BOLD), ), Span::styled(":Host", Style::default().fg(theme.text_muted)), + Span::raw(" "), + Span::styled( + "e", + Style::default() + .fg(theme.accent) + .add_modifier(Modifier::BOLD), + ), + Span::styled(":Edit", Style::default().fg(theme.text_muted)), ]); // Only Windows has more than one root to switch between. if cfg!(windows) { @@ -275,6 +286,13 @@ fn handle_popup_input(key: KeyEvent, kind: PopupKind, view: &mut ViewState) -> O None } } + PopupKind::ExternalEditConfirm => match key.code { + KeyCode::Enter | KeyCode::Char('y') | KeyCode::Char('Y') => { + Some(AppAction::FmConfirmExternalEdit) + } + KeyCode::Esc | KeyCode::Char('n') | KeyCode::Char('N') => Some(AppAction::FmClosePopup), + _ => None, + }, } } @@ -709,6 +727,9 @@ fn render_fm_popup( FileManagerPopup::TransferProgress { .. } => { // Transfer progress is rendered in the preview zone, not as a floating popup. } + FileManagerPopup::ExternalEditConfirm { path, size } => { + render_fm_external_edit_confirm(frame, area, path, *size, theme); + } } } @@ -887,7 +908,70 @@ fn render_fm_text_input( ); } -/// Renders the delete-confirmation popup for file manager items. +/// Confirm opening a large remote file in the external editor. +fn render_fm_external_edit_confirm( + frame: &mut Frame, + area: Rect, + path: &str, + size: u64, + theme: &crate::ui::theme::Theme, +) { + let popup_area = crate::ui::popup::centred_rect(70, 40, area); + frame.render_widget(Clear, popup_area); + + let block = Block::default() + .title(" External Editor ") + .title_alignment(Alignment::Center) + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(theme.accent)); + + let inner = block.inner(popup_area); + frame.render_widget(block, popup_area); + + let lines = vec![ + Line::from(Span::styled( + " File is too large for the in-app editor.", + Style::default() + .fg(Color::White) + .add_modifier(Modifier::BOLD), + )), + Line::from(""), + Line::from(Span::styled( + format!(" Path: {path}"), + Style::default().fg(theme.text_muted), + )), + Line::from(Span::styled( + format!(" Size: {size} bytes"), + Style::default().fg(theme.text_muted), + )), + Line::from(""), + Line::from(Span::styled( + " Download to a temp file and open your configured editor?", + Style::default().fg(Color::White), + )), + Line::from(""), + Line::from(vec![ + Span::styled(" ", Style::default()), + Span::styled( + "Enter / y", + Style::default() + .fg(Color::Green) + .add_modifier(Modifier::BOLD), + ), + Span::styled(": open editor ", Style::default().fg(theme.text_muted)), + Span::styled( + "n / Esc", + Style::default() + .fg(theme.text_error) + .add_modifier(Modifier::BOLD), + ), + Span::styled(": cancel", Style::default().fg(theme.text_muted)), + ]), + ]; + frame.render_widget(Paragraph::new(lines), inner); +} + fn render_fm_delete_confirm( frame: &mut Frame, area: Rect, diff --git a/crates/omnyssh/src/ui/mod.rs b/crates/omnyssh/src/ui/mod.rs index 532d3ab8..56f6fddb 100644 --- a/crates/omnyssh/src/ui/mod.rs +++ b/crates/omnyssh/src/ui/mod.rs @@ -8,6 +8,7 @@ use crate::app::{AppState, Screen, SnippetPopup, ViewState}; pub mod card; pub mod dashboard; pub mod detail_view; +pub mod file_editor; pub mod file_manager; pub mod host_list; pub mod popup; @@ -42,13 +43,17 @@ pub fn render(frame: &mut Frame, state: &AppState, view: &ViewState) { let content_area = layout[0]; let status_area = layout[1]; - // Render active screen. - match state.screen { - Screen::Dashboard => dashboard::render(frame, content_area, state, view), - Screen::DetailView => detail_view::render(frame, content_area, state, view), - Screen::FileManager => file_manager::render(frame, content_area, state, view), - Screen::Snippets => snippets::render(frame, content_area, state, view), - Screen::Terminal => terminal::render(frame, content_area, state, view), + // In-app editor overlays the active screen. + if view.file_editor.is_some() { + file_editor::render(frame, content_area, view); + } else { + match state.screen { + Screen::Dashboard => dashboard::render(frame, content_area, state, view), + Screen::DetailView => detail_view::render(frame, content_area, state, view), + Screen::FileManager => file_manager::render(frame, content_area, state, view), + Screen::Snippets => snippets::render(frame, content_area, state, view), + Screen::Terminal => terminal::render(frame, content_area, state, view), + } } // Always render status bar. diff --git a/crates/omnyssh/src/ui/status_bar.rs b/crates/omnyssh/src/ui/status_bar.rs index 8fc53ab3..6799e678 100644 --- a/crates/omnyssh/src/ui/status_bar.rs +++ b/crates/omnyssh/src/ui/status_bar.rs @@ -254,6 +254,14 @@ pub fn render(frame: &mut Frame, area: Rect, state: &AppState, view: &ViewState) hint_style, )]) } + + FileManagerPopup::ExternalEditConfirm { .. } => Line::from(vec![ + key!("Enter / y"), + hint!("open external editor"), + sep!(), + key!("n / Esc"), + hint!("cancel"), + ]), }; frame.render_widget( Paragraph::new(line).style(Style::default().bg(Color::Reset)), From 8bda743c4dfe8e3743663655a45f98daa8b8ab10 Mon Sep 17 00:00:00 2001 From: Shubhansh Bhushan Date: Thu, 1 Oct 2026 17:18:58 +0530 Subject: [PATCH 2/8] fix(gui): make SFTP deletion cancellable --- crates/omnyssh-core/src/event.rs | 2 +- crates/omnyssh-core/src/ssh/sftp.rs | 242 +++++++++++++++--- crates/omnyssh-gui/src/commands/sftp.rs | 16 +- crates/omnyssh-gui/ui/src/lib/bindings.ts | 8 +- .../ui/src/lib/screens/SftpView.svelte | 190 ++++++++++++-- .../ui/src/lib/stores/sftp.test.ts | 43 ++++ crates/omnyssh-gui/ui/src/lib/stores/sftp.ts | 17 +- 7 files changed, 439 insertions(+), 79 deletions(-) diff --git a/crates/omnyssh-core/src/event.rs b/crates/omnyssh-core/src/event.rs index 4aa26ae7..e7be655b 100644 --- a/crates/omnyssh-core/src/event.rs +++ b/crates/omnyssh-core/src/event.rs @@ -76,7 +76,7 @@ pub enum CoreEvent { MetricsUpdate(HostId, Metrics), /// Connection status changed for a host (reported by metrics poller). HostStatusChanged(HostId, ConnectionStatus), - /// File transfer progress: (transfer_id, bytes_done, bytes_total). + /// File-operation progress: transfers and recursive deletes. FileTransferProgress { transfer_id: TransferId, stage: TransferStage, diff --git a/crates/omnyssh-core/src/ssh/sftp.rs b/crates/omnyssh-core/src/ssh/sftp.rs index db556f0b..252279ff 100644 --- a/crates/omnyssh-core/src/ssh/sftp.rs +++ b/crates/omnyssh-core/src/ssh/sftp.rs @@ -9,7 +9,6 @@ use std::time::Duration; use anyhow::Context; -use async_recursion::async_recursion; use std::collections::HashSet; use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; @@ -81,8 +80,13 @@ pub enum SftpCommand { /// Cancel an active upload/download. CancelTransfer { transfer_id: TransferId }, - /// Delete a remote file (falls back to removing an empty directory). + /// Delete a remote file or directory. Delete(String), + /// Delete a remote file or directory and report progress to the GUI. + DeleteWithProgress { + path: String, + transfer_id: TransferId, + }, /// Create a remote directory. MkDir(String), /// Rename / move a remote path. @@ -111,34 +115,140 @@ pub struct SftpManager { cancelled: Arc>>, } -#[async_recursion] -async fn delete_recursive( +async fn scan_remote_tree( + sftp: &russh_sftp::client::SftpSession, + remote_root: &str, +) -> anyhow::Result> { + let mut entries = Vec::new(); + let mut scan = vec![remote_root.to_string()]; + + while let Some(remote_dir) = scan.pop() { + let dir_entries = do_list_dir(sftp, &remote_dir).await?; + + for entry in dir_entries { + if entry.name == ".." { + continue; + } + + if entry.is_dir { + scan.push(entry.path.clone()); + } + + entries.push(entry); + } + } + + Ok(entries) +} + +async fn delete_remote_tree( sftp: &russh_sftp::client::SftpSession, path: &str, + transfer_id: Option, + event_tx: &mpsc::Sender, + cancelled: &Arc>>, ) -> anyhow::Result<()> { - // Try deleting as a file first. + // Preserve the fast path for a single file. if sftp.remove_file(path).await.is_ok() { + let root_name = Path::new(path) + .file_name() + .unwrap_or_default() + .to_string_lossy() + .into_owned(); + + if let Some(transfer_id) = transfer_id { + let _ = event_tx + .send(CoreEvent::FileTransferProgress { + transfer_id, + stage: TransferStage::Transferring, + root_name: root_name.clone(), + current_file: root_name, + bytes_done: 1, + bytes_total: 1, + files_done: 1, + files_total: 1, + }) + .await; + } + return Ok(()); } - // Otherwise recurse into the directory. - let entries = sftp.read_dir(path).await?; + // Reuse the same remote pre-scan used by directory download. + let mut entries = scan_remote_tree(sftp, path).await?; + let total_items = entries.len() as u32 + 1; + let root_name = Path::new(path) + .file_name() + .unwrap_or_default() + .to_string_lossy() + .into_owned(); + + // Children must be removed before their parent directories. + entries.sort_by(|a, b| b.path.len().cmp(&a.path.len())); + + let mut deleted = 0u32; for entry in entries { - let name = entry.file_name(); + if let Some(transfer_id) = transfer_id { + if cancelled.lock().unwrap().contains(&transfer_id) { + anyhow::bail!("Transfer cancelled"); + } + } - // Skip pseudo entries. - if name == "." || name == ".." { - continue; + if entry.is_dir { + sftp.remove_dir(&entry.path) + .await + .with_context(|| format!("remove remote directory '{}'", entry.path))?; + } else { + sftp.remove_file(&entry.path) + .await + .with_context(|| format!("remove remote file '{}'", entry.path))?; } - let child = format!("{}/{}", path.trim_end_matches('/'), name); + deleted += 1; - delete_recursive(sftp, &child).await?; + if let Some(transfer_id) = transfer_id { + let _ = event_tx + .send(CoreEvent::FileTransferProgress { + transfer_id, + stage: TransferStage::Transferring, + root_name: root_name.clone(), + current_file: entry.name, + bytes_done: deleted as u64, + bytes_total: total_items as u64, + files_done: deleted, + files_total: total_items, + }) + .await; + } } - // Remove the now-empty directory. - sftp.remove_dir(path).await?; + if let Some(transfer_id) = transfer_id { + if cancelled.lock().unwrap().contains(&transfer_id) { + anyhow::bail!("Transfer cancelled"); + } + } + + sftp.remove_dir(path) + .await + .with_context(|| format!("remove remote directory '{path}'"))?; + + deleted += 1; + + if let Some(transfer_id) = transfer_id { + let _ = event_tx + .send(CoreEvent::FileTransferProgress { + transfer_id, + stage: TransferStage::Transferring, + root_name: root_name.clone(), + current_file: root_name, + bytes_done: deleted as u64, + bytes_total: total_items as u64, + files_done: deleted, + files_total: total_items, + }) + .await; + } Ok(()) } @@ -305,9 +415,48 @@ async fn sftp_task_loop( } SftpCommand::Delete(path) => { - let result = delete_recursive(&sftp, &path) - .await - .map_err(|e| e.to_string()); + let result = delete_remote_tree( + &sftp, + &path, + None, + &event_tx, + &cancelled, + ) + .await + .map_err(|e| e.to_string()); + + let _ = event_tx.send(CoreEvent::SftpOpDone { result }).await; + } + + SftpCommand::DeleteWithProgress { path, transfer_id } => { + let root_name = Path::new(&path) + .file_name() + .unwrap_or_default() + .to_string_lossy() + .into_owned(); + + let _ = event_tx + .send(CoreEvent::FileTransferProgress { + transfer_id, + stage: TransferStage::Preparing, + root_name, + current_file: "Scanning remote files…".into(), + bytes_done: 0, + bytes_total: 0, + files_done: 0, + files_total: 0, + }) + .await; + + let result = delete_remote_tree( + &sftp, + &path, + Some(transfer_id), + &event_tx, + &cancelled, + ) + .await + .map_err(|e| e.to_string()); let _ = event_tx.send(CoreEvent::SftpOpDone { result }).await; } @@ -516,6 +665,11 @@ async fn do_download( } } + remote_file + .close() + .await + .context("close remote file after download")?; + Ok(()) } @@ -589,6 +743,11 @@ async fn do_upload( } } + remote_file + .close() + .await + .context("close remote file after upload")?; + Ok(()) } @@ -740,29 +899,21 @@ async fn do_download_dir( }) .await; // ---------- Pre-scan ---------- + let scanned = scan_remote_tree(sftp, remote_root).await?; let mut work: Vec<(String, PathBuf, u64)> = Vec::new(); let mut total_bytes = 0u64; - let mut scan: Vec<(String, PathBuf)> = - vec![(remote_root.to_string(), PathBuf::from(local_root))]; - - while let Some((remote_dir, local_dir)) = scan.pop() { - let entries = do_list_dir(sftp, &remote_dir).await?; - - for entry in entries { - if entry.name == ".." { - continue; - } - - let local_path = local_dir.join(&entry.name); - - if entry.is_dir { - scan.push((entry.path, local_path)); - } else { - total_bytes += entry.size; - work.push((entry.path, local_path, entry.size)); - } + for entry in scanned { + if entry.is_dir { + continue; } + + let relative = Path::new(&entry.path) + .strip_prefix(Path::new(remote_root)) + .unwrap_or_else(|_| Path::new(&entry.name)); + let local_path = PathBuf::from(local_root).join(relative); + total_bytes += entry.size; + work.push((entry.path, local_path, entry.size)); } // ---------- Download ---------- @@ -825,7 +976,11 @@ async fn do_read_preview( let mut buf = vec![0u8; 4_096]; let n = file.read(&mut buf).await.context("read preview bytes")?; buf.truncate(n); - Ok(String::from_utf8_lossy(&buf).into_owned()) + let content = String::from_utf8_lossy(&buf).into_owned(); + file.close() + .await + .context("close remote file after preview")?; + Ok(content) } /// Read a full remote text file for the in-app editor. @@ -861,7 +1016,12 @@ async fn do_read_file( } } - String::from_utf8(data).context("file is not valid UTF-8") + let content = String::from_utf8(data).context("file is not valid UTF-8")?; + remote + .close() + .await + .context("close remote file after read")?; + Ok(content) } /// Truncate/create a remote file and write UTF-8 content (in-app editor save). @@ -878,6 +1038,10 @@ async fn do_write_file( .write_all(content.as_bytes()) .await .context("write remote")?; + remote + .close() + .await + .context("close remote file after write")?; Ok(()) } diff --git a/crates/omnyssh-gui/src/commands/sftp.rs b/crates/omnyssh-gui/src/commands/sftp.rs index 3ac79c15..80299791 100644 --- a/crates/omnyssh-gui/src/commands/sftp.rs +++ b/crates/omnyssh-gui/src/commands/sftp.rs @@ -203,7 +203,8 @@ pub fn sftp_rename( Ok(()) } -/// Delete a remote file (falls back to an empty directory in the core) (tech-gui.md §4.2). +/// Delete a remote file or directory. Allocates a transfer id so the normal +/// transfer-progress channel can also report delete progress to the owning tab. #[tauri::command] #[specta::specta] pub fn sftp_delete( @@ -211,7 +212,11 @@ pub fn sftp_delete( session_id: u64, path: String, ) -> Result<(), CommandError> { - state.send_sftp(session_id, SftpCommand::Delete(path)); + let transfer_id = state.next_transfer(session_id); + state.send_sftp( + session_id, + SftpCommand::DeleteWithProgress { path, transfer_id }, + ); Ok(()) } @@ -315,8 +320,9 @@ pub async fn local_rename(from: String, to: String) -> Result<(), CommandError> }) } -/// Delete a local file or directory recursively (tech-gui.md §4.2). Matches the -/// remote delete behaviour so the confirmation dialog is shared by both panes. +/// Delete exactly one local filesystem entry. Directories are removed only when +/// already empty; the SFTP view enumerates directory contents and deletes entries +/// one-by-one so cancellation can happen between entries. #[tauri::command] #[specta::specta] pub async fn local_delete(path: String) -> Result<(), CommandError> { @@ -325,7 +331,7 @@ pub async fn local_delete(path: String) -> Result<(), CommandError> { })?; if meta.is_dir() { - tokio::fs::remove_dir_all(path) + tokio::fs::remove_dir(path) .await .map_err(|e| CommandError { message: e.to_string(), diff --git a/crates/omnyssh-gui/ui/src/lib/bindings.ts b/crates/omnyssh-gui/ui/src/lib/bindings.ts index 9dcf9d51..77f83465 100644 --- a/crates/omnyssh-gui/ui/src/lib/bindings.ts +++ b/crates/omnyssh-gui/ui/src/lib/bindings.ts @@ -269,7 +269,8 @@ async sftpRename(sessionId: number, from: string, to: string) : Promise> { try { @@ -392,8 +393,9 @@ async listLocalRoots() : Promise { return await TAURI_INVOKE("list_local_roots"); }, /** - * Delete a local file or directory recursively (tech-gui.md §4.2). Matches the - * remote delete behaviour so the confirmation dialog is shared by both panes. + * Delete exactly one local filesystem entry. Directories are removed only when + * already empty; the SFTP view enumerates directory contents and deletes entries + * one-by-one so cancellation can happen between entries. */ async localDelete(path: string) : Promise> { try { diff --git a/crates/omnyssh-gui/ui/src/lib/screens/SftpView.svelte b/crates/omnyssh-gui/ui/src/lib/screens/SftpView.svelte index d806691b..554bb252 100644 --- a/crates/omnyssh-gui/ui/src/lib/screens/SftpView.svelte +++ b/crates/omnyssh-gui/ui/src/lib/screens/SftpView.svelte @@ -59,6 +59,13 @@ items: FileEntryDto[]; } | null>(null); + let localDeleteProgress = $state<{ + currentFile: string; + filesDone: number; + filesTotal: number; + } | null>(null); + let localDeleteCancelRequested = false; + let activeEditorSide = $state('remote'); let mkdirDialog = $state<{ @@ -278,34 +285,120 @@ deleteDialog = { side, items }; } + async function collectLocalDeleteTargets( + path: string, + targets: Array<{ path: string; name: string; isDir: boolean }>, + ): Promise { + const entries = await listLocalDir(path); + + for (const entry of entries) { + if (localDeleteCancelRequested) return; + if (entry.name === '..') continue; + + if (entry.isDir) { + await collectLocalDeleteTargets(entry.path, targets); + } else { + targets.push({ path: entry.path, name: entry.name, isDir: false }); + } + } + + // Remove directories after their children so cancellation can stop between entries. + targets.push({ + path, + name: path.split(/[\\/]/).filter(Boolean).pop() ?? path, + isDir: true + }); + } + + async function deleteLocalCancellable(items: FileEntryDto[]): Promise { + const targets: Array<{ path: string; name: string; isDir: boolean }> = []; + + localDeleteProgress = { + currentFile: 'Preparing deletion…', + filesDone: 0, + filesTotal: 0 + }; + + for (const item of items) { + if (localDeleteCancelRequested) return; + + if (item.isDir) { + await collectLocalDeleteTargets(item.path, targets); + } else { + targets.push({ path: item.path, name: item.name, isDir: false }); + } + } + + if (targets.length === 0) return; + + localDeleteProgress = { + currentFile: targets[0].name, + filesDone: 0, + filesTotal: targets.length + }; + + for (const target of targets) { + if (localDeleteCancelRequested) break; + + localDeleteProgress = { + currentFile: target.name, + filesDone: localDeleteProgress?.filesDone ?? 0, + filesTotal: targets.length + }; + + await localDelete(target.path); + + localDeleteProgress = { + currentFile: target.name, + filesDone: (localDeleteProgress?.filesDone ?? 0) + 1, + filesTotal: targets.length + }; + } + } + async function confirmDelete() { - if (!view || !deleteDialog) return; + if (!view || !deleteDialog) return; + + const { side, items } = deleteDialog; - const { side, items } = deleteDialog; + if (side === 'local') { + deleteDialog = null; + localDeleteCancelRequested = false; - for (const item of items) { - if (side === 'remote') { - sftp.pushOp(session.id, { + try { + await deleteLocalCancellable(items); + await refreshLocal(view.local.path); + } catch (err) { + lastError.set(errMsg(err)); + } finally { + localDeleteProgress = null; + } + return; + } + + const id = backendId; + if (id == null) return; + + for (const item of items) { + sftp.pushOp(id, { kind: 'delete', name: item.name, refresh: 'remote' }); - await sftpDelete(session.id, item.path); - } else { - await localDelete(item.path); + await sftpDelete(id, item.path); } - } - - if (side === 'local') { - await refreshLocal(view.local.path); - } - deleteDialog = null; + deleteDialog = null; } function cancelDelete() { - deleteDialog = null; + if (localDeleteProgress) { + localDeleteCancelRequested = true; + return; + } + + deleteDialog = null; } function startSplitDrag() { @@ -1067,7 +1160,7 @@ async function uploadExternalEdit(): Promise {
{#if transfer.stage === 'preparing'}
- Preparing {transfer.kind}… + {transfer.kind === 'delete' ? 'Preparing delete…' : `Preparing ${transfer.kind}…`}
@@ -1079,7 +1172,11 @@ async function uploadExternalEdit(): Promise {
{:else}
- {transfer.kind === 'upload' ? 'Uploading' : 'Downloading'} + {transfer.kind === 'upload' + ? 'Uploading' + : transfer.kind === 'download' + ? 'Downloading' + : 'Deleting'} {transfer.rootName}
@@ -1092,6 +1189,10 @@ async function uploadExternalEdit(): Promise {
{#if transfer.stage === 'preparing'}
Scanning…
+ {:else if transfer.kind === 'delete'} +
+ {transfer.filesDone} / {transfer.filesTotal} items +
{:else}
{transfer.filesDone} / {transfer.filesTotal} files @@ -1100,17 +1201,18 @@ async function uploadExternalEdit(): Promise {
{formatBytes(transfer.bytesDone)} / {formatBytes(transfer.bytesTotal)}
- - {/if} + +
@@ -1331,6 +1433,40 @@ async function uploadExternalEdit(): Promise {
{/if} +{#if localDeleteProgress} +
+
+

Deleting local files

+

{localDeleteProgress.currentFile}

+ + {#if localDeleteProgress.filesTotal > 0} +
+ {localDeleteProgress.filesDone} / {localDeleteProgress.filesTotal} + {Math.round((localDeleteProgress.filesDone / localDeleteProgress.filesTotal) * 100)}% +
+
+
+
+ {:else} +

Scanning directory contents…

+ {/if} + +
+ +
+
+
+{/if} + {#if mkdirDialog}
diff --git a/crates/omnyssh-gui/ui/src/lib/stores/sftp.test.ts b/crates/omnyssh-gui/ui/src/lib/stores/sftp.test.ts index 246805bf..7617f41c 100644 --- a/crates/omnyssh-gui/ui/src/lib/stores/sftp.test.ts +++ b/crates/omnyssh-gui/ui/src/lib/stores/sftp.test.ts @@ -92,6 +92,29 @@ describe("sftp reducers", () => { }); }); + it("applyProgress accepts delete progress and exposes item counts", () => { + const s: SftpSession = { + ...newSession("web-1"), + pending: [{ kind: "delete", name: "logs", refresh: "remote" }], + }; + + const next = applyProgress(s, { + sessionId: 1, + transferId: 12, + rootName: "logs", + currentFile: "old.log", + stage: "transferring", + bytesDone: 3, + bytesTotal: 10, + filesDone: 3, + filesTotal: 10, + }); + + expect(next.transfer?.kind).toBe("delete"); + expect(next.transfer?.filesDone).toBe(3); + expect(next.transfer?.filesTotal).toBe(10); + }); + it("applyProgress ignores a tick when the front op is not a transfer", () => { const s: SftpSession = { ...newSession("web-1"), @@ -141,6 +164,26 @@ describe("sftp reducers", () => { expect(next.error).toBeUndefined(); }); + it("applyOpDone clears delete progress on completion", () => { + const s: SftpSession = { + ...newSession("web-1"), + pending: [{ kind: "delete", name: "logs", refresh: "remote" }], + transfer: { + transferId: 12, + kind: "delete", + rootName: "logs", + currentFile: "old.log", + stage: "transferring", + bytesDone: 10, + bytesTotal: 10, + filesDone: 10, + filesTotal: 10, + }, + }; + + expect(applyOpDone(s, true).transfer).toBeUndefined(); + }); + it("applyOpDone surfaces the error message on failure", () => { const s: SftpSession = { ...newSession("web-1"), diff --git a/crates/omnyssh-gui/ui/src/lib/stores/sftp.ts b/crates/omnyssh-gui/ui/src/lib/stores/sftp.ts index 99afa757..015d5416 100644 --- a/crates/omnyssh-gui/ui/src/lib/stores/sftp.ts +++ b/crates/omnyssh-gui/ui/src/lib/stores/sftp.ts @@ -24,7 +24,7 @@ export interface Pane { interface Transfer { transferId: number; stage: "preparing" | "transferring"; - kind: "upload" | "download"; + kind: "upload" | "download" | "delete"; rootName: string; currentFile: string; bytesDone: number; @@ -146,7 +146,7 @@ export function applyProgress( ): SftpSession { const front = session.pending[0]; - if (!front || (front.kind !== "upload" && front.kind !== "download")) { + if (!front || (front.kind !== "upload" && front.kind !== "download" && front.kind !== "delete")) { return session; } @@ -175,7 +175,16 @@ export function applyOpDone( ): SftpSession { if (session.pending.length === 0) return session; const [front, ...rest] = session.pending; - const wasTransfer = front.kind === "upload" || front.kind === "download"; + const wasTransfer = + front.kind === "upload" || front.kind === "download" || front.kind === "delete"; + const displayError = + !ok && + front.kind === "delete" && + /transfer\s+cancelled/i.test(error ?? "") + ? "Delete canceled" + : ok + ? session.error + : (error ?? "Operation failed"); return { ...session, pending: rest, @@ -183,7 +192,7 @@ export function applyOpDone( // A later op's success must NOT wipe an earlier op's failure in the same batch — that // silently masks e.g. a non-empty-folder delete beside a deleted sibling. The error // persists until the next batch clears it (`clearError`, called on enqueue). - error: ok ? session.error : (error ?? "Operation failed"), + error: displayError, transfer: wasTransfer ? undefined : session.transfer, }; } From 3e8f9a7e65b11e3cb0349cac9ad4f16f71974928 Mon Sep 17 00:00:00 2001 From: Tim Hartmann Date: Thu, 1 Oct 2026 11:55:57 +0400 Subject: [PATCH 3/8] fix(core): read CPU, RAM, load and top processes on FreeBSD --- crates/omnyssh-core/src/ssh/metrics.rs | 192 ++++++++++++++++++++++++- crates/omnyssh-core/src/ssh/pool.rs | 74 +++++++--- 2 files changed, 244 insertions(+), 22 deletions(-) diff --git a/crates/omnyssh-core/src/ssh/metrics.rs b/crates/omnyssh-core/src/ssh/metrics.rs index 03d2fc03..52a7e4d4 100644 --- a/crates/omnyssh-core/src/ssh/metrics.rs +++ b/crates/omnyssh-core/src/ssh/metrics.rs @@ -6,11 +6,11 @@ //! renders as "N/A". //! //! Commands used per OS family: -//! - CPU: `top -bn1` (Linux) / `top -l 1 -n 0` (macOS) -//! - RAM: `free -b` (Linux) / `vm_stat` + `sysctl hw.memsize` (macOS) +//! - CPU: `top -bn1` (Linux) / `top -l 1 -n 0` (macOS) / `sysctl kern.cp_time` (FreeBSD) +//! - RAM: `free -b` (Linux) / `vm_stat` + `sysctl hw.memsize` (macOS) / `sysctl` (FreeBSD) //! - Disk: `df -k /` //! - Uptime: `uptime` -//! - Load: `cat /proc/loadavg` (Linux only) +//! - Load: `cat /proc/loadavg` (Linux), else the tail of `uptime` //! //! Severity thresholds: //! Ok < 60 % @@ -186,6 +186,33 @@ pub fn parse_cpu_proc_stat(output: &str) -> Option { Some(((total - idle) as f64 / total as f64 * 100.0).clamp(0.0, 100.0)) } +/// Parse CPU usage from two FreeBSD `kern.cp_time` samples taken a moment apart. +/// +/// Each line holds the cumulative ticks `user nice sys intr idle`. One sample only +/// gives the average since boot, so the busy share is taken over the difference. +/// The counters are C `long`s, printed signed: on a 32-bit kernel they turn +/// negative and wrap at 2^32. +pub fn parse_cpu_cp_time(output: &str) -> Option { + let mut samples = output.lines().filter_map(|line| { + let ticks: Vec = line + .split_whitespace() + .map(|t| t.parse().ok()) + .collect::>()?; + (ticks.len() == 5).then_some(ticks) + }); + let (first, second) = (samples.next()?, samples.next()?); + let delta: Vec = second + .iter() + .zip(&first) + .map(|(now, then)| (now - then).rem_euclid(1 << 32) as u64) + .collect(); + let total: u64 = delta.iter().sum(); + if total == 0 { + return None; + } + Some(((total - delta[4]) as f64 / total as f64 * 100.0).clamp(0.0, 100.0)) +} + // --------------------------------------------------------------------------- // RAM parsers // --------------------------------------------------------------------------- @@ -258,6 +285,37 @@ pub fn parse_ram_vmstat(vm_stat_output: &str, memsize_output: &str) -> Option Option { + let value = |key: &str| -> Option { + output.lines().find_map(|line| { + let (name, value) = line.split_once(':')?; + if name.trim() == key { + value.trim().parse().ok() + } else { + None + } + }) + }; + let total = value("vm.stats.vm.v_page_count")?; + let page_size = value("hw.pagesize")?; + if total == 0.0 || page_size == 0.0 { + return None; + } + // Absent on a host without ZFS. + let arc = value("kstat.zfs.misc.arcstats.size").unwrap_or(0.0) + - value("kstat.zfs.misc.arcstats.c_min").unwrap_or(0.0); + let available = value("vm.stats.vm.v_free_count")? + + value("vm.stats.vm.v_inactive_count")? + + (value("vfs.bufspace").unwrap_or(0.0) + arc.max(0.0)) / page_size; + Some(((total - available) / total * 100.0).clamp(0.0, 100.0)) +} + fn parse_vmstat_line(line: &str, prefix: &str) -> Option { if line.trim_start().starts_with(prefix) { line.split(':') @@ -359,6 +417,27 @@ pub fn parse_loadavg(output: &str) -> Option { } } +/// Extract load averages from `uptime` output, for hosts without `/proc/loadavg`. +/// +/// Linux and FreeBSD print `load average(s): 0.44, 0.49, 0.27`; macOS separates +/// the values with spaces only. +pub fn parse_loadavg_uptime(output: &str) -> Option { + let line = normalize_decimal_commas(output.lines().next()?); + let (_, tail) = line.split_once("load average")?; + let values: Vec<&str> = tail + .split_once(':')? + .1 + .split([',', ' ']) + .filter(|v| !v.is_empty()) + .take(3) + .collect(); + if values.len() == 3 && values.iter().all(|v| v.parse::().is_ok()) { + Some(values.join(" ")) + } else { + None + } +} + // --------------------------------------------------------------------------- // Process list parser // --------------------------------------------------------------------------- @@ -461,11 +540,44 @@ mod tests { assert!(result > 0.0 && result < 100.0, "got {result}"); } + #[test] + fn test_cpu_cp_time_freebsd_takes_the_delta() { + // FreeBSD 14.5, idle: one sample alone would read 4.4 % (since boot). + let out = "3235 2 4258 45 162316\n3237 2 4259 45 162586\n"; + let result = parse_cpu_cp_time(out).expect("should parse"); + assert!((result - 1.1).abs() < 0.05, "got {result}"); + } + + #[test] + fn test_cpu_cp_time_freebsd_one_of_two_cores_busy() { + // `yes` on one of two cores; top read 50.0 % idle. + let out = "8513 2 5182 52 187738\n8621 2 5204 52 187866\n"; + let result = parse_cpu_cp_time(out).expect("should parse"); + assert!((result - 50.4).abs() < 0.05, "got {result}"); + } + + #[test] + fn test_cpu_cp_time_32_bit_counters_wrap() { + // A 32-bit kernel prints the counters signed; idle crossed 2^31 here. + let out = "8513 2 5182 52 2147483600\n8621 2 5204 52 -2147483568\n"; + let result = parse_cpu_cp_time(out).expect("should parse"); + assert!((result - 50.4).abs() < 0.05, "got {result}"); + } + + #[test] + fn test_cpu_cp_time_rejects_one_sample_or_other_shapes() { + assert!(parse_cpu_cp_time("3235 2 4258 45 162316\n").is_none()); + assert!(parse_cpu_cp_time("3235 2 4258 45 162316\n3235 2 4258 45 162316\n").is_none()); + // OpenBSD's six comma-separated states are not FreeBSD's five. + assert!(parse_cpu_cp_time("1,0,2,0,0,9\n2,0,3,0,0,19\n").is_none()); + } + #[test] fn test_cpu_empty_returns_none() { assert!(parse_cpu_top("").is_none()); assert!(parse_cpu_top_macos("").is_none()); assert!(parse_cpu_proc_stat("").is_none()); + assert!(parse_cpu_cp_time("").is_none()); } #[test] @@ -501,6 +613,44 @@ mod tests { fn test_ram_empty_returns_none() { assert!(parse_ram_free("").is_none()); assert!(parse_ram_vmstat("", "").is_none()); + assert!(parse_ram_sysctl("").is_none()); + } + + #[test] + fn test_ram_sysctl_freebsd_ufs() { + // FreeBSD 14.5 without ZFS: the unknown kstat names print nothing. + let out = "hw.pagesize: 4096\n\ + vm.stats.vm.v_page_count: 504349\n\ + vm.stats.vm.v_free_count: 475781\n\ + vm.stats.vm.v_inactive_count: 489\n\ + vfs.bufspace: 20492800\n"; + let result = parse_ram_sysctl(out).expect("should parse"); + // (504349 - 475781 - 489 - 5003) / 504349 + assert!((result - 4.58).abs() < 0.01, "got {result}"); + } + + #[test] + fn test_ram_sysctl_freebsd_counts_the_arc_above_its_floor_as_available() { + // FreeBSD 14.5 with a 518M ARC; top: 11M Active, 329M Inact, 863M Wired, + // 201M Buf, 767M Free. + let out = "hw.pagesize: 4096\n\ + vm.stats.vm.v_page_count: 504349\n\ + vm.stats.vm.v_free_count: 196859\n\ + vm.stats.vm.v_inactive_count: 84151\n\ + vfs.bufspace: 210890752\n\ + kstat.zfs.misc.arcstats.size: 543477304\n\ + kstat.zfs.misc.arcstats.c_min: 66203136\n"; + let result = parse_ram_sysctl(out).expect("should parse"); + assert!((result - 10.97).abs() < 0.01, "got {result}"); + } + + #[test] + fn test_ram_sysctl_needs_the_page_counters() { + assert!( + parse_ram_sysctl("hw.pagesize: 4096\nvm.stats.vm.v_page_count: 504349\n").is_none() + ); + // OpenBSD knows the page size but not FreeBSD's vm.stats tree. + assert!(parse_ram_sysctl("hw.pagesize: 4096\nhw.physmem: 2147483648\n").is_none()); } #[test] @@ -580,6 +730,32 @@ mod tests { assert_eq!(result, "0.15 0.10 0.08"); } + #[test] + fn test_loadavg_uptime() { + let freebsd = " 6:29AM up 11 mins, 1 user, load averages: 0.35, 0.38, 0.27"; + assert_eq!( + parse_loadavg_uptime(freebsd).as_deref(), + Some("0.35 0.38 0.27") + ); + let linux = " 14:23:45 up 2 days, 3:45, 2 users, load average: 0.15, 0.10, 0.08"; + assert_eq!( + parse_loadavg_uptime(linux).as_deref(), + Some("0.15 0.10 0.08") + ); + let macos = "14:23 up 2 days, 3:45, 2 users, load averages: 1.52 1.74 1.89"; + assert_eq!( + parse_loadavg_uptime(macos).as_deref(), + Some("1.52 1.74 1.89") + ); + let comma = " 14:23:45 up 2 days, 2 users, load average: 0,15, 0,10, 0,08"; + assert_eq!( + parse_loadavg_uptime(comma).as_deref(), + Some("0.15 0.10 0.08") + ); + assert!(parse_loadavg_uptime("").is_none()); + assert!(parse_loadavg_uptime(" 6:29AM up 11 mins, 1 user").is_none()); + } + // ---- Top processes ---- #[test] @@ -642,6 +818,16 @@ mod tests { assert_eq!(procs[1].name, "sshd"); } + #[test] + fn test_top_processes_freebsd() { + // FreeBSD 14.5 `ps -Ac -o pid= ... -r` after the collector's awk filter. + let out = "0.4 0.4 sshd\n0.1 0.0 intr\n0.0 0.1 kernel\n"; + let procs = parse_top_processes(out).expect("should parse"); + assert_eq!(procs.len(), 3); + assert_eq!(procs[0].name, "sshd"); + assert!((procs[0].cpu_percent - 0.4).abs() < 0.01); + } + #[test] fn test_top_processes_empty_returns_none() { assert!(parse_top_processes("").is_none()); diff --git a/crates/omnyssh-core/src/ssh/pool.rs b/crates/omnyssh-core/src/ssh/pool.rs index 2617c959..ca328861 100644 --- a/crates/omnyssh-core/src/ssh/pool.rs +++ b/crates/omnyssh-core/src/ssh/pool.rs @@ -22,8 +22,9 @@ use crate::event::{CoreEvent, Metrics, ProcessInfo}; use crate::ssh::client::{ConnectionStatus, Host, MonitorMode}; use crate::ssh::identity; use crate::ssh::metrics::{ - parse_cpu_proc_stat, parse_cpu_top, parse_cpu_top_macos, parse_disk_df, parse_loadavg, - parse_ram_free, parse_ram_vmstat, parse_top_processes, parse_uptime, + parse_cpu_cp_time, parse_cpu_proc_stat, parse_cpu_top, parse_cpu_top_macos, parse_disk_df, + parse_loadavg, parse_loadavg_uptime, parse_ram_free, parse_ram_sysctl, parse_ram_vmstat, + parse_top_processes, parse_uptime, }; use crate::ssh::password; use crate::ssh::session::{dial_error, passphrase_required, waiting_login, SshSession}; @@ -43,10 +44,22 @@ const BACKOFF_SECS: [u64; 4] = [30, 60, 120, 300]; // read as garbage or not at all. `env` rather than a `VAR=value cmd` prefix, which // is not valid csh/tcsh syntax. const CPU_CMD: &str = "env LC_ALL=C top -bn1 2>/dev/null | head -5"; -const MEM_CMD: &str = "env LC_ALL=C free -b 2>/dev/null || env LC_ALL=C vm_stat 2>/dev/null"; +// FreeBSD has neither `free` nor `vm_stat`; its page counters come last so no +// other host pays for them. +const MEM_CMD: &str = "env LC_ALL=C free -b 2>/dev/null || env LC_ALL=C vm_stat 2>/dev/null || \ + env LC_ALL=C sysctl hw.pagesize vm.stats.vm.v_page_count vm.stats.vm.v_free_count \ + vm.stats.vm.v_inactive_count vfs.bufspace kstat.zfs.misc.arcstats.size \ + kstat.zfs.misc.arcstats.c_min 2>/dev/null"; const DISK_CMD: &str = "env LC_ALL=C df -k / 2>/dev/null"; const UPTIME_CMD: &str = "env LC_ALL=C uptime 2>/dev/null"; const CPU_MACOS_CMD: &str = "env LC_ALL=C top -l 1 -n 0 2>/dev/null | grep 'CPU usage'"; +// FreeBSD's top takes neither `-bn1` nor `-l`, and its first screen averages +// since boot: sample the tick counters a second apart instead. Only there: +// OpenBSD has the counters too, in another form, and would wait for nothing. +const CPU_FREEBSD_CMD: &str = + "env LC_ALL=C sysctl -n kern.ostype 2>/dev/null | grep -qx FreeBSD && \ + env LC_ALL=C sysctl -n kern.cp_time 2>/dev/null && sleep 1 && \ + env LC_ALL=C sysctl -n kern.cp_time 2>/dev/null"; // `ps` output reaches the user, so keep the host's LC_CTYPE: under a full // `LC_ALL=C` GNU ps replaces every non-ASCII byte of a process name with '?'. const PS_LOCALE: &str = "env LC_ALL= LC_NUMERIC=C LC_MESSAGES=C"; @@ -459,7 +472,7 @@ async fn collect_metrics(session: &SshSession, host_name: &str) -> anyhow::Resul let uptime = parse_uptime(&uptime_str); - let load_avg = parse_loadavg(&loadavg_str); + let load_avg = parse_loadavg(&loadavg_str).or_else(|| parse_loadavg_uptime(&uptime_str)); let top_processes = collect_top_processes(session).await; @@ -478,7 +491,7 @@ async fn collect_metrics(session: &SshSession, host_name: &str) -> anyhow::Resul /// Collects the top 3 processes by CPU usage. /// /// Tries GNU `ps` (Linux) with a server-side sort first, then falls back to -/// BSD `ps` (macOS). Returns `None` when neither variant yields usable output. +/// BSD `ps` (macOS, FreeBSD). Returns `None` when neither variant yields usable output. async fn collect_top_processes(session: &SshSession) -> Option> { // Linux: GNU ps with server-side sort by CPU; empty `=` headers suppressed. let linux_out = session @@ -490,14 +503,15 @@ async fn collect_top_processes(session: &SshSession) -> Option> if let Some(procs) = parse_top_processes(&linux_out) { return Some(procs); } - // macOS: BSD ps sorted by CPU usage (-r). - let macos_out = session + // macOS and FreeBSD: BSD ps sorted by CPU usage (-r). One `-o` per column: + // FreeBSD reads everything after the first `=` as that column's header. + let bsd_out = session .run_command(&top_processes_command( - "-Aceo pid=,ppid=,pcpu=,pmem=,comm= -r", + "-Ac -o pid= -o ppid= -o pcpu= -o pmem= -o comm= -r", )) .await .unwrap_or_default(); - parse_top_processes(&macos_out) + parse_top_processes(&bsd_out) } /// Builds the remote shell command that lists the top processes by CPU with @@ -513,15 +527,16 @@ async fn collect_top_processes(session: &SshSession) -> Option> /// - `g` — the privileged `sshd` one level up (parent of `$PPID`). /// /// Filtering is by PID only, never by process name, so a genuinely busy SSH -/// session belonging to another user still appears. POSIX-sh syntax — a -/// non-Bourne login shell simply yields no output and the panel degrades to -/// "process data unavailable". +/// session belonging to another user still appears. The one exception is +/// FreeBSD's kernel `idle` (parent 0), which `ps` charges with all idle time. +/// POSIX-sh syntax — a non-Bourne login shell simply yields no output and the +/// panel degrades to "process data unavailable". fn top_processes_command(ps_args: &str) -> String { format!( "g=$({PS_LOCALE} ps -o ppid= -p $PPID 2>/dev/null | tr -d ' '); \ {PS_LOCALE} ps {ps_args} 2>/dev/null | \ awk -v s=$$ -v p=$PPID -v g=\"$g\" \ - '$1!=s && $1!=p && $1!=g && $2!=s && $2!=p \ + '$1!=s && $1!=p && $1!=g && $2!=s && $2!=p && !($2==0 && $5==\"idle\") \ {{$1=\"\";$2=\"\";sub(/^[ \\t]+/,\"\");print}}' | \ head -n 3" ) @@ -537,6 +552,13 @@ async fn parse_cpu_combined(top_out: &str, session: &SshSession) -> Option if let Some(v) = parse_cpu_top_macos(&macos_out) { return Some(v); } + let freebsd_out = session + .run_command(CPU_FREEBSD_CMD) + .await + .unwrap_or_default(); + if let Some(v) = parse_cpu_cp_time(&freebsd_out) { + return Some(v); + } // Fall back to /proc/stat. let stat_out = session .run_command("head -1 /proc/stat 2>/dev/null") @@ -558,7 +580,7 @@ async fn parse_ram_combined(mem_out: &str, session: &SshSession) -> Option .unwrap_or_default(); return parse_ram_vmstat(mem_out, &memsize_out); } - None + parse_ram_sysctl(mem_out) } #[cfg(test)] @@ -623,11 +645,19 @@ mod tests { #[test] fn metric_commands_pin_the_locale() { - for cmd in [CPU_CMD, MEM_CMD, DISK_CMD, UPTIME_CMD, CPU_MACOS_CMD] { + for cmd in [ + CPU_CMD, + MEM_CMD, + DISK_CMD, + UPTIME_CMD, + CPU_MACOS_CMD, + CPU_FREEBSD_CMD, + ] { assert!(cmd.starts_with("env LC_ALL=C "), "unpinned command: {cmd}"); } - // The `||` fallback needs the prefix on both sides. - assert_eq!(MEM_CMD.matches("env LC_ALL=C ").count(), 2); + // Every `||` fallback and both samples need the prefix too. + assert_eq!(MEM_CMD.matches("env LC_ALL=C ").count(), 3); + assert_eq!(CPU_FREEBSD_CMD.matches("env LC_ALL=C ").count(), 3); } #[test] @@ -669,7 +699,13 @@ mod tests { let linux = top_processes_command("-eo pid=,ppid=,pcpu=,pmem=,comm= --sort=-pcpu"); assert!(linux.contains("ps -eo pid=,ppid=,pcpu=,pmem=,comm= --sort=-pcpu 2>/dev/null")); - let macos = top_processes_command("-Aceo pid=,ppid=,pcpu=,pmem=,comm= -r"); - assert!(macos.contains("ps -Aceo pid=,ppid=,pcpu=,pmem=,comm= -r 2>/dev/null")); + let bsd = top_processes_command("-Ac -o pid= -o ppid= -o pcpu= -o pmem= -o comm= -r"); + assert!(bsd.contains("ps -Ac -o pid= -o ppid= -o pcpu= -o pmem= -o comm= -r 2>/dev/null")); + } + + #[test] + fn top_processes_command_drops_only_the_kernel_idle_process() { + let cmd = top_processes_command("-Ac -o pid= -o ppid= -o pcpu= -o pmem= -o comm= -r"); + assert!(cmd.contains("!($2==0 && $5==\"idle\")")); } } From c7e87e480d06fdf890213e23c0d9a8de601638e3 Mon Sep 17 00:00:00 2001 From: Tim Hartmann Date: Thu, 1 Oct 2026 11:56:04 +0400 Subject: [PATCH 4/8] fix(core): offer group exchange and the ssh-rsa host key last --- crates/omnyssh-core/src/ssh/known_hosts.rs | 38 +++++++++++++++++++--- crates/omnyssh-core/src/ssh/session.rs | 5 +++ 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/crates/omnyssh-core/src/ssh/known_hosts.rs b/crates/omnyssh-core/src/ssh/known_hosts.rs index 3cd2ce74..20865b69 100644 --- a/crates/omnyssh-core/src/ssh/known_hosts.rs +++ b/crates/omnyssh-core/src/ssh/known_hosts.rs @@ -107,7 +107,8 @@ pub(crate) fn learn(host: &str, port: u16, key: &PublicKey) -> Result<(), russh: /// The host key algorithms asked for when nothing is pinned: russh 0.46's list, /// which OmnySSH shipped with, then P-384, last so that no server that worked /// shows another key; some devices have no other (Cisco RoomOS set to ECDSA). -/// Never `ssh-rsa` (SHA-1). +/// `ssh-rsa` (SHA-1) only after all of them, for servers before OpenSSH 7.2 +/// that have nothing else (RHEL 6). const KEY_ORDER: &[Algorithm] = &[ Algorithm::Ed25519, Algorithm::Ecdsa { @@ -125,6 +126,7 @@ const KEY_ORDER: &[Algorithm] = &[ Algorithm::Ecdsa { curve: EcdsaCurve::NistP384, }, + Algorithm::Rsa { hash: None }, ]; /// Host key algorithms for `host:port`, those of the keys saved for it first, @@ -145,6 +147,8 @@ fn preferred_in(files: &[PathBuf], host: &str, port: u16) -> Cow<'static, [Algor let (mut order, rest): (Vec, Vec) = KEY_ORDER .iter() .cloned() + // A saved RSA key brings ssh-rsa along, after both SHA-2 forms: an + // OpenSSH before 7.2 shows that key no other way. .partition(|algo| saved.iter().any(|k| signs_with(k, algo))); order.extend(rest); Cow::Owned(order) @@ -359,12 +363,38 @@ mod tests { } #[test] - fn nothing_pinned_asks_for_p384_last_and_no_sha1() { + fn nothing_pinned_asks_for_p384_then_sha1_last() { let p384 = Algorithm::Ecdsa { curve: EcdsaCurve::NistP384, }; - assert_eq!(KEY_ORDER.last(), Some(&p384)); - assert!(!KEY_ORDER.contains(&Algorithm::Rsa { hash: None })); + assert_eq!( + KEY_ORDER[KEY_ORDER.len() - 2..], + [p384, Algorithm::Rsa { hash: None }] + ); + } + + #[test] + fn an_rsa_pin_asks_for_sha2_then_sha1() { + let mut rng = russh::keys::key::safe_rng(); + let pair = RsaKeypair::random(&mut rng, 2048).expect("rsa key"); + let pinned = PrivateKey::from(pair).public_key().clone(); + let dir = tempfile::tempdir().unwrap(); + let file = dir.path().join("known_hosts"); + write(&file, &[line("vm", &pinned)]); + let order = preferred_in(&[file], "vm", 22); + assert_eq!( + order[..3], + [ + Algorithm::Rsa { + hash: Some(HashAlg::Sha256) + }, + Algorithm::Rsa { + hash: Some(HashAlg::Sha512) + }, + Algorithm::Rsa { hash: None }, + ] + ); + assert_eq!(order[3], Algorithm::Ed25519); } #[test] diff --git a/crates/omnyssh-core/src/ssh/session.rs b/crates/omnyssh-core/src/ssh/session.rs index 770a3e8b..6c715864 100644 --- a/crates/omnyssh-core/src/ssh/session.rs +++ b/crates/omnyssh-core/src/ssh/session.rs @@ -783,6 +783,8 @@ const KEX_ORDER: &[kex::Name] = &[ kex::ECDH_SHA2_NISTP256, kex::ECDH_SHA2_NISTP384, kex::ECDH_SHA2_NISTP521, + // The one method without SHA-1 that OpenSSH before 5.7 has (RHEL 6). + kex::DH_GEX_SHA256, kex::EXTENSION_SUPPORT_AS_CLIENT, kex::EXTENSION_SUPPORT_AS_SERVER, kex::EXTENSION_OPENSSH_STRICT_KEX_AS_CLIENT, @@ -826,6 +828,9 @@ fn client_config(host: &Host) -> Arc { compression: Cow::Borrowed(COMPRESSION_ORDER), ..russh::Preferred::DEFAULT }, + // russh asks for 8192 bits, whose exponentiations stall the connecting + // task for up to seconds; 4096 is group16's size. + gex: client::GexParams::new(2048, 4096, 8192).expect("valid group sizes"), // No inactivity timeout: russh skips resetting it on the iteration that // sends a keepalive, so a peer that never answers `keepalive@openssh.com` // (common in appliance SSH stacks) was torn down after 30 s even while From f6185753f51c80ee35a46994e46e7daf1796f09a Mon Sep 17 00:00:00 2001 From: Tim Hartmann Date: Thu, 1 Oct 2026 11:56:11 +0400 Subject: [PATCH 5/8] fix(core): sign RSA keys with SHA-1 for servers that take nothing else --- crates/omnyssh-core/src/ssh/session.rs | 155 ++++++++++++++++++++++--- 1 file changed, 137 insertions(+), 18 deletions(-) diff --git a/crates/omnyssh-core/src/ssh/session.rs b/crates/omnyssh-core/src/ssh/session.rs index 6c715864..ef05f99f 100644 --- a/crates/omnyssh-core/src/ssh/session.rs +++ b/crates/omnyssh-core/src/ssh/session.rs @@ -59,6 +59,8 @@ pub(crate) struct KnownHostsHandler { /// Set when the server leaves no login method OmnySSH has: russh's /// NoAuthMethod on disconnect, or a refused key that names only others. no_method: Arc, + /// What the server's version string says about RSA key signatures. + rsa_sigs: Arc>, /// The fingerprint of a host key first seen, and recorded, on this connection. new_key: Arc>>, /// Why the host key was turned down, for the user; russh itself only says @@ -78,6 +80,7 @@ pub(crate) struct KnownHostsHandler { struct Link { hung_up: Arc, no_method: Arc, + rsa_sigs: Arc>, new_key: Arc>>, refusal: Arc>>, /// Changes (to closed) once the session is over. @@ -177,6 +180,19 @@ fn no_common_algorithm(kind: &russh::AlgorithmKind, theirs: &[String]) -> String impl client::Handler for KnownHostsHandler { type Error = russh::Error; + async fn kex_done( + &mut self, + _: Option<&[u8]>, + _: &russh::Names, + session: &mut client::Session, + ) -> Result<(), Self::Error> { + *self + .rsa_sigs + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = rsa_sigs(session.remote_sshid()); + Ok(()) + } + async fn check_server_key( &mut self, server_public_key: &PublicKeyOrCertificate, @@ -1000,6 +1016,7 @@ fn known_hosts_handler(host: &Host, lends_agent: bool) -> (KnownHostsHandler, Li let link = Link { hung_up: Arc::new(AtomicBool::new(false)), no_method: Arc::new(AtomicBool::new(false)), + rsa_sigs: Arc::default(), new_key: Arc::new(Mutex::new(None)), refusal: Arc::new(Mutex::new(None)), ended, @@ -1009,6 +1026,7 @@ fn known_hosts_handler(host: &Host, lends_agent: bool) -> (KnownHostsHandler, Li port: host.port, hung_up: Arc::clone(&link.hung_up), no_method: Arc::clone(&link.no_method), + rsa_sigs: Arc::clone(&link.rsa_sigs), new_key: Arc::clone(&link.new_key), refusal: Arc::clone(&link.refusal), lends_agent, @@ -1033,7 +1051,11 @@ where { let Dialed { handle, link, .. } = first; let asking = matches!(passwords, Passwords::Ask(_)); - let (handle, keys) = authenticate(handle, host, asking, &link.no_method).await?; + let rsa_sigs = *link + .rsa_sigs + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let (handle, keys) = authenticate(handle, host, asking, rsa_sigs, &link.no_method).await?; let encrypted_key = match keys { KeyAuth::Accepted => return Ok(handle), KeyAuth::Rejected { encrypted_key } => encrypted_key, @@ -1356,8 +1378,10 @@ async fn authenticate( handle: Handle, host: &Host, user_started: bool, + rsa_sigs: RsaSigs, no_method: &Arc, ) -> anyhow::Result<(Handle, KeyAuth)> { + let rsa = RsaHash::new(rsa_sigs); let user = host.user.clone(); let mut encrypted_key: Option<(String, bool)> = None; @@ -1370,7 +1394,7 @@ async fn authenticate( only: host.identities_only, }; let (handle, accepted, turned_down, rsa) = - agent_login(handle, &user, user_started, identities, no_method).await?; + agent_login(handle, &user, user_started, identities, rsa, no_method).await?; if accepted { return Ok((handle, KeyAuth::Accepted)); } @@ -1379,7 +1403,7 @@ async fn authenticate( #[cfg(not(unix))] let (turned_down, mut rsa): (Vec, RsaHash) = { let _ = user_started; - (Vec::new(), RsaHash::default()) + (Vec::new(), rsa) }; let mut handle = handle; @@ -1439,13 +1463,23 @@ async fn refused_before(path: &str, turned_down: &[PublicKey]) -> bool { /// The hash an RSA key signs with on one connection: the best the server lists /// in `server-sig-algs`, else rsa-sha2-256, which more servers take (Dropbear -/// has no rsa-sha2-512); never SHA-1. Asked once: a server that lists nothing -/// makes every ask wait a second. -#[derive(Default)] -struct RsaHash(Option); +/// has no rsa-sha2-512). SHA-1 only for a server that lists nothing else, or is +/// too old to list anything, as PuTTY and ssh(1) before 8.8 sign for it. Asked +/// once: a server that lists nothing makes every ask wait a second. +struct RsaHash { + sigs: RsaSigs, + /// Once known; `Some(None)` signs with SHA-1. + hash: Option>, +} impl RsaHash { - /// `None` for a key that is not RSA. + fn new(sigs: RsaSigs) -> Self { + // A server too old for rsa-sha2 sends no server-sig-algs to wait for. + let hash = (sigs == RsaSigs::Sha1).then_some(None); + Self { sigs, hash } + } + + /// `None` for a key that is not RSA, or one signing with SHA-1. async fn for_key( &mut self, handle: &Handle, @@ -1454,12 +1488,57 @@ impl RsaHash { if !key.algorithm().is_rsa() { return None; } - if self.0.is_none() { + if self.hash.is_none() { let listed = time::timeout(AUTH_TIMEOUT, handle.best_supported_rsa_hash()).await; - let listed = listed.ok().and_then(Result::ok).flatten().flatten(); - self.0 = Some(listed.unwrap_or(HashAlg::Sha256)); + self.hash = Some(match listed.ok().and_then(Result::ok).flatten() { + Some(Some(hash)) => Some(hash), + // ssh-rsa alone. + Some(None) if self.sigs != RsaSigs::Sha2Unlisted => None, + _ => Some(HashAlg::Sha256), + }); + } + self.hash.flatten() + } +} + +/// What a server's version string says about RSA key signatures. +#[derive(Clone, Copy, Default, Debug, PartialEq)] +enum RsaSigs { + /// Whatever `server-sig-algs` lists. + #[default] + AsListed, + /// OpenSSH before 7.2 or Dropbear before 2020.79: SHA-1 only. + Sha1, + /// OpenSSH 7.4 lists only ssh-rsa yet takes rsa-sha2, as ssh(1) knows. + Sha2Unlisted, +} + +fn rsa_sigs(ident: &[u8]) -> RsaSigs { + let ident = String::from_utf8_lossy(ident); + let software = ident.splitn(3, '-').nth(2).unwrap_or_default(); + let version = |rest: &str| -> (u32, u32) { + let mut parts = rest.split(|c: char| !c.is_ascii_digit()); + let major = parts + .next() + .and_then(|p| p.parse().ok()) + .unwrap_or(u32::MAX); + let minor = parts.next().and_then(|p| p.parse().ok()).unwrap_or(0); + (major, minor) + }; + if let Some(rest) = software.strip_prefix("OpenSSH_") { + match version(rest) { + v if v < (7, 2) => RsaSigs::Sha1, + (7, 4) => RsaSigs::Sha2Unlisted, + _ => RsaSigs::AsListed, + } + } else if let Some(rest) = software.strip_prefix("dropbear_") { + if version(rest) < (2020, 79) { + RsaSigs::Sha1 + } else { + RsaSigs::AsListed } - self.0 + } else { + RsaSigs::AsListed } } @@ -1620,6 +1699,19 @@ fn refused_agent_keys() -> MutexGuard<'static, HashSet> { .unwrap_or_else(PoisonError::into_inner) } +/// A key's entry in [`refused_agent_keys`]. Some agents never sign with SHA-1, +/// which only old servers ask for, so that refusal is kept apart: other hosts +/// still get the key. +#[cfg(unix)] +fn refusal_entry(key: &PublicKey, hash: Option) -> String { + let fingerprint = fingerprint(key); + if key.algorithm().is_rsa() && hash.is_none() { + format!("{fingerprint} ssh-rsa") + } else { + fingerprint + } +} + /// Runs the agent's keys in a task that owns the connection. A caller that gives /// up mid-signature (a poller restarted, a tunnel stopped) then only detaches; /// the task itself ends within the signing bound. @@ -1631,6 +1723,7 @@ async fn agent_login( user: &str, user_started: bool, identities: Identities, + mut rsa: RsaHash, no_method: &Arc, ) -> anyhow::Result<(Handle, bool, Vec, RsaHash)> { let user = user.to_string(); @@ -1638,7 +1731,6 @@ async fn agent_login( tokio::spawn(async move { let mut handle = handle; let mut turned_down = Vec::new(); - let mut rsa = RsaHash::default(); let accepted = try_agent_auth( &mut handle, &user, @@ -1695,10 +1787,10 @@ async fn try_agent_auth( stalled: Arc::clone(&stalled), }; for pubkey in identities.order(listed) { - if !user_started && refused_agent_keys().contains(&fingerprint(&pubkey)) { + let hash = rsa.for_key(handle, &pubkey).await; + if !user_started && refused_agent_keys().contains(&refusal_entry(&pubkey, hash)) { continue; } - let hash = rsa.for_key(handle, &pubkey).await; // Fresh per key: a refusal must not cut the next key's attempt short. let failed = Arc::new(tokio::sync::Notify::new()); signer.failed = Arc::clone(&failed); @@ -1786,14 +1878,14 @@ impl russh::Signer for AgentSigner { } } } - let fingerprint = fingerprint(&key.public_key()); + let entry = refusal_entry(&key.public_key(), hash_alg); match signed { Some(data) => { - refused_agent_keys().remove(&fingerprint); + refused_agent_keys().remove(&entry); Ok(data) } None => { - refused_agent_keys().insert(fingerprint); + refused_agent_keys().insert(entry); self.refused = true; self.failed.notify_one(); Ok(to_sign) @@ -2216,6 +2308,33 @@ mod tests { ); } + #[test] + fn only_servers_before_rsa_sha2_sign_with_sha1() { + for old in [ + "SSH-2.0-OpenSSH_5.3", + "SSH-2.0-OpenSSH_6.6.1p1 Ubuntu-2ubuntu2", + "SSH-2.0-OpenSSH_7.1", + "SSH-2.0-dropbear_2019.78", + ] { + assert_eq!(rsa_sigs(old.as_bytes()), RsaSigs::Sha1, "{old}"); + } + for new in [ + "SSH-2.0-OpenSSH_7.2", + "SSH-2.0-OpenSSH_7.5", + "SSH-2.0-OpenSSH_10.2p1 Ubuntu-2ubuntu3.6", + "SSH-2.0-dropbear_2020.79", + "SSH-2.0-Go", + "SSH-2.0-OpenSSH_for_Windows_9.5", + "garbage", + ] { + assert_eq!(rsa_sigs(new.as_bytes()), RsaSigs::AsListed, "{new}"); + } + assert_eq!( + rsa_sigs(b"SSH-2.0-OpenSSH_7.4p1 Debian-10+deb9u7"), + RsaSigs::Sha2Unlisted + ); + } + #[test] fn the_quoted_offer_is_cleaned_and_bounded() { let offer = [ From 4675b1af3c0f141350abe01e947c144f9c2d3a3b Mon Sep 17 00:00:00 2001 From: Tim Hartmann Date: Thu, 1 Oct 2026 11:56:18 +0400 Subject: [PATCH 6/8] fix(core): name the key type and the legacy file in a host key refusal --- crates/omnyssh-core/src/ssh/known_hosts.rs | 105 ++++++++++++++++++--- crates/omnyssh-core/src/ssh/session.rs | 10 +- 2 files changed, 99 insertions(+), 16 deletions(-) diff --git a/crates/omnyssh-core/src/ssh/known_hosts.rs b/crates/omnyssh-core/src/ssh/known_hosts.rs index 20865b69..4f0dba72 100644 --- a/crates/omnyssh-core/src/ssh/known_hosts.rs +++ b/crates/omnyssh-core/src/ssh/known_hosts.rs @@ -18,7 +18,11 @@ pub(crate) enum Verdict { /// No key of this type is saved for the host. Unknown, /// A saved key of the same type differs. - Changed(PathBuf), + Changed { + file: PathBuf, + /// The file is the one OmnySSH kept up to 1.1.3. + legacy: bool, + }, /// The file could not be read or parsed, or there is no home to find it in. Unreadable(PathBuf, russh::keys::Error), } @@ -73,7 +77,10 @@ fn check_in(files: &[PathBuf], host: &str, port: u16, key: &PublicKey) -> Verdic return Verdict::Known; } if saved.iter().any(|k| same_type(k, key)) { - return Verdict::Changed(file.clone()); + return Verdict::Changed { + file: file.clone(), + legacy: legacy_path().as_ref() == Some(file), + }; } } Verdict::Unknown @@ -175,9 +182,38 @@ fn who(host: &str, port: u16) -> String { } } +/// The key type as `ssh-keygen -l` names it, with the curve, since a server can +/// change curves, and the name OpenSSH gives the server's file for it. +fn key_type(algorithm: &Algorithm) -> (String, Option<&'static str>) { + match algorithm { + Algorithm::Ed25519 => ("ED25519".into(), Some("ed25519")), + Algorithm::Ecdsa { curve } => { + let bits = match curve { + EcdsaCurve::NistP256 => 256, + EcdsaCurve::NistP384 => 384, + EcdsaCurve::NistP521 => 521, + }; + (format!("ECDSA P-{bits}"), Some("ecdsa")) + } + Algorithm::Rsa { .. } => ("RSA".into(), Some("rsa")), + other => (other.to_string(), None), + } +} + /// Shown when a saved key no longer matches. The first ':' closes the headline, /// so a frontend that cuts there (the TUI status bar) keeps just that. -pub(crate) fn changed_message(host: &str, port: u16, file: &Path, fingerprint: &str) -> String { +/// +/// Names the offered key's type: a server has one key per type, and checking +/// another type's key on the server shows a mismatch that is not there. A +/// refusal from the legacy file says it is OmnySSH's own list, or a user whose +/// PuTTY and `ssh` connect has no reason to believe it. +pub(crate) fn changed_message( + host: &str, + port: u16, + key: &PublicKey, + file: &Path, + legacy: bool, +) -> String { // `ssh-keygen -R` wants `[host]:port` off port 22; quoted, or zsh takes it // for a glob. let target = if port == 22 { @@ -185,12 +221,23 @@ pub(crate) fn changed_message(host: &str, port: u16, file: &Path, fingerprint: & } else { format!("[{host}]:{port}") }; + let (kind, server_file) = key_type(&key.algorithm()); + let whose = if legacy { + ", the list OmnySSH kept up to 1.1.3, which PuTTY and OpenSSH never read" + } else { + "" + }; + let how = server_file + .map(|name| format!(", e.g. with ssh-keygen -lf /etc/ssh/ssh_host_{name}_key.pub")) + .unwrap_or_default(); format!( - "Host key of {who} has changed: it does not match the key saved in {file}. \ - If the server was reinstalled, check on the server that its key is {fingerprint}, \ - then remove the old one with ssh-keygen -R \"{target}\" -f \"{file}\". \ + "Host key of {who} has changed: its {kind} key {fingerprint} does not match \ + the one saved in {file}{whose}. If the server was reinstalled or replaced, \ + check that key on the server{how}, then remove the old one with \ + ssh-keygen -R \"{target}\" -f \"{file}\". \ Otherwise someone may be intercepting the connection.", who = who(host, port), + fingerprint = key.fingerprint(HashAlg::Sha256), file = file.display(), ) } @@ -249,7 +296,10 @@ mod tests { Verdict::Known )); match check_in(&files, "10.0.0.5", 22, &other) { - Verdict::Changed(path) => assert_eq!(path, file), + Verdict::Changed { file: path, legacy } => { + assert_eq!(path, file); + assert!(!legacy); + } _ => panic!("expected a changed key"), } assert!(matches!( @@ -277,7 +327,7 @@ mod tests { // The legacy pin still refuses a key nobody saved elsewhere... write(&legacy, &[line("vm", &stale)]); match check_in(&files, "vm", 22, &server) { - Verdict::Changed(path) => assert_eq!(path, legacy), + Verdict::Changed { file, .. } => assert_eq!(file, legacy), _ => panic!("expected the legacy pin to refuse"), } // ...until the key is saved in the primary file, which is read first. @@ -290,7 +340,7 @@ mod tests { write(&primary, &[line("vm", &stale)]); write(&legacy, &[line("vm", &server)]); match check_in(&files, "vm", 22, &server) { - Verdict::Changed(path) => assert_eq!(path, primary), + Verdict::Changed { file, .. } => assert_eq!(file, primary), _ => panic!("expected the primary pin to refuse"), } } @@ -316,7 +366,7 @@ mod tests { )); assert!(matches!( check_in(&files, "Build.Corp.lan", 22, &other), - Verdict::Changed(_) + Verdict::Changed { .. } )); } @@ -415,24 +465,46 @@ mod tests { )); // Another RSA key is a changed one, not a new type to trust. match check_in(&files, "vm", 22, &rsa()) { - Verdict::Changed(path) => assert_eq!(path, file), + Verdict::Changed { file: path, .. } => assert_eq!(path, file), _ => panic!("an RSA key under another hash name slipped past the pin"), } } #[test] - fn the_refusal_names_the_file_and_the_remedy() { + fn the_refusal_names_the_key_the_file_and_the_remedy() { let file = Path::new("/home/me/.ssh/known_hosts"); - let message = changed_message("10.0.0.5", 2222, file, "SHA256:abc"); + let key = ed25519(); + let message = changed_message("10.0.0.5", 2222, &key, file, false); // The TUI status bar keeps what comes before the first ':'. assert_eq!( message.split(':').next(), Some("Host key of 10.0.0.5 port 2222 has changed") ); - assert!(message.contains("SHA256:abc")); + let fingerprint = key.fingerprint(HashAlg::Sha256).to_string(); + assert!(message.contains(&format!( + "its ED25519 key {fingerprint} does not match the one saved in /home/me/.ssh/known_hosts." + ))); + assert!(message.contains("ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub")); assert!( message.contains("ssh-keygen -R \"[10.0.0.5]:2222\" -f \"/home/me/.ssh/known_hosts\"") ); + assert!(!message.contains("PuTTY")); + } + + #[test] + fn a_legacy_refusal_says_the_file_is_omnysshs_own() { + let file = Path::new(r"C:\Users\me\ssh\known_hosts"); + let message = changed_message("192.168.1.25", 22, &ed25519(), file, true); + assert_eq!( + message.split(':').next(), + Some("Host key of 192.168.1.25 has changed") + ); + assert!(message.contains( + r"saved in C:\Users\me\ssh\known_hosts, the list OmnySSH kept up to 1.1.3, which PuTTY and OpenSSH never read." + )); + assert!( + message.contains(r#"ssh-keygen -R "192.168.1.25" -f "C:\Users\me\ssh\known_hosts""#) + ); } /// The old file, where russh-keys pinned keys on Windows, is still read. @@ -448,5 +520,10 @@ mod tests { write(&legacy, &[line("vm", &key)]); assert_eq!(path(), Some(home.path().join(".ssh").join("known_hosts"))); assert!(matches!(check("vm", 22, &key), Verdict::Known)); + // A refusal from it says it is the old file. + assert!(matches!( + check("vm", 22, &ed25519()), + Verdict::Changed { legacy: true, .. } + )); } } diff --git a/crates/omnyssh-core/src/ssh/session.rs b/crates/omnyssh-core/src/ssh/session.rs index ef05f99f..7eb61de6 100644 --- a/crates/omnyssh-core/src/ssh/session.rs +++ b/crates/omnyssh-core/src/ssh/session.rs @@ -237,13 +237,19 @@ impl client::Handler for KnownHostsHandler { return Ok(true); } // A previously recorded key changed — refuse; possible MITM. - Verdict::Changed(file) => { + Verdict::Changed { file, legacy } => { tracing::warn!( host = %self.host, port = self.port, "server key mismatch in known_hosts — possible MITM attack, refusing connection" ); - known_hosts::changed_message(&self.host, self.port, &file, &fingerprint) + known_hosts::changed_message( + &self.host, + self.port, + server_public_key, + &file, + legacy, + ) } // Unreadable or corrupt known_hosts — fail closed rather than // accept an unverified key. From 2bc2c7f5d0675e2e81528e93858fa694faf0d98b Mon Sep 17 00:00:00 2001 From: Tim Hartmann Date: Thu, 1 Oct 2026 11:56:24 +0400 Subject: [PATCH 7/8] fix(core): refuse a host key that matches none saved for the host --- crates/omnyssh-core/src/ssh/known_hosts.rs | 101 ++++++++++++++++++--- crates/omnyssh-core/src/ssh/session.rs | 7 +- 2 files changed, 95 insertions(+), 13 deletions(-) diff --git a/crates/omnyssh-core/src/ssh/known_hosts.rs b/crates/omnyssh-core/src/ssh/known_hosts.rs index 4f0dba72..aa402834 100644 --- a/crates/omnyssh-core/src/ssh/known_hosts.rs +++ b/crates/omnyssh-core/src/ssh/known_hosts.rs @@ -15,11 +15,13 @@ use russh::keys::{Algorithm, EcdsaCurve, HashAlg, PublicKey}; pub(crate) enum Verdict { /// A saved key matches. Known, - /// No key of this type is saved for the host. + /// No key is saved for the host. Unknown, - /// A saved key of the same type differs. + /// Keys are saved for the host and the offered one is not among them. Changed { file: PathBuf, + /// The types saved, when none is the offered key's. + pinned: Vec, /// The file is the one OmnySSH kept up to 1.1.3. legacy: bool, }, @@ -57,9 +59,11 @@ pub(crate) fn check(host: &str, port: u16, key: &PublicKey) -> Verdict { check_in(&files(), host, port, key) } -/// The first file holding a key of the offered type decides. Any matching line -/// is enough, as in ssh(1): a stale line next to the right one refuses nothing. -/// A file that cannot be opened counts as empty, as in ssh(1). +/// The first file holding a key for the host decides. Any matching line is +/// enough, as in ssh(1): a stale line next to the right one refuses nothing. A +/// key of a type nobody saved is refused too, as ssh(1) does: else a server +/// that shows only another type, as a man in the middle can, would pass for a +/// new host. A file that cannot be opened counts as empty, as in ssh(1). fn check_in(files: &[PathBuf], host: &str, port: u16, key: &PublicKey) -> Verdict { // No home: nothing to check against, and nowhere a key could be pinned. if files.is_empty() { @@ -76,9 +80,18 @@ fn check_in(files: &[PathBuf], host: &str, port: u16, key: &PublicKey) -> Verdic if saved.iter().any(|k| k.key_data() == key.key_data()) { return Verdict::Known; } - if saved.iter().any(|k| same_type(k, key)) { + if !saved.is_empty() { + let mut pinned = Vec::new(); + if !saved.iter().any(|k| same_type(k, key)) { + for algorithm in saved.iter().map(PublicKey::algorithm) { + if !pinned.contains(&algorithm) { + pinned.push(algorithm); + } + } + } return Verdict::Changed { file: file.clone(), + pinned, legacy: legacy_path().as_ref() == Some(file), }; } @@ -212,6 +225,7 @@ pub(crate) fn changed_message( port: u16, key: &PublicKey, file: &Path, + pinned: &[Algorithm], legacy: bool, ) -> String { // `ssh-keygen -R` wants `[host]:port` off port 22; quoted, or zsh takes it @@ -222,6 +236,12 @@ pub(crate) fn changed_message( format!("[{host}]:{port}") }; let (kind, server_file) = key_type(&key.algorithm()); + let saved = if pinned.is_empty() { + "the one".to_string() + } else { + let types: Vec = pinned.iter().map(|a| key_type(a).0).collect(); + format!("the {} key", types.join(" or ")) + }; let whose = if legacy { ", the list OmnySSH kept up to 1.1.3, which PuTTY and OpenSSH never read" } else { @@ -232,7 +252,7 @@ pub(crate) fn changed_message( .unwrap_or_default(); format!( "Host key of {who} has changed: its {kind} key {fingerprint} does not match \ - the one saved in {file}{whose}. If the server was reinstalled or replaced, \ + {saved} saved in {file}{whose}. If the server was reinstalled or replaced, \ check that key on the server{how}, then remove the old one with \ ssh-keygen -R \"{target}\" -f \"{file}\". \ Otherwise someone may be intercepting the connection.", @@ -296,8 +316,14 @@ mod tests { Verdict::Known )); match check_in(&files, "10.0.0.5", 22, &other) { - Verdict::Changed { file: path, legacy } => { + Verdict::Changed { + file: path, + pinned, + legacy, + } => { assert_eq!(path, file); + // Same type: nothing to name. + assert!(pinned.is_empty()); assert!(!legacy); } _ => panic!("expected a changed key"), @@ -318,7 +344,7 @@ mod tests { } #[test] - fn the_first_file_with_the_offered_type_decides() { + fn the_first_file_with_a_key_for_the_host_decides() { let dir = tempfile::tempdir().unwrap(); let (primary, legacy) = (dir.path().join("a"), dir.path().join("b")); let (server, stale) = (ed25519(), ed25519()); @@ -345,6 +371,34 @@ mod tests { } } + #[test] + fn a_key_of_a_type_nobody_saved_is_refused() { + let dir = tempfile::tempdir().unwrap(); + let file = dir.path().join("known_hosts"); + write(&file, &[line("vm", &ed25519())]); + // What a man in the middle shows when it has no Ed25519 key to offer. + let p384 = russh::keys::parse_public_key_base64( + "AAAAE2VjZHNhLXNoYTItbmlzdHAzODQAAAAIbmlzdHAzODQAAABhBPsWebQPfTKmztyvWSqgE1HXWtAJwl6Y\ + YUx43JswHMefMvUBiOAnCS20o697vnbFr6WtNWGsTt48NyDfBtwezmZ4wyhOqDnd7kJL8MUsWd3S7E4xe5RBd\ + U39kfoUDZ2WOQ==", + ) + .expect("p384 key"); + match check_in(std::slice::from_ref(&file), "vm", 22, &p384) { + Verdict::Changed { + file: path, pinned, .. + } => { + assert_eq!(path, file); + assert_eq!(pinned, [Algorithm::Ed25519]); + } + _ => panic!("a key of another type passed for a new host"), + } + // A host with nothing saved still trusts its first key. + assert!(matches!( + check_in(&[file], "other", 22, &p384), + Verdict::Unknown + )); + } + #[test] fn no_home_refuses_rather_than_trust_anything() { assert!(matches!( @@ -465,7 +519,9 @@ mod tests { )); // Another RSA key is a changed one, not a new type to trust. match check_in(&files, "vm", 22, &rsa()) { - Verdict::Changed { file: path, .. } => assert_eq!(path, file), + Verdict::Changed { + file: path, pinned, .. + } => assert_eq!((path, pinned), (file, vec![])), _ => panic!("an RSA key under another hash name slipped past the pin"), } } @@ -474,7 +530,7 @@ mod tests { fn the_refusal_names_the_key_the_file_and_the_remedy() { let file = Path::new("/home/me/.ssh/known_hosts"); let key = ed25519(); - let message = changed_message("10.0.0.5", 2222, &key, file, false); + let message = changed_message("10.0.0.5", 2222, &key, file, &[], false); // The TUI status bar keeps what comes before the first ':'. assert_eq!( message.split(':').next(), @@ -491,10 +547,31 @@ mod tests { assert!(!message.contains("PuTTY")); } + #[test] + fn a_refusal_names_the_saved_type_when_another_is_offered() { + let file = Path::new("/home/me/.ssh/known_hosts"); + let p256 = Algorithm::Ecdsa { + curve: EcdsaCurve::NistP256, + }; + let message = changed_message( + "vm", + 22, + &ed25519(), + file, + std::slice::from_ref(&p256), + false, + ); + assert!(message + .contains("does not match the ECDSA P-256 key saved in /home/me/.ssh/known_hosts.")); + let rsa = Algorithm::Rsa { hash: None }; + let message = changed_message("vm", 22, &ed25519(), file, &[p256, rsa], false); + assert!(message.contains("does not match the ECDSA P-256 or RSA key saved in")); + } + #[test] fn a_legacy_refusal_says_the_file_is_omnysshs_own() { let file = Path::new(r"C:\Users\me\ssh\known_hosts"); - let message = changed_message("192.168.1.25", 22, &ed25519(), file, true); + let message = changed_message("192.168.1.25", 22, &ed25519(), file, &[], true); assert_eq!( message.split(':').next(), Some("Host key of 192.168.1.25 has changed") diff --git a/crates/omnyssh-core/src/ssh/session.rs b/crates/omnyssh-core/src/ssh/session.rs index 7eb61de6..7a0236d1 100644 --- a/crates/omnyssh-core/src/ssh/session.rs +++ b/crates/omnyssh-core/src/ssh/session.rs @@ -237,7 +237,11 @@ impl client::Handler for KnownHostsHandler { return Ok(true); } // A previously recorded key changed — refuse; possible MITM. - Verdict::Changed { file, legacy } => { + Verdict::Changed { + file, + pinned, + legacy, + } => { tracing::warn!( host = %self.host, port = self.port, @@ -248,6 +252,7 @@ impl client::Handler for KnownHostsHandler { self.port, server_public_key, &file, + &pinned, legacy, ) } From 5672d2264c221f11104bda8e479f156bc72b7b90 Mon Sep 17 00:00:00 2001 From: Tim Hartmann Date: Thu, 1 Oct 2026 11:56:29 +0400 Subject: [PATCH 8/8] docs(changelog): note legacy server, FreeBSD and host key fixes --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 474e2a8a..eb0a2eff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,10 @@ Versions follow [Semantic Versioning](https://semver.org/). ## Unreleased ### Bug Fixes +- **Security: a server that offers a host key of another type than the one saved for it is refused.** OmnySSH compared the offered key only with saved keys of the same type. A host saved with its Ed25519 key that suddenly offered only an RSA or ECDSA key was therefore taken for a new host: the key was saved and the login went ahead, password included, and the dashboard did this in the background without anyone connecting. Someone intercepting the connection could get past the saved key this way. Like `ssh`, OmnySSH now refuses any key that matches none of those saved for the host, whatever its type, and says which type is saved. A server that really dropped the type of key it was saved with needs its old key removed once, as `ssh` also asks. +- **A changed host key names its type and, on Windows, whose list held the old one.** OmnySSH 1.1.3 and earlier kept host keys in `%USERPROFILE%\ssh\known_hosts`, a list that PuTTY and OpenSSH never read. When a server was reinstalled, or a new one took over its address, OmnySSH refused it while PuTTY and PowerShell's `ssh` connected, and nothing said why they disagree. A refusal from that file now says it is the list OmnySSH kept up to 1.1.3. Every refusal also names the type of the key the server offered, ED25519, ECDSA or RSA, and the command that prints that key's fingerprint on the server: a server has one key of each type, and comparing the wrong one shows a mismatch that is not there. +- **Servers from the RHEL/CentOS 6 era connect.** OpenSSH before 5.7 failed with "no common key exchange method". Its only key exchange without SHA-1 is diffie-hellman-group-exchange-sha256, which OmnySSH did not offer. Its only host keys are ssh-rsa and ssh-dss, and it predates the rsa-sha2 signatures, so an RSA key could not log in either. OmnySSH now offers diffie-hellman-group-exchange-sha256 and the ssh-rsa host key after every other method, so a server that supports anything newer negotiates exactly as before. An RSA key signs with SHA-1 only for a server that accepts nothing else: one that lists only ssh-rsa as the signatures it takes, or whose version, OpenSSH before 7.2 or Dropbear before 2020.79, predates that list, as PuTTY and `ssh` before 8.8 sign for it. SHA-1 key exchange, CBC ciphers and DSA keys are still left out. +- **FreeBSD hosts show CPU, RAM, load average and top processes.** A FreeBSD server's card showed only disk and uptime: its `top` rejects the options OmnySSH passed, it has neither `free` nor `/proc`, and its `ps` read the list of columns as a single column. CPU is now measured from the kernel's tick counters over one second, so each update of a FreeBSD host takes a second longer. RAM is read from the kernel's page counts, with free and inactive memory, the buffer cache and the ZFS ARC above its minimum counted as available, as htop counts them, so a server on ZFS does not look nearly full. The kernel's idle process, which `ps` charges with all idle time, is left out of the top processes. Where `/proc/loadavg` is missing, the load average now comes from `uptime`, which also fills it in for macOS hosts in the terminal app's host details. As on any system, an account whose login shell is csh or tcsh still shows no metrics. - **Servers that take only NIST key exchange or aes128-gcm connect, such as Cisco RoomOS video devices.** A server whose key exchange methods include no curve25519 or Diffie-Hellman group 14/16, or whose only cipher is aes128-gcm, failed with "No common algorithm". OmnySSH now also offers ecdh-sha2-nistp256, -nistp384 and -nistp521 key exchange and the aes128-gcm@openssh.com cipher, which macOS's own `ssh` tries first. A host whose only host key is ECDSA P-384, as on a RoomOS device set to ECDSA, now connects the first time too; before, that key was only accepted once it was saved in `known_hosts`. The new key exchange methods and host key type come after the ones offered before, so a server that already worked keeps its key exchange and host key. This needed a newer release of the SSH library (russh 0.63), and building from source with `cargo install omnyssh` now needs Rust 1.89 or later. - **A server with no algorithm in common says which kind is missing and what it offers.** The error read "No common algorithm", and a key exchange mismatch even "Unknown algorithm", without saying whether the key exchange, host key, cipher or MAC was the problem, so there was nothing to go on. It now reads, for example, "no common cipher; the server offers aes128-cbc". Servers that offer only methods OmnySSH leaves out on purpose, such as SHA-1 Diffie-Hellman or CBC ciphers, still do not connect. - **macOS: a host on your local network that fails with "No route to host" says macOS may be blocking it.** macOS keeps an app away from devices on the local network until you allow it, and a blocked connection fails at once with "No route to host (os error 65)", while the router usually stays reachable because it is also your DNS server, so it looked like a fault on that one machine. The error now adds that macOS may be blocking the app and where to allow it: System Settings, Privacy & Security, Local Network, then reopen the app. The desktop app also now carries the explanation macOS shows when it asks for that permission. The terminal app needs the permission for the terminal it runs in; Apple's Terminal has it already.