mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-21 16:02:20 +00:00
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:
Generated
+49
-1
@@ -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
@@ -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
|
||||
|
||||
@@ -57,6 +57,8 @@ actions!(
|
||||
ToggleTabSidebar,
|
||||
OpenSettings,
|
||||
RestartDaemon,
|
||||
// Toggle the SFTP file panel for the focused native-SSH pane (WS5).
|
||||
ToggleSftp,
|
||||
SendTab,
|
||||
SendBackTab,
|
||||
Quit
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(®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 => {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user