From 1859c9a6455fc4d973fd77786dc4ef0183e8caec Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Tue, 14 Jul 2026 02:29:35 +0800 Subject: [PATCH] =?UTF-8?q?feat(ssh):=20consolidate=20paths=20=E2=80=94=20?= =?UTF-8?q?russh=20default,=20freeze=20system-ssh=20compat=20(WS7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make native russh the default for every non-compat SSH entry point and confine the shell-out `ssh` path to a frozen compat escape hatch (PRD §3.1). Entry-point routing (ui::app): - Typed "SSH: Add Connection…": a bare `user@host[:port]` now takes the native QuickConnect path; only arg-bearing `ssh … -flags` lines (and bare tokens that only name a config alias) fall to the compat shell-out. - `~/.ssh/config` alias rows route through a documented `open_compat_alias` funnel (same funnel as `use_system_ssh` profiles) and their palette subtitle now reads `~/.ssh/config · system ssh`. - `open_managed_ssh_spec` documented as the single compat funnel; its only callers are the three deliberate escape hatches. Freeze audit: module-level freeze notes on `SshSpec`, `build_managed_ssh_command`/`SPAWN_MANAGED_SSH`, and `daemon::forward` (ControlMaster loopback). Verified `daemon::forward` is reachable only from compat panes (server branches `EnsureLoopbackForward` on `RemoteKind`); no non-compat code depends on shell-out. FR-C5 compat gating with a visible reason: SFTP toggle on a compat pane now opens a short "unavailable" notice instead of silently no-op'ing; the Ports panel shows a muted compat-mode line; managed L/R/D add-form stays native-only. Docs: Path policy section in ssh-native-architecture.md (WS6/WS7 seams marked resolved); SSH connection manager feature section in README + README.zh-CN. --- README.md | 17 +++++ README.zh-CN.md | 16 +++++ docs/ssh-native-architecture.md | 44 ++++++++++++- src/daemon/forward.rs | 10 +++ src/daemon/pane.rs | 6 ++ src/daemon/protocol.rs | 5 ++ src/ui/app.rs | 108 +++++++++++++++++++++++++++----- src/ui/forwards.rs | 9 +++ src/ui/sftp.rs | 89 +++++++++++++++++++++----- 9 files changed, 269 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index a25d2563..a1e21fef 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,23 @@ Download the build for your platform from - **⌘-click links** · desktop notifications - **Eight themes** · CJK / IME input +### SSH connection manager + +A native Rust SSH stack (russh) is the default path — profiles, credentials, and +SFTP without shelling out to `ssh`. + +- **QuickConnect** — type `user@host[:port]` in the palette and connect; IPv6 `[::1]:port` supported +- **Saved profiles** — full connection config with passwords / passphrases in the OS keychain, never on disk +- **GUI auth** — in-pane sheets for password, key passphrase, 2FA, and host-key confirmation (new vs. changed) +- **Built-in SFTP** — a slide-in file panel: browse, upload / download, rename / delete / chmod, drag to Finder +- **Port forwarding** — Local / Remote / Dynamic, preconfigured or added live, plus ⌘-click `localhost:PORT` to auto-forward +- **Jump hosts & proxies** — multi-hop via profile references, ProxyCommand, SOCKS5 / HTTP + +| Path | When | Features | +|---|---|---| +| **Native (russh)** | Profiles + QuickConnect — the default | SFTP · keychain · GUI auth · L/R/D forwards | +| **System ssh (compat)** | `use_system_ssh` profiles · `~/.ssh/config` aliases · typed `ssh …` with flags | Frozen escape hatch — OpenSSH is the source of truth; SFTP / GUI auth / managed forwards off | + ## Benchmarks All four terminals measured back-to-back on the same machine, same day, same diff --git a/README.zh-CN.md b/README.zh-CN.md index debc4d30..d6a9b677 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -62,6 +62,22 @@ macOS、Windows、Linux 三平台原生构建,每个 release 一起打出。 - **⌘ 点击打开链接** · 桌面通知 - **8 套主题** · CJK / 输入法输入 +### SSH 连接管理器 + +默认走原生 Rust SSH 栈(russh)—— profile、凭据、SFTP 全部内置,无需 shell 出 `ssh`。 + +- **QuickConnect** —— 面板里打 `user@host[:port]` 回车即连;支持 IPv6 `[::1]:port` +- **保存 profile** —— 完整连接配置,密码 / passphrase 进 OS keychain,绝不落盘 +- **GUI 认证** —— pane 内 sheet 输入密码、私钥 passphrase、2FA,并确认主机密钥(新主机 vs 已变更) +- **内置 SFTP** —— 滑入式文件面板:浏览、上传 / 下载、重命名 / 删除 / chmod,可拖进 Finder +- **端口转发** —— Local / Remote / Dynamic,预配置或运行时增删,外加 ⌘ 点击 `localhost:PORT` 一键转发 +- **跳板与代理** —— 经 profile 引用多跳、ProxyCommand、SOCKS5 / HTTP + +| 路径 | 何时 | 功能 | +|---|---|---| +| **原生(russh)** | profile + QuickConnect —— 默认 | SFTP · keychain · GUI 认证 · L/R/D 转发 | +| **系统 ssh(兼容模式)** | `use_system_ssh` profile · `~/.ssh/config` alias · 带 flag 的 `ssh …` 输入 | 冻结逃生门 —— 以 OpenSSH 为准;SFTP / GUI 认证 / 托管转发关闭 | + ## 基准测试 四款终端在同一台机器上依次测完,网格统一为 155×40 —— Apple M1 Pro, diff --git a/docs/ssh-native-architecture.md b/docs/ssh-native-architecture.md index 7b6490a5..2e664a71 100644 --- a/docs/ssh-native-architecture.md +++ b/docs/ssh-native-architecture.md @@ -7,6 +7,10 @@ API WS4/WS5 build on, and the seams intentionally left open. Code lives under `src/daemon/ssh/` plus a backend seam in `src/daemon/pane.rs` and wire types in `src/daemon/protocol.rs`. +> **Status:** WS1–WS7 merged. The native russh engine is the default path; the +> seams in §5 below are resolved (see the per-row notes). WS7 froze the shell-out +> path as the system-ssh compat escape hatch — see [§6 Path policy](#6-path-policy-ws7). + --- ## 1. Where the bytes flow @@ -174,12 +178,46 @@ brief §5); SFTP opens a session channel and drives the subsystem. | X11 forwarding | `NativeSshSpec.x11` carried only; **seam documented** in `daemon::ssh::handler` (P1, deferred — needs `request_x11` + `server_channel_open_x11` + `$DISPLAY` bridge) | WS4/WS5 | | SFTP | none; `open_session_channel` provided for the subsystem | WS5 | | Agent forwarding channels | `agent_forward` requests `auth-agent-req` on the shell channel; incoming agent-channel bridging to `SSH_AUTH_SOCK` not wired | WS4/WS5 | -| Session restore respawn | `SessionPane::Leaf.ssh_spec` (secret-free) persisted; reconnection UX not built | WS6 | -| GUI auth/host-key sheets | protocol + broker ready; sheets not built | WS3 | -| known_hosts hardening | plaintext / hashed / `@revoked` / `@cert-authority`-skip + safe append implemented; wildcard/negation matching and a management UI pending | WS3 | +| Session restore respawn | **DONE (WS6)** — `SessionPane::Leaf.ssh_spec` (secret-free) persisted; `restart_ssh_session` / `resolve_persisted_ssh_spec` re-resolve keychain secrets from the named profile and respawn in place (FR-E4) | WS6 | +| GUI auth/host-key sheets | **DONE (WS3)** — password / passphrase / keyboard-interactive / host-key sheets render in-pane over the prompting pane | WS3 | +| known_hosts hardening | plaintext / hashed / `@revoked` / `@cert-authority`-skip + safe append implemented; management UI shipped in Settings → SSH (WS3) | WS3 | **Session restore note:** a *live* native-SSH pane reattaches for free (the daemon + russh connection stay up across GUI restarts). Only a *dead* pane needs respawn, for which WS6 persists `Leaf.ssh_spec` via `without_secrets()`. (Kept a leaf field per the WS2 brief; the four `SessionPane::Leaf` literals in `ui/` got a mechanical `ssh_spec: None`, no UI behavior changed.) + +--- + +## 6. Path policy (WS7) + +Native russh is the default and only path with the full manager feature set; +the shell-out `ssh` path is a **frozen** compat escape hatch (PRD §3.1). Every +SSH entry point resolves to exactly one of the two backends: + +| Entry point | Routes to | Backend | +|---|---|---| +| Saved profile, `use_system_ssh` **off** (`connect_ssh_profile`) | `open_native_ssh_tab` | native russh | +| Typed `user@host[:port]` (`OpenSshConnect` bare target) / palette QuickConnect row | `quick_connect` → `open_native_ssh_tab` | native russh | +| Saved profile, `use_system_ssh` **on** | `compat_ssh_spec` → `open_managed_ssh_spec` | compat shell-out | +| `~/.ssh/config` alias row (`OpenSshProfile`) | `open_compat_alias` → `open_managed_ssh_spec` | compat shell-out | +| Typed line carrying ssh flags/args (`ssh … -p … -J …`) | `parse_ssh_connect_input` → `open_managed_ssh_spec` | compat shell-out | + +**Compat funnel.** `Tty7App::open_managed_ssh_spec` is the single funnel onto +`SPAWN_MANAGED_SSH`; its only callers are the three escape hatches above. No +default path reaches it. + +**Frozen surface** (kept working, never extended — module-level notes cite this +policy): `daemon::protocol::SshSpec`, `daemon::pane::build_managed_ssh_command` +(`SPAWN_MANAGED_SSH`), and `daemon::forward` (ControlMaster `ssh -O forward` +loopback). The `EnsureLoopbackForward` server handler branches on `RemoteKind` +so `daemon::forward` only ever sees compat panes; native panes take +`daemon::ssh::forward` + `SshManager::ensure_loopback_forward` (direct-tcpip). + +**Compat feature gating (FR-C5).** A compat pane has no russh connection, so +SFTP, the managed L/R/D forward add-form, and GUI auth/host-key sheets are +unavailable — each surfaces a visible reason (SFTP panel notice, a muted line in +the Ports panel, and the profile editor's compat-mode switch note), never a +silent miss. Loopback one-click forwards (FR-F4) and `~/.ssh/config` forwards +still work on compat panes via ControlMaster. diff --git a/src/daemon/forward.rs b/src/daemon/forward.rs index 89eb1071..a74bf1e9 100644 --- a/src/daemon/forward.rs +++ b/src/daemon/forward.rs @@ -1,3 +1,13 @@ +//! Loopback port forwarding for **compat-mode** (shell-out `ssh`) panes only. +//! +//! FROZEN (PRD §3.1). This drives OpenSSH's ControlMaster socket via +//! `ssh -O forward`/`cancel`, so it is reachable exclusively from panes spawned +//! through the system-ssh compat funnel (`RemoteKind::Ssh` with a +//! `RemoteContext.control_path`). Native russh panes take the WS4 equivalent +//! (`daemon::ssh::forward` + `SshManager::ensure_loopback_forward`); the server's +//! `EnsureLoopbackForward` handler branches on `RemoteKind` so this module never +//! sees a native pane. No new features may depend on it. + use std::collections::HashMap; use std::net::{TcpListener, TcpStream}; use std::path::PathBuf; diff --git a/src/daemon/pane.rs b/src/daemon/pane.rs index 42e105e8..3a12cc12 100644 --- a/src/daemon/pane.rs +++ b/src/daemon/pane.rs @@ -226,6 +226,12 @@ fn build_shell_command( Ok((cmd, integration_dir)) } +/// Build the shell-out `ssh` command for a compat-mode pane. +/// +/// FROZEN (PRD §3.1): the system-ssh escape hatch. It sets up the ControlMaster +/// socket that `daemon::forward` later drives for loopback forwarding. Only +/// reached via `SPAWN_MANAGED_SSH` (the compat funnel); native panes never come +/// through here. Kept working but not extended. fn build_managed_ssh_command( program: &str, ssh: &SshSpec, diff --git a/src/daemon/protocol.rs b/src/daemon/protocol.rs index 811e13d1..6930b372 100644 --- a/src/daemon/protocol.rs +++ b/src/daemon/protocol.rs @@ -60,6 +60,11 @@ pub struct ShellSpec { pub ssh: Option, } +/// The connect recipe for a **compat-mode** (shell-out `ssh`) pane. +/// +/// FROZEN (PRD §3.1): the system-ssh escape hatch, carried by `SPAWN_MANAGED_SSH`. +/// The native russh path uses [`NativeSshSpec`] instead. Kept functional but not +/// extended — new SSH features land on the native spec, never here. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct SshSpec { /// The destination token (`host`, `user@host`, or ssh config alias). diff --git a/src/ui/app.rs b/src/ui/app.rs index c232c6ed..d87165d9 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -1152,6 +1152,13 @@ impl Tty7App { cx.notify(); } + /// The frozen **system-ssh compat funnel** (PRD §3.1): spawn a tty7-managed + /// shell-out `ssh` tab (ControlMaster loopback forwarding, no native engine / + /// SFTP / GUI auth / vault). The only paths that reach it are the deliberate + /// escape hatches — `use_system_ssh` profiles (`connect_ssh_profile`), + /// `~/.ssh/config` aliases (`open_compat_alias`), and arg-bearing typed + /// connects (`open_typed_ssh_connect`). It is not on the default path and must + /// not gain new callers; the default is native russh (`open_native_ssh_tab`). pub(crate) fn open_managed_ssh_spec( &mut self, ssh: SshSpec, @@ -1174,6 +1181,48 @@ impl Tty7App { ); } + /// Open a `~/.ssh/config` host alias through the frozen system-ssh compat + /// funnel (PRD §3.3). OpenSSH stays the source of truth for the alias, so it + /// runs as shell-out `ssh ` — russh can't fully resolve ssh_config + /// (`Match`/canonicalize/deep `ProxyJump`), so aliases intentionally stay on + /// the compat path. Routed via [`Self::open_managed_ssh_spec`], the same + /// funnel `use_system_ssh` profiles use, rather than a bespoke spawn. + pub(crate) fn open_compat_alias( + &mut self, + alias: String, + window: &mut Window, + cx: &mut Context, + ) { + self.open_managed_ssh_spec( + SshSpec { + target: alias, + args: Vec::new(), + }, + window, + cx, + ); + } + + /// Route a typed "SSH: Add Connection…" line to the right path (PRD §3.1/§3.3 + /// path policy). A bare `user@host[:port]` target takes the **default** native + /// russh path ([`Self::quick_connect`]); anything else — a bare token that + /// only names a `~/.ssh/config` alias, or a line carrying extra ssh flags/args + /// (a system-ssh scenario) — falls to the frozen shell-out compat funnel. The + /// compat funnel is the labeled exception here, never the default. + fn open_typed_ssh_connect(&mut self, input: &str, window: &mut Window, cx: &mut Context) { + if let Some(target) = bare_ssh_target(input) + && let Some(qc) = crate::core::ssh_profile::parse_quick_connect(&target) + { + self.quick_connect(qc, window, cx); + return; + } + // A bare config alias (unparseable as a target) or an arg-bearing `ssh …` + // line: the frozen system-ssh compat path. + if let Ok(ssh) = parse_ssh_connect_input(input) { + self.open_managed_ssh_spec(ssh, window, cx); + } + } + /// Toggle the startup update check (Settings → About). Takes effect on the /// next launch — this only persists the preference; it doesn't run or cancel /// an in-flight check. @@ -1852,13 +1901,15 @@ impl Tty7App { // Live `~/.ssh/config` aliases, marked and connected via the (frozen) // shell-out alias path — OpenSSH stays their source of truth (PRD §3.3). + // The subtitle flags that these run on the system `ssh` compat path (no + // native engine / SFTP / GUI auth), unlike the saved profiles above. for alias in ssh_config::discover_profiles() { commands.push( Command::new( format!("SSH: {}", alias.alias), CommandKind::OpenSshProfile(alias), ) - .with_subtitle("~/.ssh/config"), + .with_subtitle("~/.ssh/config · system ssh"), ); } @@ -1977,21 +2028,8 @@ impl Tty7App { self.set_preset(&id, window, cx); } } - OpenSshProfile(profile) => { - self.open_managed_ssh_spec( - SshSpec { - target: profile.alias, - args: Vec::new(), - }, - window, - cx, - ); - } - OpenSshConnect(input) => { - if let Ok(ssh) = parse_ssh_connect_input(&input) { - self.open_managed_ssh_spec(ssh, window, cx); - } - } + OpenSshProfile(profile) => self.open_compat_alias(profile.alias, window, cx), + OpenSshConnect(input) => self.open_typed_ssh_connect(&input, window, cx), ConnectSavedProfile(id) => self.connect_ssh_profile(id, window, cx), EditSavedProfile(id) => self.open_ssh_profiles_for(Some(id), None, window, cx), QuickConnect(target) => { @@ -3416,6 +3454,21 @@ pub(crate) fn parse_ssh_option_words(input: &str) -> Result, ()> { Ok(words) } +/// If `input` is a single bare destination token (optionally prefixed with the +/// literal `ssh`) carrying no options, return it — the candidate for the native +/// QuickConnect path. Any leading `-flag`, extra words, or an unclosed quote +/// yields `None`, marking the line as arg-bearing (a system-ssh compat input). +fn bare_ssh_target(input: &str) -> Option { + let mut words = parse_ssh_option_words(input).ok()?; + if words.first().is_some_and(|w| w == "ssh") { + words.remove(0); + } + match words.as_slice() { + [only] if !only.starts_with('-') => Some(only.clone()), + _ => None, + } +} + pub(crate) fn parse_ssh_connect_input(input: &str) -> Result { let mut words = parse_ssh_option_words(input)?; if words.first().is_some_and(|word| word == "ssh") { @@ -3473,7 +3526,28 @@ fn ssh_short_option_value_flag(word: &str) -> Option { #[cfg(test)] mod tests { - use super::{parse_ssh_connect_input, parse_ssh_option_words}; + use super::{bare_ssh_target, parse_ssh_connect_input, parse_ssh_option_words}; + + #[test] + fn bare_ssh_target_recognizes_single_tokens_only() { + // Bare targets (→ native QuickConnect), optional `ssh` prefix stripped. + assert_eq!( + bare_ssh_target("deploy@10.0.0.5").as_deref(), + Some("deploy@10.0.0.5") + ); + assert_eq!( + bare_ssh_target("ssh host:2222").as_deref(), + Some("host:2222") + ); + assert_eq!(bare_ssh_target(" myalias ").as_deref(), Some("myalias")); + // Arg-bearing or flag-led lines are NOT bare (→ compat shell-out). + assert_eq!(bare_ssh_target("dev -p 2222"), None); + assert_eq!(bare_ssh_target("-J jump host"), None); + assert_eq!(bare_ssh_target("ssh -p 2222 dev"), None); + assert_eq!(bare_ssh_target(""), None); + // An unclosed quote is a parse error, not a bare target. + assert_eq!(bare_ssh_target("'unterminated"), None); + } #[test] fn parses_ssh_option_words_with_quotes() { diff --git a/src/ui/forwards.rs b/src/ui/forwards.rs index 8636245d..e17f7df0 100644 --- a/src/ui/forwards.rs +++ b/src/ui/forwards.rs @@ -295,6 +295,15 @@ impl Tty7App { ) .child(h_flex().gap_2().child(refresh).child(close)), ) + // Compat (shell-out) pane: managed L/R/D forwards, SFTP and GUI auth + // need the native engine, so note why they're absent here (FR-C5). The + // loopback one-click list below still works via ControlMaster. + .when(!is_native, |this| { + this.child(div().text_xs().text_color(muted_foreground).child( + "System ssh compat mode — managed forwards, SFTP and GUI auth \ + unavailable. Loopback links still work.", + )) + }) // Managed L/R/D forwards come first for native panes; the loopback // one-click list stays below and is shown for both pane kinds. .when(is_native, |this| { diff --git a/src/ui/sftp.rs b/src/ui/sftp.rs index 13a4af1d..2d3c7c01 100644 --- a/src/ui/sftp.rs +++ b/src/ui/sftp.rs @@ -1,8 +1,9 @@ //! 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 +//! focused **native-SSH** pane (a PTY pane has no remote to browse; a compat-`ssh` +//! pane has no russh connection, so it shows a short "unavailable" notice instead +//! — FR-C5). 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_*`). @@ -185,19 +186,28 @@ fn local_download_dir() -> PathBuf { // --------------------------------------------------------------------------- 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. + /// Toggle the SFTP panel for the focused SSH pane. Native panes get the file + /// browser; a compat (`use_system_ssh` / alias) pane has no russh connection, + /// so it opens the panel on a compat notice (FR-C5) — a visible reason rather + /// than a silent no-op. A non-SSH focused pane closes any open panel. 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), + let Some((pane_id, remote)) = self.active_ssh_pane(window, cx) else { + self.close_sftp_panel(cx); + return; + }; + if self.sftp_panel.open_pane_id == Some(pane_id) { + self.close_sftp_panel(cx); + return; + } + if remote.kind == RemoteKind::NativeSsh { + self.sftp_open_at(pane_id, window, cx); + } else { + // Compat pane: open the panel to show the "unavailable" notice; do not + // navigate or start the poll loop (there is nothing to list). + self.sftp_panel.open_pane_id = Some(pane_id); + self.sftp_panel.editing = None; + self.sftp_panel.poll_gen = self.sftp_panel.poll_gen.wrapping_add(1); + cx.notify(); } } @@ -578,13 +588,18 @@ impl Tty7App { pub(crate) fn render_sftp_overlay( &self, pane_id: u64, - _remote: &RemoteContext, + remote: &RemoteContext, window: &Window, cx: &mut Context, ) -> Option { if self.sftp_panel.open_pane_id != Some(pane_id) { return None; } + // Compat (shell-out) pane: no russh connection, so show why SFTP is off + // (FR-C5) instead of the file browser. + if remote.kind != RemoteKind::NativeSsh { + return Some(self.render_sftp_compat_notice(cx)); + } let popover = cx.theme().popover; let border = cx.theme().border; let shell_cwd = self.pane_shell_cwd(pane_id, window, cx); @@ -616,6 +631,50 @@ impl Tty7App { Some(panel.into_any_element()) } + /// The right-docked notice shown when SFTP is toggled on a compat-mode SSH + /// pane (FR-C5): a plain, muted explanation and a Close button — the panel + /// chrome without the browser. + fn render_sftp_compat_notice(&self, cx: &mut Context) -> AnyElement { + let theme = cx.theme(); + v_flex() + .id("sftp-compat-notice") + .absolute() + .top_0() + .right_0() + .bottom_0() + .w(px(self.sftp_panel.width)) + .gap_3() + .p_4() + .bg(theme.popover) + .border_l_1() + .border_color(theme.border) + .shadow_lg() + .child( + h_flex() + .items_center() + .justify_between() + .child( + div() + .font_weight(FontWeight::SEMIBOLD) + .text_color(theme.foreground) + .child("SFTP unavailable"), + ) + .child( + Button::new("sftp-compat-close") + .label("Close") + .small() + .on_click(cx.listener(|this, _, _w, cx| this.close_sftp_panel(cx))), + ), + ) + .child(div().text_sm().text_color(theme.muted_foreground).child( + "System ssh compat mode — SFTP, GUI auth, and the managed forward \ + manager are unavailable for this connection. Uncheck \u{201c}System ssh \ + compat mode\u{201d} on the profile (or use QuickConnect) to connect over \ + the native engine and get these features.", + )) + .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;