mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-22 16:02:24 +00:00
feat(ssh): consolidate paths — russh default, freeze system-ssh compat (WS7)
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.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -60,6 +60,11 @@ pub struct ShellSpec {
|
||||
pub ssh: Option<SshSpec>,
|
||||
}
|
||||
|
||||
/// 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).
|
||||
|
||||
+91
-17
@@ -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 <alias>` — 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>,
|
||||
) {
|
||||
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<Self>) {
|
||||
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<Vec<String>, ()> {
|
||||
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<String> {
|
||||
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<SshSpec, ()> {
|
||||
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<char> {
|
||||
|
||||
#[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() {
|
||||
|
||||
@@ -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| {
|
||||
|
||||
+74
-15
@@ -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<Self>) {
|
||||
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<Self>,
|
||||
) -> Option<AnyElement> {
|
||||
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<Self>) -> 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<Self>) -> Div {
|
||||
let border = cx.theme().border;
|
||||
let foreground = cx.theme().foreground;
|
||||
|
||||
Reference in New Issue
Block a user