diff --git a/Cargo.lock b/Cargo.lock index 7115b5d6..aab1fcaf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -552,7 +552,7 @@ dependencies = [ "futures-core", "futures-io", "futures-lite 2.6.1", - "gloo-timers", + "gloo-timers 0.3.0", "kv-log-macro", "log", "memchr", @@ -1747,6 +1747,20 @@ dependencies = [ "syn", ] +[[package]] +name = "dashmap" +version = "6.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + [[package]] name = "data-encoding" version = "2.11.0" @@ -2754,6 +2768,18 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "gloo-timers" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "482ce8a491a501da4cd806bd190275363d674f2845005c6ddbd5d3e1dd54495d" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + [[package]] name = "glow" version = "0.17.0" @@ -6568,6 +6594,26 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "russh-sftp" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed8949eca4163c18a8f59ff96d32cf61e9c13b9735e21ef32b3907f4aafa1a9" +dependencies = [ + "bitflags 2.13.0", + "bytes", + "chrono", + "dashmap", + "gloo-timers 0.4.0", + "log", + "serde", + "serde_bytes", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "wasm-bindgen-futures", +] + [[package]] name = "russh-util" version = "0.52.0" @@ -8178,6 +8224,7 @@ dependencies = [ "bytes", "futures-core", "futures-sink", + "futures-util", "pin-project-lite", "tokio", ] @@ -8438,6 +8485,7 @@ dependencies = [ "portable-pty", "reqwest_client", "russh", + "russh-sftp", "serde", "serde_json", "serde_yaml", diff --git a/Cargo.toml b/Cargo.toml index aa7b27c7..3c3a0461 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -83,6 +83,13 @@ portable-pty = "0.8" # bridges to it through blocking `Read`/`Write` adapters. russh = "0.62" +# SFTP client for the native SSH engine (`daemon::ssh::sftp`, Workstream 5). +# Not part of russh proper: `russh-sftp` drives the SFTP subsystem over any +# AsyncRead+AsyncWrite stream, which a russh session channel provides via +# `Channel::into_stream()`. Version-independent of russh (it only needs the +# channel byte stream), so it rides the same tokio runtime `daemon::ssh` owns. +russh-sftp = "2" + # tokio powers only the russh session engine — a single runtime `daemon::ssh` # owns. The daemon's PTY/reader/writer threads remain std threads and never touch # it; they cross into async through bounded/unbounded channels (the blocking @@ -95,6 +102,9 @@ tokio = { version = "1", features = [ "time", "macros", "process", + # `fs` powers the local side of SFTP transfers (`daemon::ssh::sftp`): async + # file/dir IO on the daemon process's own filesystem during upload/download. + "fs", ] } # SIMD byte search for the OSC tokenizer's Ground/Ignore fast paths — the diff --git a/src/core/actions.rs b/src/core/actions.rs index 75d9f1f0..5a98387d 100644 --- a/src/core/actions.rs +++ b/src/core/actions.rs @@ -57,6 +57,8 @@ actions!( ToggleTabSidebar, OpenSettings, RestartDaemon, + // Toggle the SFTP file panel for the focused native-SSH pane (WS5). + ToggleSftp, SendTab, SendBackTab, Quit diff --git a/src/daemon/protocol.rs b/src/daemon/protocol.rs index 13773bc1..cfb3677f 100644 --- a/src/daemon/protocol.rs +++ b/src/daemon/protocol.rs @@ -388,6 +388,137 @@ pub struct SshForwardRule { pub description: Option, } +// --------------------------------------------------------------------------- +// SFTP (Workstream 5) — wire types. +// +// SFTP rides a native-SSH pane's already-authenticated russh connection: the +// daemon opens an SFTP-subsystem channel on the pane's connection (reused across +// panes sharing it) and answers directory listings / file operations / transfer +// jobs. All requests carry the `pane_id`; the daemon resolves it to the pane's +// `SshConnection` through the registry. Only native-SSH panes have one — a PTY +// or compat-`ssh` pane replies with an `Error`. +// --------------------------------------------------------------------------- + +/// The classification of one remote directory entry. Symlinks are reported as +/// `Symlink`; the daemon additionally follow-stats the target so the GUI can tell +/// a link-to-directory (navigable) from a link-to-file (downloadable) via +/// [`SftpEntry::target_is_dir`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum SftpEntryKind { + File, + Dir, + Symlink, +} + +/// One entry in a remote directory listing (or a single `Stat` result). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SftpEntry { + pub name: String, + pub kind: SftpEntryKind, + #[serde(default)] + pub size: u64, + /// Modification time in whole seconds since the Unix epoch (0 if unknown). + #[serde(default)] + pub mtime: u64, + /// Unix mode bits (permissions + type), 0 if the server didn't report them. + #[serde(default)] + pub permissions: u32, + /// For a `Symlink`, whether the (followed) target is a directory — lets the + /// GUI decide navigate-vs-download without another round-trip. Always false + /// for non-symlinks. + #[serde(default)] + pub target_is_dir: bool, +} + +/// A metadata / namespace operation on the remote filesystem. Recursive delete +/// (`RemoveDir`) recurses daemon-side. `Stat`/`Readlink` return data in the +/// [`SftpOpResult`]; the rest just succeed or fail. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum SftpOp { + /// Follow-symlink stat of a single path. + Stat { path: String }, + Mkdir { path: String }, + RemoveFile { path: String }, + /// Recursive directory delete (daemon walks + removes children first). + RemoveDir { path: String }, + Rename { from: String, to: String }, + /// Set the permission (mode) bits of `path`. + Chmod { path: String, mode: u32 }, + /// Read a symlink's target path (returned as [`SftpOpResult::Link`]). + Readlink { path: String }, +} + +/// The reply to a [`SftpOp`]. `Done` for side-effecting ops; `Stat`/`Link` carry +/// the queried data; `Error` carries a human-readable failure reason. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum SftpOpResult { + Done, + Stat(SftpEntry), + Link(String), + Error(String), +} + +/// Transfer direction for a background SFTP job. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum SftpTransferKind { + /// local → remote. + Upload, + /// remote → local. + Download, +} + +/// The recipe for a background transfer job. `local` is a path in the *daemon +/// process's* filesystem (same user); `remote` is an absolute remote path. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SftpTransferSpec { + pub pane_id: u64, + pub kind: SftpTransferKind, + pub local: PathBuf, + pub remote: String, + /// Recurse into directories (create dirs on the far side). + #[serde(default)] + pub recursive: bool, +} + +/// Lifecycle state of a transfer job. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum SftpJobState { + Running, + Done, + Error, + Cancelled, +} + +/// A snapshot of one transfer job's progress, returned by the poll-based +/// `SftpTransferList` request while the tray is visible. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SftpJobProgress { + pub job_id: u64, + pub pane_id: u64, + pub kind: SftpTransferKind, + pub state: SftpJobState, + /// The path currently being transferred (a leaf within a recursive job). + #[serde(default)] + pub current: String, + #[serde(default)] + pub bytes_done: u64, + #[serde(default)] + pub bytes_total: u64, + /// Populated only when `state == Error`. + #[serde(default)] + pub error: Option, + /// Display labels (the job's endpoints). + #[serde(default)] + pub local: String, + #[serde(default)] + pub remote: String, +} + fn default_term() -> String { "xterm-256color".to_string() } @@ -688,6 +819,21 @@ pub enum ClientMsg { ListKnownHosts, /// Delete one `known_hosts` entry, then reply with the refreshed list. DeleteKnownHost(KnownHostId), + /// List a remote directory over the pane's SFTP session (control connection). + /// Daemon replies `SftpEntries` or `Error`. + SftpList { pane_id: u64, path: String }, + /// A one-shot SFTP filesystem operation (mkdir/remove/rename/chmod/stat/…) on + /// the pane's SFTP session. Daemon replies `SftpOpResult`. + SftpOp { pane_id: u64, op: SftpOp }, + /// Start a background upload/download job on the pane's SFTP session. Daemon + /// replies `SftpTransferStarted { job_id }` (or `Error`). + SftpTransferStart(SftpTransferSpec), + /// Cancel a running transfer job. Daemon replies with the current + /// `SftpTransferProgress` list. + SftpTransferCancel { job_id: u64 }, + /// Poll the transfer jobs for a pane (the GUI polls while its tray is + /// visible). Daemon replies with a `SftpTransferProgress` list. + SftpTransferList { pane_id: u64 }, } /// Messages the daemon sends back to the GUI client. @@ -736,6 +882,14 @@ pub enum DaemonMsg { SshStatus { phase: SshPhase }, /// Reply to `ListKnownHosts` and `DeleteKnownHost`. KnownHostsList(Vec), + /// Reply to `SftpList`: the directory's entries (unsorted; the GUI sorts). + SftpEntries(Vec), + /// Reply to `SftpOp`. + SftpOpResult(SftpOpResult), + /// Reply to `SftpTransferStart`: the id of the freshly created job. + SftpTransferStarted { job_id: u64 }, + /// Reply to `SftpTransferList` / `SftpTransferCancel`: progress snapshots. + SftpTransferProgress(Vec), /// A request failed (e.g. `Attach` to an unknown/dead pane id). Error(String), } @@ -776,6 +930,12 @@ mod kind { pub const LIST_KNOWN_HOSTS: u8 = 16; /// `DeleteKnownHost` — remove one known_hosts entry. pub const DELETE_KNOWN_HOST: u8 = 17; + // (WS3 reserves 15-17, WS4 reserves 20-24.) SFTP (WS5) owns 30-36. + pub const SFTP_LIST: u8 = 30; + pub const SFTP_OP: u8 = 31; + pub const SFTP_TRANSFER_START: u8 = 32; + pub const SFTP_TRANSFER_CANCEL: u8 = 33; + pub const SFTP_TRANSFER_LIST: u8 = 34; // Daemon -> client pub const SPAWNED: u8 = 1; @@ -796,6 +956,11 @@ mod kind { pub const SSH_STATUS: u8 = 14; /// `KnownHostsList` — reply to `LIST_KNOWN_HOSTS` / `DELETE_KNOWN_HOST`. pub const KNOWN_HOSTS_LIST: u8 = 15; + // SFTP (WS5) replies own 30-36 in the daemon space too. + pub const SFTP_ENTRIES: u8 = 30; + pub const SFTP_OP_RESULT: u8 = 31; + pub const SFTP_TRANSFER_STARTED: u8 = 32; + pub const SFTP_TRANSFER_PROGRESS: u8 = 33; } /// Write one framed message: `[u32 LE len][u8 kind][payload]`. @@ -922,6 +1087,21 @@ impl ClientMsg { ClientMsg::DeleteKnownHost(id) => { write_frame(w, kind::DELETE_KNOWN_HOST, &to_json(id)?) } + ClientMsg::SftpList { pane_id, path } => { + write_frame(w, kind::SFTP_LIST, &to_json(&(pane_id, path))?) + } + ClientMsg::SftpOp { pane_id, op } => { + write_frame(w, kind::SFTP_OP, &to_json(&(pane_id, op))?) + } + ClientMsg::SftpTransferStart(spec) => { + write_frame(w, kind::SFTP_TRANSFER_START, &to_json(spec)?) + } + ClientMsg::SftpTransferCancel { job_id } => { + write_frame(w, kind::SFTP_TRANSFER_CANCEL, &to_json(job_id)?) + } + ClientMsg::SftpTransferList { pane_id } => { + write_frame(w, kind::SFTP_TRANSFER_LIST, &to_json(pane_id)?) + } } } @@ -968,6 +1148,21 @@ impl ClientMsg { } kind::LIST_KNOWN_HOSTS => ClientMsg::ListKnownHosts, kind::DELETE_KNOWN_HOST => ClientMsg::DeleteKnownHost(from_json(&payload)?), + kind::SFTP_LIST => { + let (pane_id, path) = from_json(&payload)?; + ClientMsg::SftpList { pane_id, path } + } + kind::SFTP_OP => { + let (pane_id, op) = from_json(&payload)?; + ClientMsg::SftpOp { pane_id, op } + } + kind::SFTP_TRANSFER_START => ClientMsg::SftpTransferStart(from_json(&payload)?), + kind::SFTP_TRANSFER_CANCEL => ClientMsg::SftpTransferCancel { + job_id: from_json(&payload)?, + }, + kind::SFTP_TRANSFER_LIST => ClientMsg::SftpTransferList { + pane_id: from_json(&payload)?, + }, other => { return Err(io::Error::new( io::ErrorKind::InvalidData, @@ -1016,6 +1211,18 @@ impl DaemonMsg { DaemonMsg::KnownHostsList(list) => { write_frame(w, kind::KNOWN_HOSTS_LIST, &to_json(list)?) } + DaemonMsg::SftpEntries(entries) => { + write_frame(w, kind::SFTP_ENTRIES, &to_json(entries)?) + } + DaemonMsg::SftpOpResult(result) => { + write_frame(w, kind::SFTP_OP_RESULT, &to_json(result)?) + } + DaemonMsg::SftpTransferStarted { job_id } => { + write_frame(w, kind::SFTP_TRANSFER_STARTED, &to_json(job_id)?) + } + DaemonMsg::SftpTransferProgress(jobs) => { + write_frame(w, kind::SFTP_TRANSFER_PROGRESS, &to_json(jobs)?) + } DaemonMsg::Error(msg) => write_frame(w, kind::ERROR, &to_json(msg)?), } } @@ -1053,6 +1260,12 @@ impl DaemonMsg { phase: from_json(&payload)?, }, kind::KNOWN_HOSTS_LIST => DaemonMsg::KnownHostsList(from_json(&payload)?), + kind::SFTP_ENTRIES => DaemonMsg::SftpEntries(from_json(&payload)?), + kind::SFTP_OP_RESULT => DaemonMsg::SftpOpResult(from_json(&payload)?), + kind::SFTP_TRANSFER_STARTED => DaemonMsg::SftpTransferStarted { + job_id: from_json(&payload)?, + }, + kind::SFTP_TRANSFER_PROGRESS => DaemonMsg::SftpTransferProgress(from_json(&payload)?), kind::ERROR => DaemonMsg::Error(from_json(&payload)?), other => { return Err(io::Error::new( @@ -1217,6 +1430,45 @@ mod tests { key_type: "ssh-ed25519".into(), keyblob: "AAAAC3Nz".into(), }), + ClientMsg::SftpList { + pane_id: 4, + path: "/home/deploy/项目".into(), + }, + ClientMsg::SftpOp { + pane_id: 4, + op: SftpOp::Mkdir { + path: "/tmp/new dir".into(), + }, + }, + ClientMsg::SftpOp { + pane_id: 4, + op: SftpOp::Rename { + from: "/a".into(), + to: "/b".into(), + }, + }, + ClientMsg::SftpOp { + pane_id: 4, + op: SftpOp::Chmod { + path: "/x".into(), + mode: 0o755, + }, + }, + ClientMsg::SftpOp { + pane_id: 4, + op: SftpOp::Readlink { + path: "/link".into(), + }, + }, + ClientMsg::SftpTransferStart(SftpTransferSpec { + pane_id: 4, + kind: SftpTransferKind::Upload, + local: PathBuf::from("/local/f"), + remote: "/remote/f".into(), + recursive: true, + }), + ClientMsg::SftpTransferCancel { job_id: 9 }, + ClientMsg::SftpTransferList { pane_id: 4 }, ]; let mut buf = Vec::new(); for m in &msgs { @@ -1350,6 +1602,48 @@ mod tests { keyblob: "AAAAC3Nz".into(), }, }]), + DaemonMsg::SftpEntries(vec![ + SftpEntry { + name: "src".into(), + kind: SftpEntryKind::Dir, + size: 4096, + mtime: 1_700_000_000, + permissions: 0o40755, + target_is_dir: false, + }, + SftpEntry { + name: "链接".into(), + kind: SftpEntryKind::Symlink, + size: 0, + mtime: 0, + permissions: 0o120777, + target_is_dir: true, + }, + ]), + DaemonMsg::SftpOpResult(SftpOpResult::Done), + DaemonMsg::SftpOpResult(SftpOpResult::Link("/target/path".into())), + DaemonMsg::SftpOpResult(SftpOpResult::Error("permission denied".into())), + DaemonMsg::SftpOpResult(SftpOpResult::Stat(SftpEntry { + name: "file".into(), + kind: SftpEntryKind::File, + size: 12, + mtime: 5, + permissions: 0o100644, + target_is_dir: false, + })), + DaemonMsg::SftpTransferStarted { job_id: 3 }, + DaemonMsg::SftpTransferProgress(vec![SftpJobProgress { + job_id: 3, + pane_id: 4, + kind: SftpTransferKind::Download, + state: SftpJobState::Running, + current: "big.iso".into(), + bytes_done: 1024, + bytes_total: 4096, + error: None, + local: "/local".into(), + remote: "/remote".into(), + }]), DaemonMsg::Error("nope".into()), ]; let mut buf = Vec::new(); diff --git a/src/daemon/server.rs b/src/daemon/server.rs index c8c8ddcb..d748e810 100644 --- a/src/daemon/server.rs +++ b/src/daemon/server.rs @@ -86,6 +86,21 @@ impl Registry { } } +/// Resolve a pane id to its live native-SSH connection, for the SFTP control +/// handlers. Errors (as a client-facing string) when the pane is unknown or isn't +/// a native-SSH pane with an established connection (a PTY / compat-`ssh` pane, or +/// one still authenticating). +fn ssh_connection_for( + registry: &Registry, + pane_id: u64, +) -> Result, String> { + let pane = registry + .get(pane_id) + .ok_or_else(|| format!("no such pane {pane_id}"))?; + pane.ssh_connection() + .ok_or_else(|| "pane has no native SSH connection (SFTP needs a native-SSH pane)".to_string()) +} + /// Run the daemon: bind the socket and serve connections forever. Returns `Err` /// only on a fatal setup failure (bad socket path, bind error); the accept loop /// itself runs until the process is killed. @@ -390,6 +405,62 @@ fn handle_conn(stream: Stream, registry: Arc) -> anyhow::Result<()> { Ok(()) } + ClientMsg::SftpList { pane_id, path } => { + let mut w = write_stream; + match ssh_connection_for(®istry, pane_id) { + Ok(conn) => { + match crate::daemon::ssh::sftp::SftpManager::global().list(&conn, &path) { + Ok(entries) => DaemonMsg::SftpEntries(entries).encode(&mut w)?, + Err(e) => DaemonMsg::Error(e).encode(&mut w)?, + } + } + Err(e) => DaemonMsg::Error(e).encode(&mut w)?, + } + Ok(()) + } + + ClientMsg::SftpOp { pane_id, op } => { + let mut w = write_stream; + match ssh_connection_for(®istry, pane_id) { + Ok(conn) => { + let result = crate::daemon::ssh::sftp::SftpManager::global().op(&conn, &op); + DaemonMsg::SftpOpResult(result).encode(&mut w)?; + } + Err(e) => DaemonMsg::Error(e).encode(&mut w)?, + } + Ok(()) + } + + ClientMsg::SftpTransferStart(spec) => { + let mut w = write_stream; + match ssh_connection_for(®istry, spec.pane_id) { + Ok(conn) => { + match crate::daemon::ssh::sftp::SftpManager::global() + .start_transfer(&conn, spec) + { + Ok(job_id) => DaemonMsg::SftpTransferStarted { job_id }.encode(&mut w)?, + Err(e) => DaemonMsg::Error(e).encode(&mut w)?, + } + } + Err(e) => DaemonMsg::Error(e).encode(&mut w)?, + } + Ok(()) + } + + ClientMsg::SftpTransferCancel { job_id } => { + let mut w = write_stream; + let jobs = crate::daemon::ssh::sftp::SftpManager::global().cancel(job_id); + DaemonMsg::SftpTransferProgress(jobs).encode(&mut w)?; + Ok(()) + } + + ClientMsg::SftpTransferList { pane_id } => { + let mut w = write_stream; + let jobs = crate::daemon::ssh::sftp::SftpManager::global().list_jobs(pane_id); + DaemonMsg::SftpTransferProgress(jobs).encode(&mut w)?; + Ok(()) + } + // `Input` / `Resize` / `Detach` as an opening message are meaningless (no // pane is bound yet); ignore and close. other => { diff --git a/src/daemon/ssh/mod.rs b/src/daemon/ssh/mod.rs index eef414d7..7cc6ed2b 100644 --- a/src/daemon/ssh/mod.rs +++ b/src/daemon/ssh/mod.rs @@ -18,6 +18,7 @@ pub mod broker; pub mod known_hosts; pub mod session; +pub mod sftp; mod auth; mod connect; @@ -94,6 +95,16 @@ impl SshManager { }) } + /// A handle to the engine's tokio runtime. The SFTP layer (`ssh::sftp`) uses + /// it to `block_on` one-shot operations and `spawn` background transfer jobs + /// from the daemon's std threads (the server connection threads) without owning + /// a second runtime. Safe to call from a non-async thread; `block_on` on the + /// returned handle drives the future on the caller and panics only if called + /// from *within* a runtime worker (the server threads never are). + pub fn handle(&self) -> tokio::runtime::Handle { + self.runtime.handle().clone() + } + /// Kick off a native-SSH shell for a pane. Returns immediately; the connect → /// auth → shell sequence runs on the runtime and drives the pane through the /// provided bridge ends. All progress/prompt frames go via `broker`. diff --git a/src/daemon/ssh/sftp.rs b/src/daemon/ssh/sftp.rs new file mode 100644 index 00000000..ca01e2b3 --- /dev/null +++ b/src/daemon/ssh/sftp.rs @@ -0,0 +1,1003 @@ +//! SFTP engine for native-SSH panes (Workstream 5). +//! +//! One [`SftpManager`] (a process-wide singleton) rides the same tokio runtime the +//! [`SshManager`](super::SshManager) owns. It answers the daemon's SFTP control +//! messages (`SftpList` / `SftpOp` / transfer start/cancel/list) by opening an +//! SFTP-subsystem channel on a pane's already-authenticated `SshConnection` and +//! driving [`russh_sftp`] over it. +//! +//! ## Session lifecycle +//! - **One cached [`SftpSession`] per [`SshConnection`]** (keyed by +//! [`ConnectionKey`]), reused across every pane that shares the connection. +//! - The cache stores a `Weak` beside the session; a lookup reuses +//! the session only while that weak still upgrades to the *same* live connection +//! (`Arc::ptr_eq`). A reconnect (new connection, same key) transparently gets a +//! fresh SFTP session. +//! - One-shot operations run through [`SftpManager::with_session`], which retries +//! once on failure with a freshly re-opened session — so a dead subsystem +//! channel (while the connection itself lives) is re-opened transparently. +//! +//! ## Threading +//! The server's std connection threads call the **sync** methods here +//! ([`list`](SftpManager::list) etc.), which `block_on` the SSH runtime handle. +//! Background transfers are `spawn`ed onto that runtime and report progress the +//! GUI polls via [`list_jobs`](SftpManager::list_jobs). +//! +//! ## Notes / limitations +//! - **posix-rename:** upload writes a `.tty7-upload-` temp then renames over +//! the target. russh-sftp 2.3.0's high-level API does not expose the +//! `posix-rename@openssh.com` extension, so the swap is a plain SFTP `rename` +//! with a remove-then-rename fallback when the server refuses an +//! overwrite-rename (FR-T2's intent: atomic-ish temp-file finish). +//! - Local filesystem access is the daemon process's own (same user) — fine per +//! the spec. + +use std::collections::HashMap; +use std::future::Future; +use std::path::Path; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::{Arc, Mutex, OnceLock, Weak}; +use std::time::{Duration, Instant}; + +use russh_sftp::client::SftpSession; +use russh_sftp::protocol::{FileAttributes, OpenFlags}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; + +use crate::daemon::protocol::{ + SftpEntry, SftpEntryKind, SftpJobProgress, SftpJobState, SftpOp, SftpOpResult, + SftpTransferKind, SftpTransferSpec, +}; + +use super::{ConnectionKey, SshConnection, SshManager}; + +/// Chunk size for streaming reads/writes (matches the Tabby reference, §6). +const CHUNK: usize = 256 * 1024; + +/// How long a finished job's final progress lingers for the GUI to observe before +/// it is pruned from the job table. +const JOB_RETENTION: Duration = Duration::from_secs(30); + +// --------------------------------------------------------------------------- +// Remote path helpers (pure) — also used by the GUI panel (`ui::sftp`). +// --------------------------------------------------------------------------- + +/// Join a remote directory path with a child name, POSIX-style (`/` separator, +/// never a backslash — the remote is always POSIX regardless of the daemon's OS). +pub fn remote_join(dir: &str, name: &str) -> String { + if dir.is_empty() || dir == "/" { + format!("/{}", name.trim_start_matches('/')) + } else { + format!("{}/{}", dir.trim_end_matches('/'), name.trim_start_matches('/')) + } +} + +/// The parent directory of a remote path. Root's parent is root. Trailing slashes +/// are ignored (so `/a/b/` → `/a`). +pub fn remote_parent(path: &str) -> String { + let trimmed = path.trim_end_matches('/'); + if trimmed.is_empty() { + return "/".to_string(); + } + match trimmed.rfind('/') { + Some(0) | None => "/".to_string(), + Some(idx) => trimmed[..idx].to_string(), + } +} + +/// The final component (basename) of a remote path (`/a/b` → `b`, `/` → `/`). +pub fn remote_basename(path: &str) -> String { + let trimmed = path.trim_end_matches('/'); + if trimmed.is_empty() { + return "/".to_string(); + } + match trimmed.rfind('/') { + Some(idx) => trimmed[idx + 1..].to_string(), + None => trimmed.to_string(), + } +} + +/// The temp filename an upload writes to before renaming over its target: +/// `.tty7-upload-`. Kept in the *same directory* as the target so +/// the finishing rename is same-filesystem (atomic on the server). +pub fn upload_temp_name(remote: &str) -> String { + // A cheap, dependency-free random suffix from the system clock + a counter. + static COUNTER: AtomicU64 = AtomicU64::new(0); + let n = COUNTER.fetch_add(1, Ordering::Relaxed); + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos() as u64) + .unwrap_or(0); + format!("{remote}.tty7-upload-{:x}{:x}", nanos, n) +} + +// --------------------------------------------------------------------------- +// Entry classification (pure). +// --------------------------------------------------------------------------- + +/// Classify a remote entry from its attributes. Symlink is checked first because +/// the SFTP type bits let a symlink also satisfy `is_regular` (S_IFLNK contains +/// the S_IFREG bit), so order matters. +fn classify(attrs: &FileAttributes) -> SftpEntryKind { + if attrs.is_symlink() { + SftpEntryKind::Symlink + } else if attrs.is_dir() { + SftpEntryKind::Dir + } else { + SftpEntryKind::File + } +} + +fn entry_from_attrs(name: &str, attrs: &FileAttributes) -> SftpEntry { + SftpEntry { + name: name.to_string(), + kind: classify(attrs), + size: attrs.size.unwrap_or(0), + mtime: attrs.mtime.map(u64::from).unwrap_or(0), + permissions: attrs.permissions.unwrap_or(0), + target_is_dir: false, + } +} + +// --------------------------------------------------------------------------- +// Transfer job state machine (pure) — tested without any SFTP/window. +// --------------------------------------------------------------------------- + +/// The mutable progress of one transfer job. Terminal states (`Done`/`Error`/ +/// `Cancelled`) latch: once reached, further transitions are ignored, so a late +/// `add_bytes` after cancellation can't resurrect a job or corrupt its status. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct JobProgress { + pub state: SftpJobState, + pub current: String, + pub bytes_done: u64, + pub bytes_total: u64, + pub error: Option, +} + +impl JobProgress { + pub fn new() -> Self { + Self { + state: SftpJobState::Running, + current: String::new(), + bytes_done: 0, + bytes_total: 0, + error: None, + } + } + + fn is_terminal(&self) -> bool { + !matches!(self.state, SftpJobState::Running) + } + + pub fn set_total(&mut self, total: u64) { + if !self.is_terminal() { + self.bytes_total = total; + } + } + + pub fn set_current(&mut self, path: impl Into) { + if !self.is_terminal() { + self.current = path.into(); + } + } + + pub fn add_bytes(&mut self, n: u64) { + if !self.is_terminal() { + self.bytes_done = self.bytes_done.saturating_add(n); + } + } + + pub fn finish(&mut self) { + if !self.is_terminal() { + self.state = SftpJobState::Done; + } + } + + pub fn fail(&mut self, reason: impl Into) { + if !self.is_terminal() { + self.state = SftpJobState::Error; + self.error = Some(reason.into()); + } + } + + pub fn cancel(&mut self) { + if !self.is_terminal() { + self.state = SftpJobState::Cancelled; + } + } +} + +impl Default for JobProgress { + fn default() -> Self { + Self::new() + } +} + +/// A live/finished transfer job. Progress lives behind a `Mutex` so the transfer +/// task updates it while the GUI polls it. +struct Job { + id: u64, + pane_id: u64, + kind: SftpTransferKind, + local: String, + remote: String, + cancel: AtomicBool, + progress: Mutex, + done_at: Mutex>, +} + +impl Job { + fn is_cancelled(&self) -> bool { + self.cancel.load(Ordering::SeqCst) + } + + fn set_total(&self, total: u64) { + self.progress.lock().unwrap().set_total(total); + } + + fn set_current(&self, path: impl Into) { + self.progress.lock().unwrap().set_current(path); + } + + fn add_bytes(&self, n: u64) { + self.progress.lock().unwrap().add_bytes(n); + } + + fn finish(&self) { + self.progress.lock().unwrap().finish(); + *self.done_at.lock().unwrap() = Some(Instant::now()); + } + + fn fail(&self, reason: impl Into) { + self.progress.lock().unwrap().fail(reason); + *self.done_at.lock().unwrap() = Some(Instant::now()); + } + + fn mark_cancelled(&self) { + self.progress.lock().unwrap().cancel(); + *self.done_at.lock().unwrap() = Some(Instant::now()); + } + + fn snapshot(&self) -> SftpJobProgress { + let p = self.progress.lock().unwrap(); + SftpJobProgress { + job_id: self.id, + pane_id: self.pane_id, + kind: self.kind, + state: p.state, + current: p.current.clone(), + bytes_done: p.bytes_done, + bytes_total: p.bytes_total, + error: p.error.clone(), + local: self.local.clone(), + remote: self.remote.clone(), + } + } + + /// True once terminal and past the retention window (safe to prune). + fn is_expired(&self) -> bool { + matches!( + *self.done_at.lock().unwrap(), + Some(t) if t.elapsed() > JOB_RETENTION + ) + } +} + +// --------------------------------------------------------------------------- +// The manager. +// --------------------------------------------------------------------------- + +/// A per-connection SFTP-session cache slot. The inner `tokio::Mutex` serializes +/// opening (so two panes racing to first-use a connection open one session, not +/// two) without serializing *different* connections. +struct SessionSlot { + inner: tokio::sync::Mutex>, +} + +struct CachedSession { + conn: Weak, + sftp: Arc, +} + +pub struct SftpManager { + sessions: Mutex>>, + jobs: Mutex>>, + next_job: AtomicU64, +} + +impl SftpManager { + /// The process-wide SFTP engine. + pub fn global() -> &'static SftpManager { + static MANAGER: OnceLock = OnceLock::new(); + MANAGER.get_or_init(|| SftpManager { + sessions: Mutex::new(HashMap::new()), + jobs: Mutex::new(HashMap::new()), + next_job: AtomicU64::new(1), + }) + } + + // --- sync entry points (called from the server's std threads) ---------- + + /// List a remote directory. Blocks the calling thread on the SSH runtime. + pub fn list(&self, conn: &Arc, path: &str) -> Result, String> { + SshManager::global().handle().block_on(async { + self.with_session(conn, |sftp| async move { list_dir(&sftp, path).await }) + .await + }) + } + + /// Run a one-shot filesystem operation. + pub fn op(&self, conn: &Arc, op: &SftpOp) -> SftpOpResult { + let result = SshManager::global().handle().block_on(async { + self.with_session(conn, |sftp| async move { run_op(&sftp, op).await }) + .await + }); + match result { + Ok(r) => r, + Err(e) => SftpOpResult::Error(e), + } + } + + /// Start a background transfer. Returns the new job id immediately; the + /// transfer runs on the SSH runtime and reports progress via `list_jobs`. + pub fn start_transfer( + &'static self, + conn: &Arc, + spec: SftpTransferSpec, + ) -> Result { + // Establish the session up-front so an immediate failure (no SFTP) is + // reported synchronously rather than as a phantom job. + let sftp = SshManager::global() + .handle() + .block_on(async { self.session_for(conn).await })?; + + let id = self.next_job.fetch_add(1, Ordering::Relaxed); + let job = Arc::new(Job { + id, + pane_id: spec.pane_id, + kind: spec.kind, + local: spec.local.to_string_lossy().to_string(), + remote: spec.remote.clone(), + cancel: AtomicBool::new(false), + progress: Mutex::new(JobProgress::new()), + done_at: Mutex::new(None), + }); + self.jobs.lock().unwrap().insert(id, job.clone()); + + SshManager::global().handle().spawn(async move { + run_transfer(sftp, spec, job).await; + }); + Ok(id) + } + + /// Cancel a running job (idempotent). Returns the current progress list for + /// the job's pane so the caller can refresh the tray in one round-trip. + pub fn cancel(&self, job_id: u64) -> Vec { + let pane = { + let jobs = self.jobs.lock().unwrap(); + if let Some(job) = jobs.get(&job_id) { + job.cancel.store(true, Ordering::SeqCst); + Some(job.pane_id) + } else { + None + } + }; + match pane { + Some(pane_id) => self.list_jobs(pane_id), + None => Vec::new(), + } + } + + /// Snapshot the transfer jobs for a pane, pruning expired (long-finished) + /// ones as a side effect so the table stays bounded. + pub fn list_jobs(&self, pane_id: u64) -> Vec { + let mut jobs = self.jobs.lock().unwrap(); + jobs.retain(|_, job| !job.is_expired()); + let mut out: Vec = jobs + .values() + .filter(|j| j.pane_id == pane_id) + .map(|j| j.snapshot()) + .collect(); + out.sort_by_key(|j| j.job_id); + out + } + + // --- session cache ----------------------------------------------------- + + /// Run `f` against the pane's cached SFTP session, retrying once with a + /// freshly re-opened session if the first attempt fails — so a dead subsystem + /// channel (while the connection lives) is transparently re-established. + async fn with_session(&self, conn: &Arc, f: F) -> Result + where + F: Fn(Arc) -> Fut, + Fut: Future>, + { + let sftp = self.session_for(conn).await?; + match f(sftp).await { + Ok(v) => Ok(v), + Err(_) => { + self.invalidate(conn.key()); + let sftp = self.session_for(conn).await?; + f(sftp).await + } + } + } + + /// The cached session for `conn`, opening one if absent or stale. + async fn session_for(&self, conn: &Arc) -> Result, String> { + let slot = { + let mut map = self.sessions.lock().unwrap(); + map.entry(conn.key().clone()) + .or_insert_with(|| { + Arc::new(SessionSlot { + inner: tokio::sync::Mutex::new(None), + }) + }) + .clone() + }; + let mut guard = slot.inner.lock().await; + if let Some(cached) = guard.as_ref() { + let same = cached + .conn + .upgrade() + .is_some_and(|c| Arc::ptr_eq(&c, conn)); + if same && conn.is_alive() { + return Ok(cached.sftp.clone()); + } + } + let sftp = open_sftp(conn).await?; + *guard = Some(CachedSession { + conn: Arc::downgrade(conn), + sftp: sftp.clone(), + }); + Ok(sftp) + } + + fn invalidate(&self, key: &ConnectionKey) { + self.sessions.lock().unwrap().remove(key); + } +} + +/// Open a fresh SFTP subsystem channel on `conn` and hand back a session. +async fn open_sftp(conn: &Arc) -> Result, String> { + let channel = conn + .open_session_channel() + .await + .map_err(|e| format!("open sftp channel failed: {e}"))?; + channel + .request_subsystem(true, "sftp") + .await + .map_err(|e| format!("sftp subsystem request failed: {e}"))?; + let sftp = SftpSession::new(channel.into_stream()) + .await + .map_err(|e| format!("sftp init failed: {e}"))?; + Ok(Arc::new(sftp)) +} + +// --------------------------------------------------------------------------- +// Operations. +// --------------------------------------------------------------------------- + +async fn list_dir(sftp: &SftpSession, path: &str) -> Result, String> { + let read_dir = sftp.read_dir(path).await.map_err(|e| format!("{e}"))?; + let mut out = Vec::new(); + for entry in read_dir { + let name = entry.file_name(); + if name == "." || name == ".." { + continue; + } + let attrs = entry.metadata(); + let mut e = entry_from_attrs(&name, &attrs); + if e.kind == SftpEntryKind::Symlink { + // Follow-stat the target so the GUI knows navigate-vs-download. + if let Ok(target) = sftp.metadata(remote_join(path, &name)).await { + e.target_is_dir = target.is_dir(); + } + } + out.push(e); + } + Ok(out) +} + +async fn run_op(sftp: &SftpSession, op: &SftpOp) -> Result { + Ok(match op { + SftpOp::Stat { path } => { + let attrs = sftp.metadata(path.clone()).await.map_err(|e| format!("{e}"))?; + SftpOpResult::Stat(entry_from_attrs(&remote_basename(path), &attrs)) + } + SftpOp::Mkdir { path } => { + sftp.create_dir(path.clone()) + .await + .map_err(|e| format!("{e}"))?; + SftpOpResult::Done + } + SftpOp::RemoveFile { path } => { + sftp.remove_file(path.clone()) + .await + .map_err(|e| format!("{e}"))?; + SftpOpResult::Done + } + SftpOp::RemoveDir { path } => { + remove_dir_recursive(sftp, path).await?; + SftpOpResult::Done + } + SftpOp::Rename { from, to } => { + rename_over(sftp, from, to).await?; + SftpOpResult::Done + } + SftpOp::Chmod { path, mode } => { + let mut attrs = FileAttributes::empty(); + attrs.permissions = Some(*mode); + sftp.set_metadata(path.clone(), attrs) + .await + .map_err(|e| format!("{e}"))?; + SftpOpResult::Done + } + SftpOp::Readlink { path } => { + let target = sftp.read_link(path.clone()).await.map_err(|e| format!("{e}"))?; + SftpOpResult::Link(target) + } + }) +} + +/// Rename `from` over `to`, tolerating a server that refuses to overwrite an +/// existing target: remove the target first, then retry. (See the module note on +/// posix-rename.) +async fn rename_over(sftp: &SftpSession, from: &str, to: &str) -> Result<(), String> { + if sftp.rename(from.to_string(), to.to_string()).await.is_ok() { + return Ok(()); + } + let _ = sftp.remove_file(to.to_string()).await; + sftp.rename(from.to_string(), to.to_string()) + .await + .map_err(|e| format!("rename failed: {e}")) +} + +/// Daemon-side recursive directory delete: remove children (files and links +/// directly; subdirectories by recursion) then the directory itself. A +/// symlink child is unlinked, never followed. +async fn remove_dir_recursive(sftp: &SftpSession, path: &str) -> Result<(), String> { + // Explicit worklist to avoid async recursion. Each dir is visited twice: + // first to enqueue its children, then (after them) to remove the now-empty + // directory. We push a directory's own removal marker before its children so + // that, popping LIFO, children are removed first. + enum Step { + Enter(String), + RemoveDir(String), + } + let mut stack = vec![Step::Enter(path.to_string())]; + while let Some(step) = stack.pop() { + match step { + Step::Enter(dir) => { + stack.push(Step::RemoveDir(dir.clone())); + let read_dir = sftp.read_dir(dir.clone()).await.map_err(|e| format!("{e}"))?; + for entry in read_dir { + let name = entry.file_name(); + if name == "." || name == ".." { + continue; + } + let child = remote_join(&dir, &name); + let attrs = entry.metadata(); + // Only a real directory recurses; a symlink (even to a dir) is + // unlinked as a file so we never delete through it. + if attrs.is_dir() && !attrs.is_symlink() { + stack.push(Step::Enter(child)); + } else { + // Best-effort: a child already gone is fine. + let _ = sftp.remove_file(child).await; + } + } + } + Step::RemoveDir(dir) => { + sftp.remove_dir(dir).await.map_err(|e| format!("{e}"))?; + } + } + } + Ok(()) +} + +// --------------------------------------------------------------------------- +// Transfers. +// --------------------------------------------------------------------------- + +async fn run_transfer(sftp: Arc, spec: SftpTransferSpec, job: Arc) { + let result = match spec.kind { + SftpTransferKind::Download => download(&sftp, &spec, &job).await, + SftpTransferKind::Upload => upload(&sftp, &spec, &job).await, + }; + match result { + Ok(()) => job.finish(), + Err(_) if job.is_cancelled() => job.mark_cancelled(), + Err(e) => job.fail(e), + } +} + +/// A cancelled job surfaces as an `Err` that `run_transfer` maps to `Cancelled`. +fn cancelled() -> String { + "cancelled".to_string() +} + +async fn download(sftp: &SftpSession, spec: &SftpTransferSpec, job: &Job) -> Result<(), String> { + // Size pre-pass (recursive) so the tray has a denominator. + let total = remote_size(sftp, &spec.remote, spec.recursive, job).await?; + job.set_total(total); + + let mut stack = vec![(spec.remote.clone(), spec.local.clone())]; + while let Some((rpath, lpath)) = stack.pop() { + if job.is_cancelled() { + return Err(cancelled()); + } + let attrs = sftp.metadata(rpath.clone()).await.map_err(|e| format!("{e}"))?; + if attrs.is_dir() { + if !spec.recursive { + return Err("remote path is a directory (enable recursive)".to_string()); + } + tokio::fs::create_dir_all(&lpath) + .await + .map_err(|e| format!("create local dir: {e}"))?; + let read_dir = sftp.read_dir(rpath.clone()).await.map_err(|e| format!("{e}"))?; + for entry in read_dir { + let name = entry.file_name(); + if name == "." || name == ".." { + continue; + } + stack.push((remote_join(&rpath, &name), lpath.join(&name))); + } + } else { + download_file(sftp, &rpath, &lpath, attrs.permissions, job).await?; + } + } + Ok(()) +} + +async fn download_file( + sftp: &SftpSession, + rpath: &str, + lpath: &Path, + mode: Option, + job: &Job, +) -> Result<(), String> { + job.set_current(rpath.to_string()); + if let Some(parent) = lpath.parent() { + let _ = tokio::fs::create_dir_all(parent).await; + } + let mut remote = sftp.open(rpath.to_string()).await.map_err(|e| format!("{e}"))?; + let mut local = tokio::fs::File::create(lpath) + .await + .map_err(|e| format!("create {}: {e}", lpath.display()))?; + let mut buf = vec![0u8; CHUNK]; + loop { + if job.is_cancelled() { + return Err(cancelled()); + } + let n = remote.read(&mut buf).await.map_err(|e| format!("{e}"))?; + if n == 0 { + break; + } + local + .write_all(&buf[..n]) + .await + .map_err(|e| format!("write local: {e}"))?; + job.add_bytes(n as u64); + } + local.flush().await.ok(); + // Preserve the executable/permission bits where sane (unix only, low 12 bits). + preserve_mode(lpath, mode); + Ok(()) +} + +async fn upload(sftp: &SftpSession, spec: &SftpTransferSpec, job: &Job) -> Result<(), String> { + let total = local_size(&spec.local, spec.recursive, job).await?; + job.set_total(total); + + let mut stack = vec![(spec.local.clone(), spec.remote.clone())]; + while let Some((lpath, rpath)) = stack.pop() { + if job.is_cancelled() { + return Err(cancelled()); + } + let meta = tokio::fs::metadata(&lpath) + .await + .map_err(|e| format!("stat {}: {e}", lpath.display()))?; + if meta.is_dir() { + if !spec.recursive { + return Err("local path is a directory (enable recursive)".to_string()); + } + // Create the remote dir (ignore "already exists"). + let _ = sftp.create_dir(rpath.clone()).await; + let mut read_dir = tokio::fs::read_dir(&lpath) + .await + .map_err(|e| format!("read local dir: {e}"))?; + while let Some(child) = read_dir + .next_entry() + .await + .map_err(|e| format!("read local dir: {e}"))? + { + let name = child.file_name().to_string_lossy().to_string(); + stack.push((child.path(), remote_join(&rpath, &name))); + } + } else { + upload_file(sftp, &lpath, &rpath, job).await?; + } + } + Ok(()) +} + +async fn upload_file( + sftp: &SftpSession, + lpath: &Path, + rpath: &str, + job: &Job, +) -> Result<(), String> { + job.set_current(rpath.to_string()); + let temp = upload_temp_name(rpath); + let mut local = tokio::fs::File::open(lpath) + .await + .map_err(|e| format!("open {}: {e}", lpath.display()))?; + let flags = OpenFlags::WRITE | OpenFlags::CREATE | OpenFlags::TRUNCATE; + let mut remote = match sftp.open_with_flags(temp.clone(), flags).await { + Ok(f) => f, + Err(e) => return Err(format!("open remote temp: {e}")), + }; + let mut buf = vec![0u8; CHUNK]; + let result: Result<(), String> = async { + loop { + if job.is_cancelled() { + return Err(cancelled()); + } + let n = local.read(&mut buf).await.map_err(|e| format!("{e}"))?; + if n == 0 { + break; + } + remote + .write_all(&buf[..n]) + .await + .map_err(|e| format!("write remote: {e}"))?; + job.add_bytes(n as u64); + } + remote.flush().await.ok(); + remote.shutdown().await.ok(); + Ok(()) + } + .await; + + if let Err(e) = result { + // Clean up the partial temp file, best effort. + let _ = sftp.remove_file(temp.clone()).await; + return Err(e); + } + // Swap the temp over the target. + if let Err(e) = rename_over(sftp, &temp, rpath).await { + let _ = sftp.remove_file(temp).await; + return Err(e); + } + Ok(()) +} + +/// Recursively sum remote file sizes (files only). Cancellation short-circuits. +async fn remote_size( + sftp: &SftpSession, + root: &str, + recursive: bool, + job: &Job, +) -> Result { + let mut total = 0u64; + let mut stack = vec![root.to_string()]; + while let Some(path) = stack.pop() { + if job.is_cancelled() { + return Err(cancelled()); + } + let attrs = match sftp.metadata(path.clone()).await { + Ok(a) => a, + Err(_) => continue, + }; + if attrs.is_dir() { + if !recursive { + continue; + } + if let Ok(read_dir) = sftp.read_dir(path.clone()).await { + for entry in read_dir { + let name = entry.file_name(); + if name == "." || name == ".." { + continue; + } + stack.push(remote_join(&path, &name)); + } + } + } else { + total = total.saturating_add(attrs.size.unwrap_or(0)); + } + } + Ok(total) +} + +/// Recursively sum local file sizes (files only). +async fn local_size(root: &Path, recursive: bool, job: &Job) -> Result { + let mut total = 0u64; + let mut stack = vec![root.to_path_buf()]; + while let Some(path) = stack.pop() { + if job.is_cancelled() { + return Err(cancelled()); + } + let meta = match tokio::fs::metadata(&path).await { + Ok(m) => m, + Err(_) => continue, + }; + if meta.is_dir() { + if !recursive { + continue; + } + if let Ok(mut rd) = tokio::fs::read_dir(&path).await { + while let Ok(Some(child)) = rd.next_entry().await { + stack.push(child.path()); + } + } + } else { + total = total.saturating_add(meta.len()); + } + } + Ok(total) +} + +/// Apply the sane low permission bits of a downloaded file locally (unix only). +#[cfg(unix)] +fn preserve_mode(path: &Path, mode: Option) { + use std::os::unix::fs::PermissionsExt; + if let Some(mode) = mode { + let bits = mode & 0o777; + if bits != 0 { + let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(bits)); + } + } +} + +#[cfg(not(unix))] +fn preserve_mode(_path: &Path, _mode: Option) {} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn remote_join_handles_root_and_nested_and_slashes() { + assert_eq!(remote_join("/", "file"), "/file"); + assert_eq!(remote_join("", "file"), "/file"); + assert_eq!(remote_join("/home/deploy", "src"), "/home/deploy/src"); + // Trailing/leading slashes are normalized to a single separator. + assert_eq!(remote_join("/home/deploy/", "/src"), "/home/deploy/src"); + // Unicode names survive intact. + assert_eq!(remote_join("/家", "文件"), "/家/文件"); + } + + #[test] + fn remote_parent_walks_up_and_stops_at_root() { + assert_eq!(remote_parent("/home/deploy/src"), "/home/deploy"); + assert_eq!(remote_parent("/home"), "/"); + assert_eq!(remote_parent("/"), "/"); + assert_eq!(remote_parent(""), "/"); + // Trailing slash ignored. + assert_eq!(remote_parent("/a/b/"), "/a"); + assert_eq!(remote_parent("/项目/子"), "/项目"); + } + + #[test] + fn remote_basename_extracts_final_component() { + assert_eq!(remote_basename("/a/b/c"), "c"); + assert_eq!(remote_basename("/a/b/"), "b"); + assert_eq!(remote_basename("/"), "/"); + assert_eq!(remote_basename("/项目/子"), "子"); + } + + #[test] + fn upload_temp_name_is_distinct_and_marked() { + let a = upload_temp_name("/dir/file.txt"); + let b = upload_temp_name("/dir/file.txt"); + assert!(a.starts_with("/dir/file.txt.tty7-upload-")); + assert!(b.starts_with("/dir/file.txt.tty7-upload-")); + // Two temp names for the same target must differ (counter component). + assert_ne!(a, b); + } + + #[test] + fn classify_prefers_symlink_over_regular_bit() { + // S_IFLNK carries the S_IFREG bit too; symlink must win. + let mut link = FileAttributes::empty(); + link.permissions = Some(0o120777); + assert_eq!(classify(&link), SftpEntryKind::Symlink); + + let mut dir = FileAttributes::empty(); + dir.permissions = Some(0o040755); + assert_eq!(classify(&dir), SftpEntryKind::Dir); + + let mut file = FileAttributes::empty(); + file.permissions = Some(0o100644); + assert_eq!(classify(&file), SftpEntryKind::File); + + // Unknown permissions default to file. + assert_eq!(classify(&FileAttributes::empty()), SftpEntryKind::File); + } + + #[test] + fn entry_from_attrs_maps_fields() { + let mut attrs = FileAttributes::empty(); + attrs.size = Some(4096); + attrs.mtime = Some(1_700_000_000); + attrs.permissions = Some(0o100644); + let e = entry_from_attrs("readme", &attrs); + assert_eq!(e.name, "readme"); + assert_eq!(e.kind, SftpEntryKind::File); + assert_eq!(e.size, 4096); + assert_eq!(e.mtime, 1_700_000_000); + assert_eq!(e.permissions, 0o100644); + assert!(!e.target_is_dir); + } + + #[test] + fn job_progress_transitions_are_monotonic_and_latch() { + let mut p = JobProgress::new(); + assert_eq!(p.state, SftpJobState::Running); + + p.set_total(1000); + p.set_current("a.bin"); + p.add_bytes(400); + p.add_bytes(200); + assert_eq!(p.bytes_total, 1000); + assert_eq!(p.bytes_done, 600); + assert_eq!(p.current, "a.bin"); + + p.finish(); + assert_eq!(p.state, SftpJobState::Done); + + // Terminal state latches: later transitions are ignored. + p.add_bytes(999); + p.fail("late error"); + p.cancel(); + assert_eq!(p.state, SftpJobState::Done); + assert_eq!(p.bytes_done, 600); + assert_eq!(p.error, None); + } + + #[test] + fn job_progress_cancel_and_fail_paths_latch() { + let mut c = JobProgress::new(); + c.cancel(); + assert_eq!(c.state, SftpJobState::Cancelled); + c.finish(); + assert_eq!(c.state, SftpJobState::Cancelled); + + let mut f = JobProgress::new(); + f.fail("boom"); + assert_eq!(f.state, SftpJobState::Error); + assert_eq!(f.error.as_deref(), Some("boom")); + f.finish(); + assert_eq!(f.state, SftpJobState::Error); + } + + #[test] + fn job_snapshot_reflects_progress() { + let job = Job { + id: 7, + pane_id: 3, + kind: SftpTransferKind::Download, + local: "/l".into(), + remote: "/r".into(), + cancel: AtomicBool::new(false), + progress: Mutex::new(JobProgress::new()), + done_at: Mutex::new(None), + }; + job.set_total(500); + job.set_current("f"); + job.add_bytes(120); + let snap = job.snapshot(); + assert_eq!(snap.job_id, 7); + assert_eq!(snap.pane_id, 3); + assert_eq!(snap.kind, SftpTransferKind::Download); + assert_eq!(snap.state, SftpJobState::Running); + assert_eq!(snap.bytes_total, 500); + assert_eq!(snap.bytes_done, 120); + assert_eq!(snap.current, "f"); + + job.finish(); + assert_eq!(job.snapshot().state, SftpJobState::Done); + assert!(!job.is_expired(), "just-finished job is not yet expired"); + } +} diff --git a/src/terminal/remote.rs b/src/terminal/remote.rs index ba0fb9ad..07e935c7 100644 --- a/src/terminal/remote.rs +++ b/src/terminal/remote.rs @@ -37,9 +37,10 @@ use std::collections::VecDeque; use crate::core::osc::OscTokenizer; use crate::daemon::protocol::{ - AuthPromptKind, AuthResponse, ClientMsg, DaemonMsg, KnownHostEntry, KnownHostId, LoopbackForward, - LoopbackForwardId, LoopbackForwardInfo, LoopbackForwardRequest, NativeSshSpec, RemoteContext, - ShellSpec, SshPhase, WinSize, + AuthPromptKind, AuthResponse, ClientMsg, DaemonMsg, KnownHostEntry, KnownHostId, + LoopbackForward, LoopbackForwardId, LoopbackForwardInfo, LoopbackForwardRequest, NativeSshSpec, + RemoteContext, ShellSpec, SftpEntry, SftpJobProgress, SftpOp, SftpOpResult, SftpTransferSpec, + SshPhase, WinSize, }; use crate::daemon::transport::{self, Stream}; @@ -1047,6 +1048,84 @@ impl RemoteTerminal { } query(id).unwrap_or_default() } + + // --- SFTP (Workstream 5) ------------------------------------------------- + // + // Each is a synchronous one-shot control request modeled on the loopback + // helpers above: connect, send one `ClientMsg`, read one `DaemonMsg`. SFTP + // targets a native-SSH pane; the daemon errors if `pane_id` isn't one. + + /// List a remote directory over the pane's SFTP session. + pub fn sftp_list(pane_id: u64, path: &str) -> Result, String> { + fn query(pane_id: u64, path: String) -> anyhow::Result, String>> { + let mut stream = connect()?; + ClientMsg::SftpList { pane_id, path }.encode(&mut stream)?; + Ok(match DaemonMsg::read(&mut stream)? { + DaemonMsg::SftpEntries(entries) => Ok(entries), + DaemonMsg::Error(msg) => Err(msg), + other => Err(format!("unexpected reply to SftpList: {other:?}")), + }) + } + query(pane_id, path.to_string()).unwrap_or_else(|e| Err(e.to_string())) + } + + /// Run a one-shot SFTP filesystem operation. + pub fn sftp_op(pane_id: u64, op: SftpOp) -> SftpOpResult { + fn query(pane_id: u64, op: SftpOp) -> anyhow::Result { + let mut stream = connect()?; + ClientMsg::SftpOp { pane_id, op }.encode(&mut stream)?; + Ok(match DaemonMsg::read(&mut stream)? { + DaemonMsg::SftpOpResult(result) => result, + DaemonMsg::Error(msg) => SftpOpResult::Error(msg), + other => SftpOpResult::Error(format!("unexpected reply to SftpOp: {other:?}")), + }) + } + query(pane_id, op).unwrap_or_else(|e| SftpOpResult::Error(e.to_string())) + } + + /// Start a background transfer job; returns its id. + pub fn sftp_transfer_start(spec: SftpTransferSpec) -> Result { + fn query(spec: SftpTransferSpec) -> anyhow::Result> { + let mut stream = connect()?; + ClientMsg::SftpTransferStart(spec).encode(&mut stream)?; + Ok(match DaemonMsg::read(&mut stream)? { + DaemonMsg::SftpTransferStarted { job_id } => Ok(job_id), + DaemonMsg::Error(msg) => Err(msg), + other => Err(format!("unexpected reply to SftpTransferStart: {other:?}")), + }) + } + query(spec).unwrap_or_else(|e| Err(e.to_string())) + } + + /// Cancel a transfer job; returns the pane's refreshed progress list. + pub fn sftp_transfer_cancel(job_id: u64) -> Vec { + fn query(job_id: u64) -> anyhow::Result> { + let mut stream = connect()?; + ClientMsg::SftpTransferCancel { job_id }.encode(&mut stream)?; + match DaemonMsg::read(&mut stream)? { + DaemonMsg::SftpTransferProgress(jobs) => Ok(jobs), + other => Err(anyhow::anyhow!( + "unexpected reply to SftpTransferCancel: {other:?}" + )), + } + } + query(job_id).unwrap_or_default() + } + + /// Poll the transfer jobs for a pane (drives the tray while it is visible). + pub fn sftp_transfer_list(pane_id: u64) -> Vec { + fn query(pane_id: u64) -> anyhow::Result> { + let mut stream = connect()?; + ClientMsg::SftpTransferList { pane_id }.encode(&mut stream)?; + match DaemonMsg::read(&mut stream)? { + DaemonMsg::SftpTransferProgress(jobs) => Ok(jobs), + other => Err(anyhow::anyhow!( + "unexpected reply to SftpTransferList: {other:?}" + )), + } + } + query(pane_id).unwrap_or_default() + } } fn daemon_disconnected_before_spawn_reply(err: &anyhow::Error) -> bool { diff --git a/src/ui/app.rs b/src/ui/app.rs index 35f0ebf6..bc364364 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -198,6 +198,8 @@ pub struct Tty7App { /// over the active SSH pane, but the input/editing state is app-owned so it /// is not tied to the Settings tab. pub(crate) loopback_panel: LoopbackForwardPanelState, + /// Pane-contextual SFTP file panel (WS5), bound to a focused native-SSH pane. + pub(crate) sftp_panel: crate::ui::sftp::SftpPanelState, /// Vertical tab sidebar width (px), held in a shared `Cell` so the resize /// drag's window-level mouse listener can mutate it without the entity handle /// (mirrors the split divider's `ratio`). Seeded from `Config::sidebar_width` @@ -263,6 +265,7 @@ impl Tty7App { .placeholder("3000") .default_value("") }); + let sftp_panel = crate::ui::sftp::SftpPanelState::new(window, cx); let sidebar_width = cx.global::().sidebar_width; // Live-apply hot-reloaded config: the watcher in `main.rs` swaps the // `Config` global on every `config.json` change, which fires this. Theme @@ -347,6 +350,7 @@ impl Tty7App { port_input: loopback_port_input, editing: None, }, + sftp_panel, sidebar_width: Rc::new(Cell::new(sidebar_width)), sidebar_dragging: Rc::new(Cell::new(false)), sidebar_scroll: gpui::ScrollHandle::new(), @@ -1697,6 +1701,7 @@ impl Tty7App { ReopenClosedTab => self.reopen_closed_tab(window, cx), OpenSettings => self.toggle_settings(window, cx), RestartDaemon => self.restart_daemon(window, cx), + ToggleSftp => self.toggle_sftp(window, cx), SetTheme(i) => { if let Some(id) = crate::ui::presets::all(cx).get(i).map(|t| t.id.clone()) { self.set_preset(&id, window, cx); @@ -2226,7 +2231,11 @@ impl Tty7App { self.settings.as_mut() } - fn active_ssh_pane(&self, window: &Window, cx: &App) -> Option<(u64, RemoteContext)> { + pub(crate) fn active_ssh_pane( + &self, + window: &Window, + cx: &App, + ) -> Option<(u64, RemoteContext)> { let pane = self .tabs .get(self.active)? @@ -2584,6 +2593,12 @@ impl Render for Tty7App { // the terminal area when the active pane is an SSH session. .when_some(active_ssh_pane, |this, (pane_id, remote)| { this.child(self.render_loopback_forward_overlay(pane_id, &remote, cx)) + // Pane-contextual SFTP panel (WS5), docked right when open for + // this (native-SSH) pane. + .when_some( + self.render_sftp_overlay(pane_id, &remote, window, cx), + |this, panel| this.child(panel), + ) }) // In-pane native-SSH auth / host-key sheet (WS3), shown over the pane // that raised the prompt. @@ -2762,6 +2777,7 @@ impl Render for Tty7App { .on_action( cx.listener(|this, _: &RestartDaemon, window, cx| this.restart_daemon(window, cx)), ) + .on_action(cx.listener(|this, _: &ToggleSftp, window, cx| this.toggle_sftp(window, cx))) // Quit lives on the same element-tree action path as every other Cmd // shortcut above, so a focused terminal routes `cmd-q` here rather // than relying solely on the global handler (which the keystroke diff --git a/src/ui/keymap.rs b/src/ui/keymap.rs index 1f40b506..b6a0407b 100644 --- a/src/ui/keymap.rs +++ b/src/ui/keymap.rs @@ -162,6 +162,9 @@ pub(crate) fn default_bindings() -> Vec<(&'static str, &'static str)> { // Like Terminal.app / iTerm2 / Ghostty ⌘K: wipe the screen + scrollback. ("ClearScrollback", "secondary-k"), ("OpenSettings", "secondary-,"), + // No default chord — reachable from the command palette ("SFTP Panel") and + // bindable in Settings like any other action. + ("ToggleSftp", ""), ("Quit", "secondary-q"), ] } @@ -439,6 +442,7 @@ fn make_binding(action: &str, keystroke: &str) -> Option { // dead global chord there. "ClearScrollback" => KeyBinding::new(keystroke, ClearScrollback, Some("Terminal")), "OpenSettings" => KeyBinding::new(keystroke, OpenSettings, None), + "ToggleSftp" => KeyBinding::new(keystroke, ToggleSftp, None), "Quit" => KeyBinding::new(keystroke, Quit, None), _ => return None, }) diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 446fab55..2f994636 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -15,6 +15,7 @@ pub mod pane; pub mod perf; pub mod presets; pub mod settings; +pub mod sftp; pub mod ssh_connect; pub mod ssh_prompt; pub mod tab_sidebar; diff --git a/src/ui/palette.rs b/src/ui/palette.rs index c6f37652..512aa001 100644 --- a/src/ui/palette.rs +++ b/src/ui/palette.rs @@ -54,6 +54,8 @@ pub enum CommandKind { ReopenClosedTab, OpenSettings, RestartDaemon, + /// Toggle the SFTP file panel for the focused native-SSH pane (WS5). + ToggleSftp, /// Opens the theme sub-list (a nested palette). Handled in `PaletteView`. OpenThemePicker, /// Opens a typed SSH connection sub-list. Handled in `PaletteView`. @@ -104,6 +106,7 @@ impl CommandKind { ReopenClosedTab => "ReopenClosedTab", OpenSettings => "OpenSettings", RestartDaemon => "RestartDaemon", + ToggleSftp => "ToggleSftp", FindInTerminal | OpenThemePicker | OpenSshConnectInput @@ -167,6 +170,7 @@ impl Command { Command::new("Find in Terminal…", FindInTerminal), Command::new("Reopen Closed Tab", ReopenClosedTab), Command::new("SSH: Add Connection…", OpenSshConnectInput), + Command::new("SFTP Panel", ToggleSftp), Command::new("Change Theme…", OpenThemePicker), Command::new("Open Settings", OpenSettings), Command::new("Reset Font Size", ResetFontSize), diff --git a/src/ui/sftp.rs b/src/ui/sftp.rs new file mode 100644 index 00000000..de9168a3 --- /dev/null +++ b/src/ui/sftp.rs @@ -0,0 +1,1152 @@ +//! Pane-contextual SFTP file panel (Workstream 5). +//! +//! Renders as a right-docked, slide-in panel over the terminal body area for the +//! focused **native-SSH** pane (a compat-`ssh` or PTY pane has no russh connection +//! and never shows it). Mirrors the `ui::forwards` pattern: a set of +//! `impl Tty7App` render helpers plus a [`SftpPanelState`] held on `Tty7App`, and +//! synchronous one-shot [`RemoteTerminal`] control calls to the daemon +//! (`sftp_list` / `sftp_op` / `sftp_transfer_*`). +//! +//! Layout: a breadcrumb path bar, a filter box, a toolbar (up / refresh / new +//! folder / upload / go-to-shell-cwd), a dir-first entry list with per-row actions +//! (download / rename / delete / chmod), an inline edit form, and a bottom +//! transfer tray that polls job progress while the panel is open. + +use std::path::PathBuf; +use std::time::Duration; + +use gpui::{ + AnyElement, App, Context, Div, ExternalPaths, FontWeight, PathPromptOptions, SharedString, + Stateful, Subscription, Window, div, prelude::*, px, +}; +use gpui_component::button::{Button, ButtonVariants as _}; +use gpui_component::input::{Input, InputState}; +use gpui_component::{ + ActiveTheme as _, Disableable as _, Icon, IconName, Sizable as _, h_flex, v_flex, +}; + +use crate::daemon::protocol::{ + RemoteContext, RemoteKind, SftpEntry, SftpEntryKind, SftpJobProgress, SftpJobState, SftpOp, + SftpOpResult, SftpTransferKind, SftpTransferSpec, +}; +use crate::daemon::ssh::sftp::{remote_basename, remote_join, remote_parent}; +use crate::terminal::RemoteTerminal; +use crate::ui::app::Tty7App; + +/// Default and clamp range for the panel's width (px). +const SFTP_PANEL_WIDTH: f32 = 380.0; + +/// One in-progress inline edit form in the panel. +pub(crate) enum SftpEdit { + NewFolder(gpui::Entity), + Rename { + original: String, + input: gpui::Entity, + }, + Chmod { + path: String, + input: gpui::Entity, + }, +} + +/// State for the SFTP side panel. One panel at a time, bound to a pane id. +pub(crate) struct SftpPanelState { + pub(crate) open_pane_id: Option, + /// The remote directory currently listed (absolute POSIX path). + pub(crate) cwd: String, + pub(crate) entries: Vec, + pub(crate) filter_input: gpui::Entity, + /// Last listing error, shown in place of the list. + pub(crate) error: Option, + /// Latest transfer-job snapshots for the tray. + pub(crate) jobs: Vec, + pub(crate) width: f32, + pub(crate) editing: Option, + /// Bumped on every (re)open so a stale poll loop exits. + pub(crate) poll_gen: u64, + _subs: Vec, +} + +impl SftpPanelState { + pub(crate) fn new(window: &mut Window, cx: &mut Context) -> Self { + let filter_input = cx.new(|cx| InputState::new(window, cx).placeholder("Filter")); + // Re-render the panel (and thus re-filter the list) on every keystroke. + let sub = cx.subscribe_in(&filter_input, window, |_this, _input, ev, _w, cx| { + if matches!(ev, gpui_component::input::InputEvent::Change) { + cx.notify(); + } + }); + Self { + open_pane_id: None, + cwd: "/".to_string(), + entries: Vec::new(), + filter_input, + error: None, + jobs: Vec::new(), + width: SFTP_PANEL_WIDTH, + editing: None, + poll_gen: 0, + _subs: vec![sub], + } + } +} + +// --------------------------------------------------------------------------- +// Pure helpers (tested). +// --------------------------------------------------------------------------- + +fn is_dir_like(e: &SftpEntry) -> bool { + matches!(e.kind, SftpEntryKind::Dir) + || (matches!(e.kind, SftpEntryKind::Symlink) && e.target_is_dir) +} + +/// Directory-first, then case-insensitive by name; substring-filtered (case +/// insensitive). Returns borrows into `entries` in display order. +pub(crate) fn sorted_filtered_entries<'a>( + entries: &'a [SftpEntry], + filter: &str, +) -> Vec<&'a SftpEntry> { + let needle = filter.to_lowercase(); + let mut out: Vec<&SftpEntry> = entries + .iter() + .filter(|e| needle.is_empty() || e.name.to_lowercase().contains(&needle)) + .collect(); + out.sort_by(|a, b| { + let (ad, bd) = (is_dir_like(a), is_dir_like(b)); + // Directories first, then name. + bd.cmp(&ad) + .then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase())) + }); + out +} + +/// Split a remote path into clickable breadcrumb segments: `(label, full_path)`, +/// always starting with the root `("/", "/")`. +pub(crate) fn breadcrumb_segments(path: &str) -> Vec<(String, String)> { + let mut out = vec![("/".to_string(), "/".to_string())]; + let mut acc = String::new(); + for comp in path.split('/').filter(|s| !s.is_empty()) { + acc.push('/'); + acc.push_str(comp); + out.push((comp.to_string(), acc.clone())); + } + out +} + +/// Compact human-readable byte size (`1.5M`). +fn human_size(bytes: u64) -> String { + const UNITS: [&str; 5] = ["B", "K", "M", "G", "T"]; + let mut value = bytes as f64; + let mut unit = 0; + while value >= 1024.0 && unit < UNITS.len() - 1 { + value /= 1024.0; + unit += 1; + } + if unit == 0 { + format!("{bytes} B") + } else { + format!("{value:.1}{}", UNITS[unit]) + } +} + +/// A `-rwxr-xr-x`-style mode string from Unix permission bits (low 9 bits). +fn mode_string(mode: u32) -> String { + let rwx = |bits: u32| { + format!( + "{}{}{}", + if bits & 0o4 != 0 { 'r' } else { '-' }, + if bits & 0o2 != 0 { 'w' } else { '-' }, + if bits & 0o1 != 0 { 'x' } else { '-' }, + ) + }; + format!( + "{}{}{}", + rwx((mode >> 6) & 0o7), + rwx((mode >> 3) & 0o7), + rwx(mode & 0o7) + ) +} + +/// The daemon-process home directory used as the local base for transfers. +fn local_home() -> PathBuf { + std::env::var_os("HOME") + .or_else(|| std::env::var_os("USERPROFILE")) + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(".")) +} + +/// Where downloads land locally: `~/Downloads` (created on demand by the daemon). +fn local_download_dir() -> PathBuf { + local_home().join("Downloads") +} + +// --------------------------------------------------------------------------- +// Tty7App: open / navigate / operations. +// --------------------------------------------------------------------------- + +impl Tty7App { + /// Toggle the SFTP panel for the focused native-SSH pane. A no-op (with a + /// gentle close) when the focused pane isn't native-SSH. + pub(crate) fn toggle_sftp(&mut self, window: &mut Window, cx: &mut Context) { + let native = self + .active_ssh_pane(window, cx) + .filter(|(_, remote)| remote.kind == RemoteKind::NativeSsh); + match native { + Some((pane_id, _)) if self.sftp_panel.open_pane_id == Some(pane_id) => { + self.close_sftp_panel(cx); + } + Some((pane_id, _)) => self.sftp_open_at(pane_id, window, cx), + // Focused pane can't do SFTP: close any panel that was open. + None => self.close_sftp_panel(cx), + } + } + + pub(crate) fn close_sftp_panel(&mut self, cx: &mut Context) { + self.sftp_panel.open_pane_id = None; + self.sftp_panel.editing = None; + // Invalidate the poll loop. + self.sftp_panel.poll_gen = self.sftp_panel.poll_gen.wrapping_add(1); + cx.notify(); + } + + fn sftp_open_at(&mut self, pane_id: u64, window: &mut Window, cx: &mut Context) { + self.sftp_panel.open_pane_id = Some(pane_id); + self.sftp_panel.editing = None; + // Start at the shell's OSC-7 cwd when known, else the filesystem root. + let start = self.pane_shell_cwd(pane_id, window, cx).unwrap_or_else(|| "/".to_string()); + self.sftp_navigate(start, cx); + self.sftp_poll_jobs(cx); + self.sftp_start_polling(cx); + } + + /// The focused pane's OSC-7 cwd as an absolute remote path, if tracked. + fn pane_shell_cwd(&self, pane_id: u64, window: &Window, cx: &App) -> Option { + let leaf = self.tabs.get(self.active)?.pane.focused_or_first(window, cx)?; + let leaf = leaf.read(cx); + if leaf.pane_id != pane_id { + return None; + } + let path = leaf.cwd()?; + let s = path.to_string_lossy().to_string(); + s.starts_with('/').then_some(s) + } + + /// FR-T4: navigate to the shell's current cwd (button only enabled when known). + pub(crate) fn sftp_go_shell_cwd(&mut self, window: &mut Window, cx: &mut Context) { + if let Some(pane_id) = self.sftp_panel.open_pane_id + && let Some(cwd) = self.pane_shell_cwd(pane_id, window, cx) + { + self.sftp_navigate(cwd, cx); + } + } + + /// List `path` on the pane's SFTP session and show it. Errors are surfaced in + /// the panel body rather than thrown away. + pub(crate) fn sftp_navigate(&mut self, path: String, cx: &mut Context) { + let Some(pane_id) = self.sftp_panel.open_pane_id else { + return; + }; + match RemoteTerminal::sftp_list(pane_id, &path) { + Ok(mut entries) => { + entries.sort_by(|a, b| a.name.cmp(&b.name)); + self.sftp_panel.cwd = path; + self.sftp_panel.entries = entries; + self.sftp_panel.error = None; + } + Err(e) => { + // Keep the old listing; just report the failure. + self.sftp_panel.error = Some(e); + } + } + cx.notify(); + } + + pub(crate) fn sftp_refresh(&mut self, cx: &mut Context) { + let cwd = self.sftp_panel.cwd.clone(); + self.sftp_navigate(cwd, cx); + } + + pub(crate) fn sftp_up(&mut self, cx: &mut Context) { + let parent = remote_parent(&self.sftp_panel.cwd); + self.sftp_navigate(parent, cx); + } + + /// Click on an entry: enter a directory (or symlink-to-directory), or download + /// a file/other symlink. + pub(crate) fn sftp_open_entry(&mut self, entry: SftpEntry, cx: &mut Context) { + let target = remote_join(&self.sftp_panel.cwd, &entry.name); + if is_dir_like(&entry) { + self.sftp_navigate(target, cx); + } else { + self.sftp_download_entry(entry, cx); + } + } + + pub(crate) fn sftp_download_entry(&mut self, entry: SftpEntry, cx: &mut Context) { + let Some(pane_id) = self.sftp_panel.open_pane_id else { + return; + }; + let remote = remote_join(&self.sftp_panel.cwd, &entry.name); + let local = local_download_dir().join(&entry.name); + let recursive = matches!(entry.kind, SftpEntryKind::Dir); + let spec = SftpTransferSpec { + pane_id, + kind: SftpTransferKind::Download, + local, + remote, + recursive, + }; + match RemoteTerminal::sftp_transfer_start(spec) { + Ok(_) => self.sftp_panel.error = None, + Err(e) => self.sftp_panel.error = Some(e), + } + self.sftp_poll_jobs(cx); + self.sftp_start_polling(cx); + } + + pub(crate) fn sftp_delete_entry(&mut self, entry: SftpEntry, cx: &mut Context) { + let Some(pane_id) = self.sftp_panel.open_pane_id else { + return; + }; + let path = remote_join(&self.sftp_panel.cwd, &entry.name); + // A directory (not a symlink to one) deletes recursively; everything else + // is a plain file unlink. + let op = if matches!(entry.kind, SftpEntryKind::Dir) { + SftpOp::RemoveDir { path } + } else { + SftpOp::RemoveFile { path } + }; + self.sftp_run_op(pane_id, op, cx); + } + + /// Follow a symlink: readlink, then navigate to the resolved target's + /// directory (or the target itself when it is a directory). + pub(crate) fn sftp_follow_symlink(&mut self, entry: SftpEntry, cx: &mut Context) { + let Some(pane_id) = self.sftp_panel.open_pane_id else { + return; + }; + let path = remote_join(&self.sftp_panel.cwd, &entry.name); + match RemoteTerminal::sftp_op(pane_id, SftpOp::Readlink { path }) { + SftpOpResult::Link(target) => { + let resolved = if target.starts_with('/') { + target + } else { + remote_join(&self.sftp_panel.cwd, &target) + }; + // Navigate to the target if it's a directory, else its parent. + let dest = if entry.target_is_dir { + resolved + } else { + remote_parent(&resolved) + }; + self.sftp_navigate(dest, cx); + } + SftpOpResult::Error(e) => { + self.sftp_panel.error = Some(e); + cx.notify(); + } + _ => {} + } + } + + fn sftp_run_op(&mut self, pane_id: u64, op: SftpOp, cx: &mut Context) { + match RemoteTerminal::sftp_op(pane_id, op) { + SftpOpResult::Error(e) => { + self.sftp_panel.error = Some(e); + cx.notify(); + } + _ => { + self.sftp_panel.editing = None; + self.sftp_refresh(cx); + } + } + } + + // --- inline edit forms ------------------------------------------------- + + pub(crate) fn sftp_begin_new_folder(&mut self, window: &mut Window, cx: &mut Context) { + let input = cx.new(|cx| InputState::new(window, cx).placeholder("New folder name")); + self.sftp_panel.editing = Some(SftpEdit::NewFolder(input)); + cx.notify(); + } + + pub(crate) fn sftp_begin_rename( + &mut self, + name: String, + window: &mut Window, + cx: &mut Context, + ) { + let input = cx.new(|cx| InputState::new(window, cx).default_value(name.clone())); + self.sftp_panel.editing = Some(SftpEdit::Rename { + original: name, + input, + }); + cx.notify(); + } + + pub(crate) fn sftp_begin_chmod( + &mut self, + entry: SftpEntry, + window: &mut Window, + cx: &mut Context, + ) { + let octal = format!("{:o}", entry.permissions & 0o777); + let path = remote_join(&self.sftp_panel.cwd, &entry.name); + let input = cx.new(|cx| InputState::new(window, cx).default_value(octal)); + self.sftp_panel.editing = Some(SftpEdit::Chmod { path, input }); + cx.notify(); + } + + pub(crate) fn sftp_cancel_edit(&mut self, cx: &mut Context) { + self.sftp_panel.editing = None; + cx.notify(); + } + + pub(crate) fn sftp_commit_edit(&mut self, cx: &mut Context) { + let Some(pane_id) = self.sftp_panel.open_pane_id else { + return; + }; + let op = match &self.sftp_panel.editing { + Some(SftpEdit::NewFolder(input)) => { + let name = input.read(cx).value().trim().to_string(); + if name.is_empty() { + return; + } + Some(SftpOp::Mkdir { + path: remote_join(&self.sftp_panel.cwd, &name), + }) + } + Some(SftpEdit::Rename { original, input }) => { + let name = input.read(cx).value().trim().to_string(); + if name.is_empty() || name == *original { + self.sftp_panel.editing = None; + cx.notify(); + return; + } + Some(SftpOp::Rename { + from: remote_join(&self.sftp_panel.cwd, original), + to: remote_join(&self.sftp_panel.cwd, &name), + }) + } + Some(SftpEdit::Chmod { path, input }) => { + match u32::from_str_radix(input.read(cx).value().trim(), 8) { + Ok(mode) => Some(SftpOp::Chmod { + path: path.clone(), + mode, + }), + Err(_) => { + self.sftp_panel.error = Some("invalid octal mode".to_string()); + cx.notify(); + return; + } + } + } + None => None, + }; + if let Some(op) = op { + self.sftp_run_op(pane_id, op, cx); + } + } + + // --- uploads (picker + drag&drop) -------------------------------------- + + /// FR-T5 fallback / toolbar action: open a native file picker and upload the + /// chosen paths into the current remote directory. + pub(crate) fn sftp_pick_upload(&mut self, cx: &mut Context) { + if self.sftp_panel.open_pane_id.is_none() { + return; + } + let rx = cx.prompt_for_paths(PathPromptOptions { + files: true, + directories: true, + multiple: true, + prompt: None, + }); + cx.spawn(async move |this, cx| { + if let Ok(Ok(Some(paths))) = rx.await { + let _ = this.update(cx, |this, cx| this.sftp_upload_paths(paths, cx)); + } + }) + .detach(); + } + + /// Upload local paths into the current remote directory (used by the picker + /// and by FR-T5 Finder drops). Directories upload recursively. + pub(crate) fn sftp_upload_paths(&mut self, paths: Vec, cx: &mut Context) { + let Some(pane_id) = self.sftp_panel.open_pane_id else { + return; + }; + let cwd = self.sftp_panel.cwd.clone(); + for path in paths { + let name = path + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_default(); + if name.is_empty() { + continue; + } + let recursive = path.is_dir(); + let spec = SftpTransferSpec { + pane_id, + kind: SftpTransferKind::Upload, + local: path, + remote: remote_join(&cwd, &name), + recursive, + }; + if let Err(e) = RemoteTerminal::sftp_transfer_start(spec) { + self.sftp_panel.error = Some(e); + } + } + self.sftp_poll_jobs(cx); + self.sftp_start_polling(cx); + // A little later the uploaded entries will exist; refresh the listing now + // so at least already-finished small files appear. + self.sftp_refresh(cx); + } + + // --- transfer tray ----------------------------------------------------- + + pub(crate) fn sftp_cancel_job(&mut self, job_id: u64, cx: &mut Context) { + self.sftp_panel.jobs = RemoteTerminal::sftp_transfer_cancel(job_id); + cx.notify(); + } + + fn sftp_poll_jobs(&mut self, cx: &mut Context) { + if let Some(pane_id) = self.sftp_panel.open_pane_id { + self.sftp_panel.jobs = RemoteTerminal::sftp_transfer_list(pane_id); + cx.notify(); + } + } + + /// Spawn a background poll loop that refreshes the tray every 500ms while the + /// panel is open. `poll_gen` guards against overlapping loops after re-opens. + fn sftp_start_polling(&mut self, cx: &mut Context) { + self.sftp_panel.poll_gen = self.sftp_panel.poll_gen.wrapping_add(1); + let generation = self.sftp_panel.poll_gen; + cx.spawn(async move |this, cx| { + loop { + cx.background_executor() + .timer(Duration::from_millis(500)) + .await; + // Read the pane still bound to this generation. + let pane = this + .update(cx, |this, _| { + if this.sftp_panel.poll_gen != generation { + None + } else { + this.sftp_panel.open_pane_id + } + }) + .ok() + .flatten(); + let Some(pane_id) = pane else { break }; + // Poll off the main thread so the blocking control round-trip + // doesn't jank the UI. + let jobs = cx + .background_spawn(async move { RemoteTerminal::sftp_transfer_list(pane_id) }) + .await; + let keep = this + .update(cx, |this, cx| { + if this.sftp_panel.poll_gen != generation { + return false; + } + this.sftp_panel.jobs = jobs; + cx.notify(); + true + }) + .unwrap_or(false); + if !keep { + break; + } + } + }) + .detach(); + } + + // --------------------------------------------------------------------- + // Rendering. + // --------------------------------------------------------------------- + + /// The right-docked SFTP panel, mounted over the terminal body when open for + /// `pane_id`. Returns `None` when not open for this pane. + pub(crate) fn render_sftp_overlay( + &self, + pane_id: u64, + _remote: &RemoteContext, + window: &Window, + cx: &mut Context, + ) -> Option { + if self.sftp_panel.open_pane_id != Some(pane_id) { + return None; + } + let popover = cx.theme().popover; + let border = cx.theme().border; + let shell_cwd = self.pane_shell_cwd(pane_id, window, cx); + + let panel = v_flex() + .id("sftp-panel") + .absolute() + .top_0() + .right_0() + .bottom_0() + .w(px(self.sftp_panel.width)) + .bg(popover) + .border_l_1() + .border_color(border) + .shadow_lg() + .child(self.render_sftp_header(pane_id, shell_cwd.is_some(), cx)) + .child(self.render_sftp_breadcrumb(cx)) + .child(self.render_sftp_filter()) + .when_some( + self.render_sftp_edit_form(cx), + |this, form| this.child(form), + ) + .child(self.render_sftp_list(cx)) + .when_some(self.render_sftp_tray(cx), |this, tray| this.child(tray)) + // FR-T5: a Finder drop uploads onto the current directory. + .on_drop(cx.listener(|this, paths: &ExternalPaths, _window, cx| { + this.sftp_upload_paths(paths.paths().to_vec(), cx); + })); + + Some(panel.into_any_element()) + } + + fn render_sftp_header( + &self, + pane_id: u64, + has_shell_cwd: bool, + cx: &mut Context, + ) -> Div { + let border = cx.theme().border; + let foreground = cx.theme().foreground; + let toolbar = h_flex() + .gap_1() + .child( + Button::new("sftp-up") + .label("Up") + .small() + .on_click(cx.listener(|this, _, _w, cx| this.sftp_up(cx))), + ) + .child( + Button::new("sftp-refresh") + .label("Refresh") + .small() + .on_click(cx.listener(|this, _, _w, cx| this.sftp_refresh(cx))), + ) + .child( + Button::new("sftp-newfolder") + .label("New Folder") + .small() + .on_click(cx.listener(|this, _, window, cx| { + this.sftp_begin_new_folder(window, cx) + })), + ) + .child( + Button::new("sftp-upload") + .label("Upload") + .small() + .on_click(cx.listener(|this, _, _w, cx| this.sftp_pick_upload(cx))), + ) + .child( + Button::new("sftp-shell-cwd") + .label("Shell cwd") + .small() + .disabled(!has_shell_cwd) + .on_click(cx.listener(|this, _, window, cx| { + this.sftp_go_shell_cwd(window, cx) + })), + ); + + v_flex() + .gap_2() + .p_2() + .border_b_1() + .border_color(border) + .child( + h_flex() + .items_center() + .justify_between() + .child( + div() + .text_sm() + .font_weight(FontWeight::MEDIUM) + .text_color(foreground) + .child("SFTP"), + ) + .child( + Button::new(("sftp-close", pane_id)) + .icon(IconName::Close) + .small() + .ghost() + .on_click(cx.listener(|this, _, _w, cx| this.close_sftp_panel(cx))), + ), + ) + .child(toolbar) + } + + fn render_sftp_breadcrumb(&self, cx: &mut Context) -> Div { + let muted = cx.theme().muted_foreground; + let accent = cx.theme().accent; + let mut row = h_flex() + .flex_wrap() + .items_center() + .gap_0p5() + .px_2() + .py_1(); + for (i, (label, path)) in breadcrumb_segments(&self.sftp_panel.cwd).into_iter().enumerate() { + if i > 0 { + row = row.child(div().text_xs().text_color(muted).child("›")); + } + let seg_id = SharedString::from(format!("sftp-crumb-{path}")); + row = row.child( + div() + .id(seg_id) + .text_xs() + .text_color(accent) + .cursor_pointer() + .hover(|s| s.underline()) + .child(label) + .on_click(cx.listener(move |this, _, _w, cx| { + this.sftp_navigate(path.clone(), cx) + })), + ); + } + row + } + + fn render_sftp_filter(&self) -> Div { + h_flex() + .px_2() + .pb_1() + .child(Input::new(&self.sftp_panel.filter_input).small()) + } + + /// The active inline edit form (new folder / rename / chmod), if any. + fn render_sftp_edit_form(&self, cx: &mut Context) -> Option
{ + let secondary = cx.theme().secondary; + let border = cx.theme().border; + let foreground = cx.theme().foreground; + let (title, input) = match self.sftp_panel.editing.as_ref()? { + SftpEdit::NewFolder(input) => ("New folder", input), + SftpEdit::Rename { input, .. } => ("Rename", input), + SftpEdit::Chmod { input, .. } => ("Permissions (octal)", input), + }; + Some( + v_flex() + .gap_2() + .m_2() + .p_2() + .bg(secondary) + .border_1() + .border_color(border) + .rounded_md() + .child( + div() + .text_xs() + .font_weight(FontWeight::MEDIUM) + .text_color(foreground) + .child(title), + ) + .child(Input::new(input).small()) + .child( + h_flex() + .gap_2() + .justify_end() + .child( + Button::new("sftp-edit-cancel") + .label("Cancel") + .small() + .on_click(cx.listener(|this, _, _w, cx| this.sftp_cancel_edit(cx))), + ) + .child( + Button::new("sftp-edit-ok") + .label("OK") + .small() + .primary() + .on_click(cx.listener(|this, _, _w, cx| this.sftp_commit_edit(cx))), + ), + ), + ) + } + + fn render_sftp_list(&self, cx: &mut Context) -> Stateful
{ + let danger = cx.theme().danger; + let muted = cx.theme().muted_foreground; + let container = div() + .id("sftp-list") + .flex_1() + .min_h_0() + .overflow_y_scroll() + .px_1(); + + if let Some(err) = &self.sftp_panel.error { + return container.child( + div() + .p_3() + .text_xs() + .text_color(danger) + .child(err.clone()), + ); + } + + let filter = self.sftp_panel.filter_input.read(cx).value().to_string(); + let entries = sorted_filtered_entries(&self.sftp_panel.entries, &filter); + if entries.is_empty() { + return container.child( + div() + .p_3() + .text_xs() + .text_color(muted) + .child("Empty directory."), + ); + } + + let mut list = v_flex().gap_0p5().py_1(); + for entry in entries { + list = list.child(self.render_sftp_row(entry, cx)); + } + container.child(list) + } + + fn render_sftp_row(&self, entry: &SftpEntry, cx: &mut Context) -> Stateful
{ + let foreground = cx.theme().foreground; + let muted = cx.theme().muted_foreground; + let accent = cx.theme().accent; + let list_hover = cx.theme().list_hover; + let entry = entry.clone(); + let dir_like = is_dir_like(&entry); + let icon = if dir_like { + IconName::Folder + } else { + IconName::File + }; + let is_symlink = matches!(entry.kind, SftpEntryKind::Symlink); + let size = if dir_like { + String::new() + } else { + human_size(entry.size) + }; + let name_label = if is_symlink { + format!("{} →", entry.name) + } else { + entry.name.clone() + }; + let row_id = SharedString::from(format!("sftp-row-{}", entry.name)); + + let open_entry = entry.clone(); + let del_entry = entry.clone(); + let rename_name = entry.name.clone(); + let chmod_entry = entry.clone(); + let follow_entry = entry.clone(); + + let name_id = SharedString::from(format!("sftp-name-{}", entry.name)); + h_flex() + .id(row_id) + .items_center() + .gap_2() + .px_2() + .py_1() + .rounded_md() + .hover(|s| s.bg(list_hover)) + .child( + Icon::new(icon) + .small() + .text_color(if dir_like { accent } else { muted }), + ) + .child( + div() + .id(name_id) + .flex_1() + .min_w_0() + .text_sm() + .text_color(foreground) + .cursor_pointer() + .truncate() + .child(name_label) + .on_click(cx.listener(move |this, _, _w, cx| { + this.sftp_open_entry(open_entry.clone(), cx) + })), + ) + .child( + v_flex() + .items_end() + .child(div().text_xs().text_color(muted).child(size)) + .when(entry.permissions != 0, |this| { + this.child( + div() + .text_xs() + .font_family("monospace") + .text_color(muted) + .child(mode_string(entry.permissions)), + ) + }), + ) + .child( + h_flex() + .gap_0p5() + .when(is_symlink, |row| { + row.child( + Button::new(SharedString::from(format!("sftp-fl-{}", entry.name))) + .label("Follow") + .xsmall() + .ghost() + .on_click(cx.listener(move |this, _, _w, cx| { + this.sftp_follow_symlink(follow_entry.clone(), cx) + })), + ) + }) + .child( + Button::new(SharedString::from(format!("sftp-dl-{}", entry.name))) + .label("↓") + .xsmall() + .ghost() + .on_click(cx.listener({ + let e = entry.clone(); + move |this, _, _w, cx| this.sftp_download_entry(e.clone(), cx) + })), + ) + .child( + Button::new(SharedString::from(format!("sftp-rn-{}", entry.name))) + .label("Rename") + .xsmall() + .ghost() + .on_click(cx.listener(move |this, _, window, cx| { + this.sftp_begin_rename(rename_name.clone(), window, cx) + })), + ) + .child( + Button::new(SharedString::from(format!("sftp-cm-{}", entry.name))) + .label("chmod") + .xsmall() + .ghost() + .on_click(cx.listener(move |this, _, window, cx| { + this.sftp_begin_chmod(chmod_entry.clone(), window, cx) + })), + ) + .child( + Button::new(SharedString::from(format!("sftp-del-{}", entry.name))) + .label("Delete") + .xsmall() + .ghost() + .on_click(cx.listener(move |this, _, _w, cx| { + this.sftp_delete_entry(del_entry.clone(), cx) + })), + ), + ) + } + + /// The bottom transfer tray, if there are any jobs to show. + fn render_sftp_tray(&self, cx: &mut Context) -> Option
{ + if self.sftp_panel.jobs.is_empty() { + return None; + } + let border = cx.theme().border; + let secondary = cx.theme().secondary; + let muted = cx.theme().muted_foreground; + let mut list = v_flex().gap_1(); + for job in &self.sftp_panel.jobs { + list = list.child(self.render_sftp_job(job, cx)); + } + Some( + v_flex() + .gap_1() + .p_2() + .border_t_1() + .border_color(border) + .bg(secondary) + .child( + div() + .text_xs() + .font_weight(FontWeight::MEDIUM) + .text_color(muted) + .child("Transfers"), + ) + .child(list), + ) + } + + fn render_sftp_job(&self, job: &SftpJobProgress, cx: &mut Context) -> Div { + let foreground = cx.theme().foreground; + let border = cx.theme().border; + let danger = cx.theme().danger; + let success = cx.theme().success; + let muted = cx.theme().muted_foreground; + let accent = cx.theme().accent; + let arrow = match job.kind { + SftpTransferKind::Upload => "↑", + SftpTransferKind::Download => "↓", + }; + let name = remote_basename(&job.remote); + let pct = if job.bytes_total > 0 { + ((job.bytes_done as f64 / job.bytes_total as f64) * 100.0).min(100.0) + } else { + 0.0 + }; + let status = match job.state { + SftpJobState::Running => format!( + "{} / {} ({pct:.0}%)", + human_size(job.bytes_done), + human_size(job.bytes_total) + ), + SftpJobState::Done => "done".to_string(), + SftpJobState::Cancelled => "cancelled".to_string(), + SftpJobState::Error => job.error.clone().unwrap_or_else(|| "error".to_string()), + }; + let status_color = match job.state { + SftpJobState::Error => danger, + SftpJobState::Done => success, + _ => muted, + }; + let bar_color = if matches!(job.state, SftpJobState::Error) { + danger + } else { + accent + }; + let job_id = job.job_id; + let running = matches!(job.state, SftpJobState::Running); + + v_flex() + .gap_0p5() + .child( + h_flex() + .items_center() + .gap_2() + .child( + div() + .flex_1() + .min_w_0() + .text_xs() + .text_color(foreground) + .truncate() + .child(format!("{arrow} {name}")), + ) + .when(running, |this| { + this.child( + Button::new(("sftp-cancel-job", job_id as usize)) + .label("✕") + .xsmall() + .ghost() + .on_click(cx.listener(move |this, _, _w, cx| { + this.sftp_cancel_job(job_id, cx) + })), + ) + }), + ) + .child( + // A thin progress bar. + div().h(px(3.)).w_full().rounded_full().bg(border).child( + div() + .h_full() + .w(gpui::relative((pct / 100.0) as f32)) + .rounded_full() + .bg(bar_color), + ), + ) + .child( + div() + .text_xs() + .text_color(status_color) + .child(status), + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn entry(name: &str, kind: SftpEntryKind, target_is_dir: bool) -> SftpEntry { + SftpEntry { + name: name.to_string(), + kind, + size: 0, + mtime: 0, + permissions: 0, + target_is_dir, + } + } + + #[test] + fn breadcrumb_segments_splits_absolute_paths() { + assert_eq!(breadcrumb_segments("/"), vec![("/".into(), "/".into())]); + assert_eq!( + breadcrumb_segments("/home/deploy"), + vec![ + ("/".to_string(), "/".to_string()), + ("home".to_string(), "/home".to_string()), + ("deploy".to_string(), "/home/deploy".to_string()), + ] + ); + // Unicode components survive and build correct cumulative paths. + assert_eq!( + breadcrumb_segments("/项目/子"), + vec![ + ("/".to_string(), "/".to_string()), + ("项目".to_string(), "/项目".to_string()), + ("子".to_string(), "/项目/子".to_string()), + ] + ); + } + + #[test] + fn sort_puts_dirs_first_then_name_case_insensitively() { + let entries = vec![ + entry("Zebra.txt", SftpEntryKind::File, false), + entry("apple", SftpEntryKind::Dir, false), + entry("beta.txt", SftpEntryKind::File, false), + entry("Alpha", SftpEntryKind::Dir, false), + entry("link-to-dir", SftpEntryKind::Symlink, true), + entry("link-to-file", SftpEntryKind::Symlink, false), + ]; + let sorted: Vec<&str> = sorted_filtered_entries(&entries, "") + .iter() + .map(|e| e.name.as_str()) + .collect(); + // Dir-likes first (Alpha, apple, link-to-dir), then files/other symlinks. + assert_eq!( + sorted, + vec![ + "Alpha", + "apple", + "link-to-dir", + "beta.txt", + "link-to-file", + "Zebra.txt", + ] + ); + } + + #[test] + fn filter_is_case_insensitive_substring() { + let entries = vec![ + entry("README.md", SftpEntryKind::File, false), + entry("src", SftpEntryKind::Dir, false), + entry("Cargo.toml", SftpEntryKind::File, false), + ]; + // Filter "a" matches "Cargo.toml" (lowercase a) and "README.md" (the + // uppercase A) — exercising case-insensitive substring matching — but not + // "src". Sorted by name, "Cargo.toml" precedes "README.md". + let names: Vec<&str> = sorted_filtered_entries(&entries, "a") + .iter() + .map(|e| e.name.as_str()) + .collect(); + assert_eq!(names, vec!["Cargo.toml", "README.md"]); + } + + #[test] + fn human_size_scales_units() { + assert_eq!(human_size(0), "0 B"); + assert_eq!(human_size(512), "512 B"); + assert_eq!(human_size(1024), "1.0K"); + assert_eq!(human_size(1536), "1.5K"); + assert_eq!(human_size(1024 * 1024), "1.0M"); + } + + #[test] + fn mode_string_renders_rwx() { + assert_eq!(mode_string(0o755), "rwxr-xr-x"); + assert_eq!(mode_string(0o644), "rw-r--r--"); + assert_eq!(mode_string(0o000), "---------"); + assert_eq!(mode_string(0o777), "rwxrwxrwx"); + } +}