Merge branch 'ssh-ws5-sftp' into ssh-connection-manager

# Conflicts:
#	src/daemon/protocol.rs
#	src/daemon/server.rs
#	src/terminal/remote.rs
#	src/ui/app.rs
#	src/ui/mod.rs
This commit is contained in:
l0ng-ai
2026-07-14 01:27:50 +08:00
13 changed files with 2700 additions and 5 deletions
Generated
+49 -1
View File
@@ -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",
+10
View File
@@ -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
+2
View File
@@ -57,6 +57,8 @@ actions!(
ToggleTabSidebar,
OpenSettings,
RestartDaemon,
// Toggle the SFTP file panel for the focused native-SSH pane (WS5).
ToggleSftp,
SendTab,
SendBackTab,
Quit
+294
View File
@@ -388,6 +388,137 @@ pub struct SshForwardRule {
pub description: Option<String>,
}
// ---------------------------------------------------------------------------
// 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<String>,
/// 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<KnownHostEntry>),
/// Reply to `SftpList`: the directory's entries (unsorted; the GUI sorts).
SftpEntries(Vec<SftpEntry>),
/// 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<SftpJobProgress>),
/// 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();
+71
View File
@@ -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<Arc<crate::daemon::ssh::SshConnection>, 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<Registry>) -> anyhow::Result<()> {
Ok(())
}
ClientMsg::SftpList { pane_id, path } => {
let mut w = write_stream;
match ssh_connection_for(&registry, 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(&registry, 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(&registry, 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 => {
+11
View File
@@ -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`.
File diff suppressed because it is too large Load Diff
+82 -3
View File
@@ -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<Vec<SftpEntry>, String> {
fn query(pane_id: u64, path: String) -> anyhow::Result<Result<Vec<SftpEntry>, 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<SftpOpResult> {
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<u64, String> {
fn query(spec: SftpTransferSpec) -> anyhow::Result<Result<u64, String>> {
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<SftpJobProgress> {
fn query(job_id: u64) -> anyhow::Result<Vec<SftpJobProgress>> {
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<SftpJobProgress> {
fn query(pane_id: u64) -> anyhow::Result<Vec<SftpJobProgress>> {
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 {
+17 -1
View File
@@ -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::<Config>().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
+4
View File
@@ -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<KeyBinding> {
// 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,
})
+1
View File
@@ -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;
+4
View File
@@ -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),
+1152
View File
File diff suppressed because it is too large Load Diff