From 01969ef6bbaa103a67ff77d4300c49240104785b Mon Sep 17 00:00:00 2001 From: l0ng-ai Date: Tue, 11 Aug 2026 21:22:36 +0800 Subject: [PATCH] fix(settings): say what an ssh config import added, updated and could not keep (#515) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Importing from ~/.ssh/config was silent in three ways. A missing or unreadable file did nothing; a file of nothing but `Host *` and `Match` did nothing; and a successful import did nothing visible either, so the only way to learn what had happened was to go count the host list. Options tty7 has no field for — IdentityAgent, CertificateFile, AddKeysToAgent and the rest — were dropped without a word. They still are, because there is nowhere to put them, but the import now names them and the hosts that set them instead of pretending they were kept. Parsing keeps each keyword's original spelling alongside the lowercased form it matches on, and `option_is_supported` is the one list both the resolver and the report read, so the two cannot drift. Ignored options are grouped per Host block rather than per resolved alias: a keyword under a two-alias `Host` line is one omission, not two, and `Host *` noise stays out of the report entirely. `merge_imported` now returns added/updated/unchanged, comparing the six fields it writes before it writes them — so re-importing an unedited file reports six hosts unchanged rather than six updated. Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> --- src/core/ssh_config.rs | 394 ++++++++++++++++++++++++++++++++++++++--- src/ui/i18n/en.rs | 27 +++ src/ui/i18n/ja.rs | 31 ++++ src/ui/i18n/mod.rs | 7 + src/ui/i18n/zh.rs | 25 +++ src/ui/settings.rs | 120 +++++++++++-- 6 files changed, 569 insertions(+), 35 deletions(-) diff --git a/src/core/ssh_config.rs b/src/core/ssh_config.rs index a78db670..f1ca8a7a 100644 --- a/src/core/ssh_config.rs +++ b/src/core/ssh_config.rs @@ -1,4 +1,4 @@ -use std::collections::HashSet; +use std::collections::{BTreeMap, HashSet}; use std::path::{Path, PathBuf}; use crate::core::ssh_profile::{ForwardKind, ForwardRule, HostPort, SshProfile as ManagedProfile}; @@ -163,6 +163,45 @@ pub struct ImportedProfile { pub proxy_jump: Option, } +/// A keyword the file sets for a host tty7 is importing, that no `SshProfile` +/// field can hold — `IdentityAgent`, `CertificateFile`, `AddKeysToAgent` and +/// the rest of what `resolve_alias` walks past. +/// +/// Grouped by keyword rather than by host because that is the question someone +/// reads the report to answer — "what did it not keep?" — and because the same +/// keyword under a two-alias `Host` line is one omission, not two. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct IgnoredOption { + /// Spelled as the file spells it. A report that says `identityagent` when + /// the file says `IdentityAgent` sends the reader hunting for a typo that + /// is tty7's, not theirs. + pub option: String, + pub hosts: Vec, +} + +/// Everything one pass over `~/.ssh/config` found, including the parts of it +/// that went nowhere. +/// +/// `import_profiles_from` answers only the first field, because it is also on a +/// render path; the import button wants the rest so it can say what happened +/// instead of leaving the person to diff the host list by eye. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ImportReport { + pub profiles: Vec, + pub ignored: Vec, + pub source: PathBuf, + /// Whether `source` itself could be opened. An `Include` that matches + /// nothing is ordinary — most of these files carry one for a directory + /// that may or may not exist — but a root that cannot be read is the whole + /// import, and the two are indistinguishable in `profiles`, which comes + /// back empty either way. + pub source_read: bool, + /// `source` plus every `Include` that resolved to a file that was read, + /// for the log line. Someone who edited the wrong `conf.d` fragment finds + /// out here that tty7 never opened it. + pub files_read: usize, +} + #[allow(dead_code)] pub fn import_profiles() -> Vec { let Some(home) = home_dir() else { @@ -172,11 +211,40 @@ pub fn import_profiles() -> Vec { } pub fn import_profiles_from(root: PathBuf, home: &Path) -> Vec { - let blocks = parse_config_blocks(root, home); + profiles_from_blocks(&parse_config(&root, home).blocks) +} +pub fn import_report() -> ImportReport { + let Some(home) = home_dir() else { + // Without a home directory there is no path to have failed at, and the + // caller still has to name one. `~/.ssh/config` is the name the button + // itself uses, so it is the name the failure uses too. + return ImportReport { + profiles: Vec::new(), + ignored: Vec::new(), + source: PathBuf::from("~/.ssh/config"), + source_read: false, + files_read: 0, + }; + }; + import_report_from(home.join(".ssh/config"), &home) +} + +pub fn import_report_from(root: PathBuf, home: &Path) -> ImportReport { + let parsed = parse_config(&root, home); + ImportReport { + profiles: profiles_from_blocks(&parsed.blocks), + ignored: ignored_options(&parsed.blocks), + source: root, + source_read: parsed.root_read, + files_read: parsed.files_read, + } +} + +fn profiles_from_blocks(blocks: &[HostBlock]) -> Vec { let mut aliases: Vec = Vec::new(); let mut seen = HashSet::new(); - for block in &blocks { + for block in blocks { for pat in &block.patterns { if concrete_host_alias(pat) && seen.insert(pat.clone()) { aliases.push(pat.clone()); @@ -188,7 +256,7 @@ pub fn import_profiles_from(root: PathBuf, home: &Path) -> Vec aliases .into_iter() .map(|alias| { - let resolved = resolve_alias(&alias, &blocks); + let resolved = resolve_alias(&alias, blocks); let mut profile = ManagedProfile::new(alias.clone()); profile.group = Some(IMPORTED_GROUP.to_string()); let proxy_jump = apply_resolved(&mut profile, &alias, resolved); @@ -216,7 +284,7 @@ pub fn resolve_alias_to_profile_from( home: &Path, alias: &str, ) -> Option { - let blocks = parse_config_blocks(root, home); + let blocks = parse_config(&root, home).blocks; let matched = blocks.iter().any(|block| block_matches(block, alias)); let resolved = resolve_alias(alias, &blocks); if !matched && resolved.hostname.is_none() { @@ -262,8 +330,26 @@ fn apply_resolved(profile: &mut ManagedProfile, alias: &str, r: ResolvedHost) -> r.proxy_jump } +/// What a merge did, in the three counts the person who pressed the button is +/// owed: a host that arrived, one whose details moved, and one that already +/// said the right thing. +/// +/// The third is the one worth having. Importing the same unedited file twice +/// changes nothing — the suite pins that — and a report that called those +/// hosts "updated" would be the old silence with a number on it. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct MergeStats { + pub added: usize, + pub updated: usize, + pub unchanged: usize, +} + #[allow(dead_code)] -pub fn merge_imported(existing: &mut Vec, imported: Vec) { +pub fn merge_imported( + existing: &mut Vec, + imported: Vec, +) -> MergeStats { + let mut stats = MergeStats::default(); let mut jump_targets: Vec<(String, String)> = Vec::new(); for entry in imported { @@ -276,6 +362,20 @@ pub fn merge_imported(existing: &mut Vec, imported: Vec { + // Asked before the assignments below, and only about the fields + // they write: afterwards every one of them agrees by + // construction, so the answer would always be "updated". + let changed = current.host != profile.host + || current.port != profile.port + || current.user != profile.user + || current.identity_files != profile.identity_files + || current.proxy_command != profile.proxy_command + || current.agent_forward != profile.agent_forward; + if changed { + stats.updated += 1; + } else { + stats.unchanged += 1; + } current.host = profile.host; current.port = profile.port; current.user = profile.user; @@ -283,7 +383,10 @@ pub fn merge_imported(existing: &mut Vec, imported: Vec existing.push(profile), + None => { + stats.added += 1; + existing.push(profile); + } } } @@ -299,6 +402,8 @@ pub fn merge_imported(existing: &mut Vec, imported: Vec Option { struct HostBlock { patterns: Vec, - options: Vec<(String, String)>, + options: Vec, +} + +/// One `Keyword value` line, kept twice over: `key` is what the resolver +/// matches on, `spelled` is what the import report shows a human. +struct ConfigOption { + key: String, + spelled: String, + value: String, } #[derive(Default)] @@ -338,30 +451,46 @@ struct ResolvedHost { forwards: Vec, } -fn parse_config_blocks(root: PathBuf, home: &Path) -> Vec { - let mut blocks = Vec::new(); - let mut seen = HashSet::new(); - parse_config_file(&root, home, 0, &mut blocks, &mut seen); - blocks +struct ParsedConfig { + blocks: Vec, + root_read: bool, + files_read: usize, } +fn parse_config(root: &Path, home: &Path) -> ParsedConfig { + let mut blocks = Vec::new(); + let mut seen = HashSet::new(); + let mut files_read = 0; + let root_read = parse_config_file(root, home, 0, &mut blocks, &mut seen, &mut files_read); + ParsedConfig { + blocks, + root_read, + files_read, + } +} + +/// Returns whether this file was read, which only the root's answer is worth +/// anything: an include that resolves to nothing is a normal config, a root +/// that does not is a failed import. fn parse_config_file( path: &Path, home: &Path, depth: usize, blocks: &mut Vec, seen: &mut HashSet, -) { + files_read: &mut usize, +) -> bool { if depth > MAX_INCLUDE_DEPTH || seen.len() >= MAX_CONFIG_FILES { - return; + return false; } let path = expand_path(path, home); if !seen.insert(path.clone()) { - return; + return false; } let Ok(text) = std::fs::read_to_string(&path) else { - return; + return false; }; + *files_read += 1; let base = path.parent().unwrap_or(home).to_path_buf(); let mut current: Option = None; @@ -403,11 +532,15 @@ fn parse_config_file( } for token in split_words(rest) { for include in expand_include(&token, &base, home) { - parse_config_file(&include, home, depth + 1, blocks, seen); + parse_config_file(&include, home, depth + 1, blocks, seen, files_read); } } } else if !in_match { - let opt = (key.to_ascii_lowercase(), rest.to_string()); + let opt = ConfigOption { + key: key.to_ascii_lowercase(), + spelled: key.to_string(), + value: rest.to_string(), + }; match current.as_mut() { Some(block) => block.options.push(opt), None => global @@ -426,6 +559,91 @@ fn parse_config_file( if let Some(block) = global.take() { blocks.push(block); } + true +} + +/// Group the keywords no `SshProfile` field can hold, by keyword, over the +/// blocks that name at least one host tty7 is actually importing. +/// +/// Grouping happens here rather than in `resolve_alias` because `resolve_alias` +/// runs once per alias: an option set under a pattern that matches ten hosts +/// would be reported ten times, and the `Host *` block would drag every +/// keyword in the file into the report for hosts that never set it. +/// +/// `Host *` and `Match` blocks are left out for the same reason. A shared +/// config's `SendEnv LANG` at the top is not something the import dropped from +/// anyone's host; it is a line about hosts tty7 was never asked to import. +fn ignored_options(blocks: &[HostBlock]) -> Vec { + let mut by_keyword: BTreeMap<&str, IgnoredOption> = BTreeMap::new(); + for block in blocks { + let mut hosts: Vec<&String> = block + .patterns + .iter() + .filter(|pat| concrete_host_alias(pat)) + .collect(); + if hosts.is_empty() { + continue; + } + hosts.sort(); + hosts.dedup(); + for opt in &block.options { + if option_is_supported(&opt.key) { + continue; + } + // Keyed on the lowercased form so `IdentityAgent` under one host + // and `identityagent` under another are one entry; the spelling + // shown is the first one the file uses. + by_keyword + .entry(&opt.key) + .or_insert_with(|| IgnoredOption { + option: opt.spelled.clone(), + hosts: Vec::new(), + }) + .hosts + .extend(hosts.iter().map(|host| (*host).clone())); + } + } + by_keyword + .into_values() + .map(|mut entry| { + entry.hosts.sort(); + entry.hosts.dedup(); + entry + }) + .collect() +} + +/// The keywords `resolve_alias` below knows how to carry into an `SshProfile`. +/// +/// A list of its own rather than a second one written out inside +/// `ignored_options`, because the report and the resolver have to answer the +/// same question and a hand-copied pair of lists is a thing that drifts. +/// `every_supported_keyword_is_kept` sets all twenty of these in one config and +/// fails if any of them comes back in the report as dropped. +fn option_is_supported(key: &str) -> bool { + matches!( + key, + "hostname" + | "user" + | "port" + | "identityfile" + | "proxyjump" + | "proxycommand" + | "forwardagent" + | "connecttimeout" + | "serveraliveinterval" + | "serveralivecountmax" + | "ciphers" + | "macs" + | "kexalgorithms" + | "hostkeyalgorithms" + | "compression" + | "forwardx11" + | "stricthostkeychecking" + | "localforward" + | "remoteforward" + | "dynamicforward" + ) } fn resolve_alias(alias: &str, blocks: &[HostBlock]) -> ResolvedHost { @@ -434,7 +652,10 @@ fn resolve_alias(alias: &str, blocks: &[HostBlock]) -> ResolvedHost { if !block_matches(block, alias) { continue; } - for (key, val) in &block.options { + for ConfigOption { + key, value: val, .. + } in &block.options + { match key.as_str() { "hostname" if r.hostname.is_none() => { r.hostname = first_word(val); @@ -754,7 +975,15 @@ mod tests { let prod_id = existing[0].id; let imported = import_profiles_from(ssh.join("config"), &root); - merge_imported(&mut existing, imported); + let stats = merge_imported(&mut existing, imported); + assert_eq!( + stats, + MergeStats { + added: 1, + updated: 1, + unchanged: 0 + } + ); assert_eq!(existing.len(), 2); let prod = existing.iter().find(|p| p.name == "prod").unwrap(); @@ -769,8 +998,18 @@ mod tests { let snapshot = existing.clone(); let imported_again = import_profiles_from(ssh.join("config"), &root); - merge_imported(&mut existing, imported_again); + let stats = merge_imported(&mut existing, imported_again); assert_eq!(existing, snapshot); + // The same invariant the line above pins, said in the words the button + // reports: a second import of an unedited file updates nothing. + assert_eq!( + stats, + MergeStats { + added: 0, + updated: 0, + unchanged: 2 + } + ); } #[test] @@ -947,6 +1186,117 @@ mod tests { assert_eq!(bastion.profile.user, "jumper"); } + #[test] + fn report_groups_ignored_options_by_keyword_as_the_file_spells_them() { + let root = temp_root("report-ignored"); + let ssh = root.join(".ssh"); + std::fs::create_dir_all(&ssh).unwrap(); + std::fs::write( + ssh.join("config"), + concat!( + "Host prod web\n", + " HostName 10.0.0.5\n", + " IdentityAgent /run/agent.sock\n", + " CertificateFile ~/.ssh/id-cert.pub\n", + "Host db\n", + " identityagent /run/other.sock\n", + " AddKeysToAgent yes\n", + "Host *\n", + " SendEnv LANG\n", + "Match host prod\n", + " PKCS11Provider /usr/lib/x.so\n", + ), + ) + .unwrap(); + + let report = import_report_from(ssh.join("config"), &root); + assert!(report.source_read); + assert_eq!(report.files_read, 1); + let ignored: Vec<(&str, Vec<&str>)> = report + .ignored + .iter() + .map(|opt| { + ( + opt.option.as_str(), + opt.hosts.iter().map(String::as_str).collect(), + ) + }) + .collect(); + assert_eq!( + ignored, + vec![ + ("AddKeysToAgent", vec!["db"]), + ("CertificateFile", vec!["prod", "web"]), + // One entry despite the two spellings, under the first of them, + // and one entry per host rather than one per `Host` line. + ("IdentityAgent", vec!["db", "prod", "web"]), + ] + ); + } + + #[test] + fn every_supported_keyword_is_kept() { + let root = temp_root("report-supported"); + let ssh = root.join(".ssh"); + std::fs::create_dir_all(&ssh).unwrap(); + std::fs::write( + ssh.join("config"), + concat!( + "Host everything\n", + " HostName 10.0.0.5\n", + " User deploy\n", + " Port 2222\n", + " IdentityFile ~/.ssh/id_prod\n", + " ProxyJump bastion\n", + " ProxyCommand nc %h %p\n", + " ForwardAgent yes\n", + " ConnectTimeout 15\n", + " ServerAliveInterval 30\n", + " ServerAliveCountMax 4\n", + " Ciphers aes256-ctr\n", + " MACs hmac-sha2-256\n", + " KexAlgorithms curve25519-sha256\n", + " HostKeyAlgorithms ssh-ed25519\n", + " Compression yes\n", + " ForwardX11 no\n", + " StrictHostKeyChecking no\n", + " LocalForward 8080 localhost:80\n", + " RemoteForward 9000 127.0.0.1:3000\n", + " DynamicForward 1080\n", + ), + ) + .unwrap(); + + let report = import_report_from(ssh.join("config"), &root); + assert_eq!(report.ignored, Vec::new()); + } + + #[test] + fn report_says_when_the_root_config_could_not_be_read() { + let root = temp_root("report-missing"); + let missing = root.join(".ssh/config"); + + let report = import_report_from(missing.clone(), &root); + assert_eq!(report.source, missing); + assert!(!report.source_read); + assert_eq!(report.files_read, 0); + assert!(report.profiles.is_empty()); + } + + #[test] + fn a_wildcard_only_config_was_still_read() { + let root = temp_root("report-wildcard"); + let ssh = root.join(".ssh"); + std::fs::create_dir_all(&ssh).unwrap(); + std::fs::write(ssh.join("config"), "Host *\n User fallback\n").unwrap(); + + let report = import_report_from(ssh.join("config"), &root); + assert!(report.source_read); + assert_eq!(report.files_read, 1); + assert!(report.profiles.is_empty()); + assert!(report.ignored.is_empty()); + } + fn temp_root(name: &str) -> PathBuf { let dir = std::env::temp_dir().join(format!( "tty7-ssh-config-test-{name}-{}", diff --git a/src/ui/i18n/en.rs b/src/ui/i18n/en.rs index 1fb462cf..58563223 100644 --- a/src/ui/i18n/en.rs +++ b/src/ui/i18n/en.rs @@ -195,6 +195,17 @@ pub fn translate_en(key: L10nKey) -> &'static str { "Re-reads the file and adds anything new. Edits you make here are stored by tty7 — the file itself is never written." } L10nKey::SettingsImportNow => "Import now", + L10nKey::SettingsImportUnreadable => "Could not read {path} — nothing was imported.", + L10nKey::SettingsImportNoHosts => { + "{path} names no hosts to import — only wildcard or Match rules." + } + L10nKey::SettingsImportSummary => { + "{count} hosts added — {updated} updated, {unchanged} already current" + } + L10nKey::SettingsImportIgnored => { + "{count} options have no setting in tty7 and were left in the file: {options}" + } + L10nKey::SettingsImportMoreOptions => "+{count} more", L10nKey::SettingsDefaultsIntro => { "Every host starts from these. Any host can override one under its own Advanced." } @@ -1600,6 +1611,22 @@ pub fn translate_variant_en(key: L10nKey, branch: &'static str) -> Option<&'stat (L10nKey::SettingsAliasesLinked, "zero") => "No aliases linked yet.", (L10nKey::SettingsAliasesLinked, "one") => "1 alias linked.", (L10nKey::SettingsAliasesLinked, "other") => "{count} aliases linked.", + (L10nKey::SettingsImportSummary, "zero") => { + "Nothing new — {updated} updated, {unchanged} already current" + } + (L10nKey::SettingsImportSummary, "one") => { + "1 host added — {updated} updated, {unchanged} already current" + } + (L10nKey::SettingsImportSummary, "other") => { + "{count} hosts added — {updated} updated, {unchanged} already current" + } + (L10nKey::SettingsImportIgnored, "zero") => "Every option in the file has a tty7 setting.", + (L10nKey::SettingsImportIgnored, "one") => { + "1 option has no setting in tty7 and was left in the file: {options}" + } + (L10nKey::SettingsImportIgnored, "other") => { + "{count} options have no setting in tty7 and were left in the file: {options}" + } (L10nKey::SettingsRulesOpenedWithConnection, "zero") => { "0 rules, opened with the connection" } diff --git a/src/ui/i18n/ja.rs b/src/ui/i18n/ja.rs index f59e68a6..f60b6240 100644 --- a/src/ui/i18n/ja.rs +++ b/src/ui/i18n/ja.rs @@ -196,6 +196,19 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { "ファイルを再読み込みして新しい項目を追加します。ここでの編集は tty7 が保存します — ファイル自体には書き込まれません" } L10nKey::SettingsImportNow => "今すぐインポート", + L10nKey::SettingsImportUnreadable => { + "{path} を読み取れませんでした — 何もインポートされていません" + } + L10nKey::SettingsImportNoHosts => { + "{path} にインポートできるホストがありません — ワイルドカードや Match のルールだけです" + } + L10nKey::SettingsImportSummary => { + "ホスト {count} 件を追加 — {updated} 件を更新、{unchanged} 件は変更なし" + } + L10nKey::SettingsImportIgnored => { + "tty7 に設定のないオプションが {count} 件あり、ファイルに残されています: {options}" + } + L10nKey::SettingsImportMoreOptions => "他 {count} 件", L10nKey::SettingsDefaultsIntro => { "すべてのホストはこの設定から始まります。各ホストは詳細設定で個別に上書きできます" } @@ -1650,6 +1663,24 @@ pub fn translate_variant_ja(key: L10nKey, branch: &'static str) -> Option<&'stat (L10nKey::SettingsAliasesLinked, "zero") => "エイリアスはまだリンクされていません", (L10nKey::SettingsAliasesLinked, "one") => "エイリアス 1 件がリンクされています", (L10nKey::SettingsAliasesLinked, "other") => "エイリアス {count} 件がリンクされています", + (L10nKey::SettingsImportSummary, "zero") => { + "新しいホストはありません — {updated} 件を更新、{unchanged} 件は変更なし" + } + (L10nKey::SettingsImportSummary, "one") => { + "ホスト 1 件を追加 — {updated} 件を更新、{unchanged} 件は変更なし" + } + (L10nKey::SettingsImportSummary, "other") => { + "ホスト {count} 件を追加 — {updated} 件を更新、{unchanged} 件は変更なし" + } + (L10nKey::SettingsImportIgnored, "zero") => { + "ファイル内のすべてのオプションに tty7 側の設定があります" + } + (L10nKey::SettingsImportIgnored, "one") => { + "tty7 に設定のないオプションが 1 件あり、ファイルに残されています: {options}" + } + (L10nKey::SettingsImportIgnored, "other") => { + "tty7 に設定のないオプションが {count} 件あり、ファイルに残されています: {options}" + } (L10nKey::SettingsRulesOpenedWithConnection, "zero") => "接続と同時に開くルール 0 件", (L10nKey::SettingsRulesOpenedWithConnection, "one") => "接続と同時に開くルール 1 件", (L10nKey::SettingsRulesOpenedWithConnection, "other") => { diff --git a/src/ui/i18n/mod.rs b/src/ui/i18n/mod.rs index 3937d4d3..9c632aef 100644 --- a/src/ui/i18n/mod.rs +++ b/src/ui/i18n/mod.rs @@ -224,6 +224,11 @@ l10n_keys! { SettingsImportAliases, SettingsImportAliasesDesc, SettingsImportNow, + SettingsImportUnreadable, + SettingsImportNoHosts, + SettingsImportSummary, + SettingsImportIgnored, + SettingsImportMoreOptions, SettingsDefaultsIntro, SettingsCopyAddress, SettingsDuplicate, @@ -2004,6 +2009,8 @@ mod tests { fn plural_and_select_branches_are_translated() { let plural_keys = [ L10nKey::SettingsAliasesLinked, + L10nKey::SettingsImportSummary, + L10nKey::SettingsImportIgnored, L10nKey::SettingsRulesOpenedWithConnection, L10nKey::SettingsOfflineMachines, L10nKey::PanelMoreChangedFiles, diff --git a/src/ui/i18n/zh.rs b/src/ui/i18n/zh.rs index fe5a0bf8..75e0f895 100644 --- a/src/ui/i18n/zh.rs +++ b/src/ui/i18n/zh.rs @@ -176,6 +176,15 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { "重新读取文件并添加新内容。你在这里做的编辑由 tty7 保存——不会写入该文件本身。" } L10nKey::SettingsImportNow => "立即导入", + L10nKey::SettingsImportUnreadable => "无法读取 {path}——没有导入任何内容。", + L10nKey::SettingsImportNoHosts => "{path} 中没有可导入的主机——只有通配符或 Match 规则。", + L10nKey::SettingsImportSummary => { + "新增 {count} 个主机——更新 {updated} 个,{unchanged} 个已是最新" + } + L10nKey::SettingsImportIgnored => { + "有 {count} 个选项在 tty7 中没有对应设置,仍留在文件里:{options}" + } + L10nKey::SettingsImportMoreOptions => "还有 {count} 个", L10nKey::SettingsDefaultsIntro => { "所有主机都从这些设置开始。每个主机都可以在自己的高级选项中覆盖某项。" } @@ -1519,6 +1528,22 @@ pub fn translate_variant_zh(key: L10nKey, branch: &'static str) -> Option<&'stat (L10nKey::SettingsAliasesLinked, "zero") => "还没有关联别名。", (L10nKey::SettingsAliasesLinked, "one") => "已关联 1 个别名。", (L10nKey::SettingsAliasesLinked, "other") => "已关联 {count} 个别名。", + (L10nKey::SettingsImportSummary, "zero") => { + "没有新主机——更新 {updated} 个,{unchanged} 个已是最新" + } + (L10nKey::SettingsImportSummary, "one") => { + "新增 1 个主机——更新 {updated} 个,{unchanged} 个已是最新" + } + (L10nKey::SettingsImportSummary, "other") => { + "新增 {count} 个主机——更新 {updated} 个,{unchanged} 个已是最新" + } + (L10nKey::SettingsImportIgnored, "zero") => "文件里的每个选项在 tty7 中都有对应设置。", + (L10nKey::SettingsImportIgnored, "one") => { + "有 1 个选项在 tty7 中没有对应设置,仍留在文件里:{options}" + } + (L10nKey::SettingsImportIgnored, "other") => { + "有 {count} 个选项在 tty7 中没有对应设置,仍留在文件里:{options}" + } (L10nKey::SettingsRulesOpenedWithConnection, "zero") => "0 条规则,随连接打开", (L10nKey::SettingsRulesOpenedWithConnection, "one") => "1 条规则,随连接打开", (L10nKey::SettingsRulesOpenedWithConnection, "other") => "{count} 条规则,随连接打开", diff --git a/src/ui/settings.rs b/src/ui/settings.rs index 05db1a97..8564294e 100644 --- a/src/ui/settings.rs +++ b/src/ui/settings.rs @@ -9,6 +9,7 @@ use gpui_component::color_picker::{ColorPicker, ColorPickerState}; use gpui_component::input::{Input, InputEvent, InputState}; use gpui_component::link::Link; use gpui_component::menu::{ContextMenuExt as _, DropdownMenu as _, PopupMenu, PopupMenuItem}; +use gpui_component::notification::{Notification, NotificationType}; use gpui_component::select::{SearchableVec, Select, SelectState}; use gpui_component::sidebar::{Sidebar, SidebarCollapsible, SidebarMenu, SidebarMenuItem}; use gpui_component::slider::{Slider, SliderState}; @@ -2586,8 +2587,9 @@ impl Tty7App { .item( PopupMenuItem::new(t(L10nKey::SettingsImportFromSshConfig)).on_click({ let app = app.clone(); - move |_, _window, cx| { - let _ = app.update(cx, |this, cx| this.import_ssh_config_profiles(cx)); + move |_, window, cx| { + let _ = + app.update(cx, |this, cx| this.import_ssh_config_profiles(window, cx)); } }), ) @@ -2965,9 +2967,9 @@ impl Tty7App { Button::new("ssh-empty-import") .label(t(L10nKey::Link)) .small() - .on_click( - cx.listener(|this, _, _w, cx| this.import_ssh_config_profiles(cx)), - ), + .on_click(cx.listener(|this, _, window, cx| { + this.import_ssh_config_profiles(window, cx) + })), ), ); } @@ -3011,9 +3013,9 @@ impl Tty7App { Button::new("ssh-defaults-import") .label(t(L10nKey::SettingsImportNow)) .small() - .on_click( - cx.listener(|this, _, _w, cx| this.import_ssh_config_profiles(cx)), - ) + .on_click(cx.listener(|this, _, window, cx| { + this.import_ssh_config_profiles(window, cx) + })) .into_any_element(), cx, ), @@ -3490,15 +3492,107 @@ impl Tty7App { cx.notify(); } - pub(crate) fn import_ssh_config_profiles(&mut self, cx: &mut Context) { - let imported = crate::core::ssh_config::import_profiles(); - if imported.is_empty() { + /// Import `~/.ssh/config`, and say what that did. + /// + /// Every branch here ends in a notification because every branch used to + /// end in nothing: a missing file, a file of nothing but `Host *`, and a + /// clean import of six hosts were all the same silent button press, and the + /// only way to tell them apart was to go count the host list. + pub(crate) fn import_ssh_config_profiles( + &mut self, + window: &mut Window, + cx: &mut Context, + ) { + // One id for all three outcomes, so pressing the button again replaces + // what it said last time instead of stacking a second toast on top of + // an answer that is now out of date. + const NOTIFICATION: &str = "ssh-config-import"; + // A toast is 448pt wide. A config with a dozen unsupported keywords in + // it would push the counts out of view, so the notification names the + // first few and the log line below carries the whole list, with the + // hosts each keyword was set on. + const OPTIONS_SHOWN: usize = 5; + + let report = crate::core::ssh_config::import_report(); + let source = report.source.display().to_string(); + + if !report.source_read { + window.push_notification( + Notification::error(t_fmt( + L10nKey::SettingsImportUnreadable, + &[("path", &source)], + )) + .id1::(NOTIFICATION), + cx, + ); return; } + if report.profiles.is_empty() { + window.push_notification( + Notification::warning(t_fmt(L10nKey::SettingsImportNoHosts, &[("path", &source)])) + .id1::(NOTIFICATION), + cx, + ); + return; + } + + let read = report.profiles.len(); + let ignored = report.ignored; + let mut stats = crate::core::ssh_config::MergeStats::default(); self.update_config(cx, |cfg| { - crate::core::ssh_config::merge_imported(&mut cfg.ssh_profiles, imported); + stats = crate::core::ssh_config::merge_imported(&mut cfg.ssh_profiles, report.profiles); }); - cx.notify(); + + let dropped: Vec = ignored + .iter() + .map(|opt| format!("{} ({})", opt.option, opt.hosts.join(", "))) + .collect(); + log::info!( + "imported {read} alias(es) from {source} ({} file(s) read): {} added, {} updated, \ + {} unchanged; no tty7 setting for: [{}]", + report.files_read, + stats.added, + stats.updated, + stats.unchanged, + dropped.join("; ") + ); + + let mut notification = Notification::new() + .with_type(NotificationType::Success) + .title(t_plural( + L10nKey::SettingsImportSummary, + stats.added, + &[ + ("updated", &stats.updated.to_string()), + ("unchanged", &stats.unchanged.to_string()), + ], + )) + .id1::(NOTIFICATION); + if !ignored.is_empty() { + let mut options: Vec = ignored + .iter() + .take(OPTIONS_SHOWN) + .map(|opt| opt.option.clone()) + .collect(); + let rest = ignored.len() - options.len(); + if rest > 0 { + options.push(t_fmt( + L10nKey::SettingsImportMoreOptions, + &[("count", &rest.to_string())], + )); + } + notification = notification + .message(t_plural( + L10nKey::SettingsImportIgnored, + ignored.len(), + &[("options", &options.join(", "))], + )) + // A list of what the import could not carry is something to + // read and act on, and four seconds is not long enough to do + // either. The counts alone still fade on their own. + .autohide(false); + } + window.push_notification(notification, cx); } pub(crate) fn copy_profile_connect_string(&mut self, id: Uuid, cx: &mut Context) {