From 1071e1cf2ebefc7bb6098b324b180959fc5f2936 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Tue, 14 Jul 2026 13:19:03 +0800 Subject: [PATCH] refactor(palette): saved profiles are the single SSH source The command palette listed SSH hosts from two parallel sources: saved profiles and a live scan of ~/.ssh/config Host aliases. The same host could appear twice with different behaviors (frecency, edit affordance, credential handling), and config hosts surfaced even when Settings showed no profiles. Make saved profiles the palette's only SSH listing: - drop the live-alias rows and the OpenSshProfile command; ~/.ssh/config hosts appear after Settings -> SSH -> 'Import from ~/.ssh/config' - keep 'ssh ' semantics for *typed* targets: QuickConnect and 'SSH: Add Connection...' now resolve a target naming a config alias on the spot (HostName/User/Port/IdentityFile/ProxyJump), with typed user@/:port/ flags overriding the config's values -- previously only the ProxyJump chain resolved and a typed alias was treated as a literal hostname - remove the now-dead discovery walker (discover_profiles + struct); its alias-filtering and Include-following tests move to import_profiles_from, which exercises the shared parse_config_blocks path - update PRD (FR-P3, section 3.3) and both READMEs to the new model --- README.md | 2 +- README.zh-CN.md | 2 +- docs/prd-ssh-connection-manager.md | 8 +-- src/core/ssh_config.rs | 89 +++++------------------------- src/ui/app.rs | 83 +++++++++++----------------- src/ui/palette.rs | 5 -- src/ui/ssh_connect.rs | 29 ++++++++++ 7 files changed, 81 insertions(+), 137 deletions(-) diff --git a/README.md b/README.md index 3d20a1ef..74ce53f6 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ and SFTP without ever shelling out to `ssh`. There is no system-ssh compat mode. - **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 -- **`~/.ssh/config` aliases** — resolved natively (common fields, best-effort) and connected over russh +- **`~/.ssh/config` aliases** — type one to connect (resolved natively — common fields, best-effort — over russh), or import them as profiles in Settings - **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 diff --git a/README.zh-CN.md b/README.zh-CN.md index 532bfa02..ae958bed 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -68,7 +68,7 @@ macOS、Windows、Linux 三平台原生构建,每个 release 一起打出。 - **QuickConnect** —— 面板里打 `user@host[:port]` 回车即连;支持 IPv6 `[::1]:port` - **保存 profile** —— 完整连接配置,密码 / passphrase 进 OS keychain,绝不落盘 -- **`~/.ssh/config` alias** —— 原生解析(常用字段,尽力而为)后走 russh 连接 +- **`~/.ssh/config` alias** —— 直接输入 alias 即连(原生解析常用字段,尽力而为,走 russh),也可在设置页一键导入为 profile - **GUI 认证** —— pane 内 sheet 输入密码、私钥 passphrase、2FA,并确认主机密钥(新主机 vs 已变更) - **内置 SFTP** —— 滑入式文件面板:浏览、上传 / 下载、重命名 / 删除 / chmod,可拖进 Finder - **端口转发** —— Local / Remote / Dynamic,预配置或运行时增删,外加 ⌘ 点击 `localhost:PORT` 一键转发 diff --git a/docs/prd-ssh-connection-manager.md b/docs/prd-ssh-connection-manager.md index ccf251e6..17b3563c 100644 --- a/docs/prd-ssh-connection-manager.md +++ b/docs/prd-ssh-connection-manager.md @@ -17,7 +17,7 @@ tty7 当前把系统 `ssh` 二进制丢进 daemon 拥有的 PTY 运行(`daemon/p **已有可复用的地基:** - daemon 拥有全部 PTY,GUI 经 socket 镜像字节流(`terminal/remote.rs` 的 `RemoteTerminal`)——字节源已抽象,是 russh shell channel 的天然接缝 -- palette 已能实时发现 `~/.ssh/config` 的 Host alias(`core/ssh_config.rs`,仅发现不解析) +- `core/ssh_config.rs` 已能解析 `~/.ssh/config`(含 Include),供导入与按名 resolve 使用 - pane 级滑入面板(`ui/forwards.rs`)、全窗口设置页、竖向 tab 侧栏均已存在 - **完全没有的**:profile 存储、keychain/凭据、GUI 认证、SFTP、Remote/Dynamic 转发 @@ -97,7 +97,7 @@ tty7 当前把系统 `ssh` 二进制丢进 daemon 拥有的 PTY 运行(`daemon/p ### 3.3 ssh_config 处理 | 决策 | v1 做法 | |---|---| -| **alias 实时发现(已有)** | 保留 `core/ssh_config.rs` 的实时 alias 发现,palette 里 config alias 始终与文件同步,不吃"导入快照过期"的亏 | +| **alias 按名 resolve** | palette 只列保存的 profile(唯一来源,避免同一主机两套行为);config alias 不再实时列出,但在输入连接目标时按名现场 resolve(等价 `ssh `),或经导入转成 profile | | **导入为 profile(可选)** | 解析常见字段(Host/HostName/User/Port/IdentityFile/ProxyJump)导成 profile,给想要凭据/SFTP/转发管理的条目用;导入是显式动作,可重复执行(按 alias 去重更新) | | **运行时完整解析(Match/canonicalize)** | v1 不做;复杂 config 用户对该 profile 勾"系统 ssh 兼容模式"(§3.1) | @@ -194,7 +194,7 @@ tty7 当前把系统 `ssh` 二进制丢进 daemon 拥有的 PTY 运行(`daemon/p ### 5.4 Profile 管理 + 凭据(P0) - **FR-P1** Profile 存储完整字段(见 §7 数据模型),支持分组。 - **FR-P2** 密码 / passphrase 存 OS keychain(macOS Keychain;Windows Credential Manager;Linux libsecret),配置文件只存引用不存明文。 -- **FR-P3** palette 为统一入口:同框展示保存的 profile + ssh_config alias + "现连"项,按 frecency 排序。 +- **FR-P3** palette 为统一入口:SSH 列表只来自保存的 profile(按 frecency 排序)+ "现连"项;ssh_config 主机经导入成为 profile 后出现,或直接输入 alias 现连。 - **FR-P4** QuickConnect 解析 `[ssh] user@host[:port] [flags]`,支持 IPv6 `[::1]:port`。 - **FR-P5** profile → `user@host:port` 一键复制;`~/.ssh/config` 一键导入。 @@ -379,7 +379,7 @@ struct SshProfile { | **russh 兼容性长尾**(Tabby 前车之鉴:#10188/#10207/#11058) | 部分用户切换后连不上 | 系统 ssh 兼容模式逃生门 + GA 前 ≥2 周 dogfood + 认证失败信息可诊断(展示服务端拒绝原因) | | russh 特性缺口(FIDO/PKCS#11) | 部分密钥类型连不上 | 引导用户经 ssh-agent 认证(签名由 agent 完成);仍不行走兼容模式 | | GSSAPI/Kerberos 用户 | 管理器路径无法连接 | 兼容模式覆盖,文档说明 | -| ssh_config 复杂语义(Match/多跳) | 导入不完整 | 实时 alias 发现保底;v1 只导入常见字段;复杂场景引导兼容模式 | +| ssh_config 复杂语义(Match/多跳) | 导入不完整 | 输入 alias 按名 resolve 保底;v1 只导入常见字段;复杂场景引导兼容模式 | | known_hosts 格式细节 | 误报/漏报 host key | 只做明文/hashed/@revoked 的信任判定,@cert-authority 尽力而为;解析层绝不改写无关行;充分测试 | | 连接复用爆炸半径 | 一条连接挂掉拖垮多个 pane | FR-C2 明确断开语义:所有共享 pane 同步提示,一键重连全部恢复 | | X11 转发依赖 XQuartz(macOS) | 开了不生效,用户困惑 | 检测不到 X server 时提示安装 XQuartz,而非静默失败 | diff --git a/src/core/ssh_config.rs b/src/core/ssh_config.rs index 1be7c581..12444710 100644 --- a/src/core/ssh_config.rs +++ b/src/core/ssh_config.rs @@ -1,11 +1,15 @@ -//! Lightweight discovery of OpenSSH host aliases for UI pickers. +//! `~/.ssh/config` parsing: alias resolution for typed connects and the +//! Settings-page import (PRD §3.3). //! -//! tty7 does not try to resolve the final SSH configuration here. OpenSSH is the -//! source of truth for `HostName`, `User`, `Port`, `ProxyJump`, `Match`, and the -//! rest when we eventually run `ssh `. This module only finds concrete -//! `Host` aliases worth listing in the command palette. +//! Saved profiles are the app's single listed source of SSH hosts; this module +//! never feeds a UI list directly. It resolves a *named* alias on demand +//! (`resolve_alias_to_profile`, used when a typed target names a config Host) +//! and turns the whole config into managed profiles on explicit import +//! (`import_profiles` + `merge_imported`, behind Settings → SSH → "Import +//! from ~/.ssh/config"). `Match` blocks and `canonicalize` are intentionally +//! not evaluated. -use std::collections::{HashSet, VecDeque}; +use std::collections::HashSet; use std::path::{Path, PathBuf}; use crate::core::ssh_profile::{ForwardKind, ForwardRule, HostPort, SshProfile as ManagedProfile}; @@ -18,68 +22,6 @@ const MAX_CONFIG_FILES: usize = 256; /// profile's group is preserved on re-import. pub const IMPORTED_GROUP: &str = "Imported from ssh_config"; -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct SshProfile { - pub alias: String, - pub source: PathBuf, -} - -pub fn discover_profiles() -> Vec { - let Some(home) = home_dir() else { - return Vec::new(); - }; - discover_profiles_from(home.join(".ssh/config"), &home) -} - -fn discover_profiles_from(root: PathBuf, home: &Path) -> Vec { - let mut profiles = Vec::new(); - let mut aliases = HashSet::new(); - let mut seen_files = HashSet::new(); - let mut queue = VecDeque::from([(root, 0usize)]); - - while let Some((path, depth)) = queue.pop_front() { - if depth > MAX_INCLUDE_DEPTH || seen_files.len() >= MAX_CONFIG_FILES { - continue; - } - let path = expand_path(&path, home); - if !seen_files.insert(path.clone()) { - continue; - } - let Ok(text) = std::fs::read_to_string(&path) else { - continue; - }; - let base = path.parent().unwrap_or(home); - for line in text.lines() { - let line = strip_comment(line).trim(); - if line.is_empty() { - continue; - } - let Some((key, rest)) = split_keyword(line) else { - continue; - }; - if key.eq_ignore_ascii_case("host") { - for token in split_words(rest) { - if concrete_host_alias(&token) && aliases.insert(token.clone()) { - profiles.push(SshProfile { - alias: token, - source: path.clone(), - }); - } - } - } else if key.eq_ignore_ascii_case("include") { - for token in split_words(rest) { - for include in expand_include(&token, base, home) { - queue.push_back((include, depth + 1)); - } - } - } - } - } - - profiles.sort_by(|a, b| a.alias.cmp(&b.alias).then_with(|| a.source.cmp(&b.source))); - profiles -} - fn home_dir() -> Option { #[cfg(windows)] { @@ -239,8 +181,7 @@ fn glob_match(pattern: &str, text: &str) -> bool { // ───────────────────────────────────────────────────────────────────────────── // ssh_config → profile import (PRD §3.3) // -// `discover_profiles` above stays untouched (live alias discovery for the -// palette). The code below resolves the russh-mappable fields of each concrete +// The code below resolves the russh-mappable fields of each concrete // `Host` alias into a [`ManagedProfile`], so a config entry can connect natively // (there is no system-ssh fallback). Scope, per PRD §3.3: // @@ -807,9 +748,9 @@ mod tests { ) .unwrap(); - let aliases: Vec<_> = discover_profiles_from(ssh.join("config"), &root) + let aliases: Vec<_> = import_profiles_from(ssh.join("config"), &root) .into_iter() - .map(|p| p.alias) + .map(|p| p.profile.name) .collect(); assert_eq!(aliases, vec!["dev", "prod", "quoted host"]); } @@ -823,9 +764,9 @@ mod tests { std::fs::write(ssh.join("conf.d/dev"), "Host dev\n").unwrap(); std::fs::write(ssh.join("conf.d/prod"), "Host prod\n").unwrap(); - let aliases: Vec<_> = discover_profiles_from(ssh.join("config"), &root) + let aliases: Vec<_> = import_profiles_from(ssh.join("config"), &root) .into_iter() - .map(|p| p.alias) + .map(|p| p.profile.name) .collect(); assert_eq!(aliases, vec!["dev", "prod", "root"]); } diff --git a/src/ui/app.rs b/src/ui/app.rs index e6541a2a..95ae90c6 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -1079,43 +1079,6 @@ impl Tty7App { cx.notify(); } - /// Open a `~/.ssh/config` host alias over the native (russh) engine (PRD §3.3). - /// The alias is resolved against `~/.ssh/config` (common fields, best-effort; - /// `Match`/canonicalize unsupported with no fallback) into a transient profile, - /// its `ProxyJump` resolved into the nested jump chain. An alias unknown to the - /// config falls back to being treated as a bare hostname. - pub(crate) fn open_native_alias( - &mut self, - alias: String, - window: &mut Window, - cx: &mut Context, - ) { - let verify = cx.global::().verify_host_keys; - match ssh_config::resolve_alias_to_profile(&alias) { - Some(resolved) => { - let spec = crate::ui::ssh_connect::native_spec_from_transient_profile( - &resolved.profile, - resolved.proxy_jump, - &crate::core::keychain::OsCredentialStore, - verify, - &|a| ssh_config::resolve_alias_to_profile(a).map(|r| (r.profile, r.proxy_jump)), - ); - self.open_native_ssh_tab(Box::new(spec), window, cx); - } - // Not in ssh_config → treat the alias as a bare host (QuickConnect). - None => { - if let Some(qc) = crate::core::ssh_profile::parse_quick_connect(&alias) { - self.quick_connect(qc, window, cx); - } else { - self.push_ssh_connect_error( - format!("Can't connect to \u{201c}{alias}\u{201d}"), - cx, - ); - } - } - } - } - /// Route a typed "SSH: Add Connection…" line to the native engine (PRD §3.1/ /// §3.3). The input is parsed as best-effort into a transient profile — a /// `user@host[:port]` target plus the trivially-mappable flags (`-p`, `-i`, @@ -1124,13 +1087,35 @@ impl Tty7App { fn open_typed_ssh_connect(&mut self, input: &str, window: &mut Window, cx: &mut Context) { match parse_ssh_connect_input(input) { Ok(parsed) => { + // `ssh` semantics: a target naming a `~/.ssh/config` alias + // resolves through it, with typed flags overriding the config's + // values. (After parsing, a port of 22 is indistinguishable + // from "not given", so an explicit `-p 22` can't override a + // config port — the one caveat of this overlay.) + let (profile, proxy_jump) = + match ssh_config::resolve_alias_to_profile(&parsed.profile.host) { + Some(resolved) => { + let mut p = resolved.profile; + if !parsed.profile.user.is_empty() { + p.user = parsed.profile.user; + } + if parsed.profile.port != 22 { + p.port = parsed.profile.port; + } + if !parsed.profile.identity_files.is_empty() { + p.identity_files = parsed.profile.identity_files; + } + (p, parsed.proxy_jump.or(resolved.proxy_jump)) + } + None => (parsed.profile, parsed.proxy_jump), + }; let verify = cx.global::().verify_host_keys; let spec = crate::ui::ssh_connect::native_spec_from_transient_profile( - &parsed.profile, - parsed.proxy_jump, + &profile, + proxy_jump, &crate::core::keychain::OsCredentialStore, verify, - &|a| ssh_config::resolve_alias_to_profile(a).map(|r| (r.profile, r.proxy_jump)), + &crate::ui::ssh_connect::config_alias_resolver, ); self.open_native_ssh_tab(Box::new(spec), window, cx); } @@ -1837,17 +1822,12 @@ impl Tty7App { ); } - // Live `~/.ssh/config` aliases: resolved (common fields, best-effort) and - // connected over the native engine, same as saved profiles (PRD §3.3). - for alias in ssh_config::discover_profiles() { - commands.push( - Command::new( - format!("SSH: {}", alias.alias), - CommandKind::OpenSshProfile(alias), - ) - .with_subtitle("~/.ssh/config"), - ); - } + // Saved profiles are the palette's *only* SSH listing: `~/.ssh/config` + // hosts appear here after Settings → SSH → "Import from ~/.ssh/config" + // turns them into profiles, never as a parallel live-discovered source + // (two lists of the same hosts with different behaviors confused more + // than it helped). Typing an alias into "SSH: Add Connection…" still + // resolves it against ssh_config on the spot. for (i, tab) in self.tabs.iter().enumerate() { // Skip the active tab — "switch to the tab you're already on" is a @@ -1964,7 +1944,6 @@ impl Tty7App { self.set_preset(&id, window, cx); } } - OpenSshProfile(profile) => self.open_native_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_profile_in_settings(id, window, cx), diff --git a/src/ui/palette.rs b/src/ui/palette.rs index b67d8283..b821049f 100644 --- a/src/ui/palette.rs +++ b/src/ui/palette.rs @@ -22,7 +22,6 @@ use gpui_component::{ use uuid::Uuid; use crate::core::config::Config; -use crate::core::ssh_config::SshProfile; use crate::core::ssh_profile::parse_quick_connect; /// What a command actually does. Most variants map to an existing `Tty7App` @@ -70,9 +69,6 @@ pub enum CommandKind { /// Apply the preset at this index in `presets::all()`. Emitted from the /// theme sub-list. SetTheme(usize), - /// Open a native SSH tab for this discovered OpenSSH host alias (resolved - /// against `~/.ssh/config`). - OpenSshProfile(SshProfile), /// Switch to the tab at this index in `Tty7App::tabs`. ActivateTab(usize), /// Connect a saved SSH profile by id (over the native engine). @@ -140,7 +136,6 @@ impl CommandKind { | OpenSshConnectInput | OpenSshConnect(_) | SetTheme(_) - | OpenSshProfile(_) | ActivateTab(_) | ConnectSavedProfile(_) | EditSavedProfile(_) diff --git a/src/ui/ssh_connect.rs b/src/ui/ssh_connect.rs index 62255239..1c215be7 100644 --- a/src/ui/ssh_connect.rs +++ b/src/ui/ssh_connect.rs @@ -74,12 +74,35 @@ impl Tty7App { /// QuickConnect to a typed `user@host[:port]` target (PRD FR-P4), always via /// the native path. Builds a transient profile so keychain lookup by endpoint /// still applies (a QuickConnect can reuse a remembered password). + /// + /// `ssh ` semantics: a host naming a `~/.ssh/config` alias resolves + /// through it (HostName/User/Port/IdentityFile/ProxyJump), with the typed + /// `user@` / `:port` overriding the config's values. The palette lists only + /// saved profiles, so this is how a config alias connects without importing. pub(crate) fn quick_connect( &mut self, qc: crate::core::ssh_profile::QuickConnect, window: &mut gpui::Window, cx: &mut gpui::Context, ) { + if let Some(resolved) = crate::core::ssh_config::resolve_alias_to_profile(&qc.host) { + let mut profile = resolved.profile; + if let Some(user) = qc.user { + profile.user = user; + } + if let Some(port) = qc.port { + profile.port = port; + } + let spec = native_spec_from_transient_profile( + &profile, + resolved.proxy_jump, + &OsCredentialStore, + cx.global::().verify_host_keys, + &config_alias_resolver, + ); + self.open_native_ssh_tab(Box::new(spec), window, cx); + return; + } let port = qc.port_or_default(); let mut profile = SshProfile::new(qc.host.clone()); profile.host = qc.host; @@ -350,6 +373,12 @@ fn map_algorithms(a: &Algorithms) -> SshAlgorithms { /// [`crate::core::ssh_config::resolve_alias_to_profile`]). pub(crate) type AliasResolver<'a> = dyn Fn(&str) -> Option<(SshProfile, Option)> + 'a; +/// The standard [`AliasResolver`]: resolve against the live `~/.ssh/config`. +/// Shared by every typed-connect path (QuickConnect, "SSH: Add Connection…"). +pub(crate) fn config_alias_resolver(alias: &str) -> Option<(SshProfile, Option)> { + crate::core::ssh_config::resolve_alias_to_profile(alias).map(|r| (r.profile, r.proxy_jump)) +} + /// Build a [`NativeSshSpec`] from a **transient** (unsaved) profile — resolved /// from a `~/.ssh/config` alias or a typed connect line — whose jump host is a /// raw string rather than a stored profile id. The base spec is built like any