1use super::{GroupBy, HostListItem};
6use crate::app::App;
7use crate::ssh_config::model::HostEntry;
8
9impl App {
10 pub fn add_host_from_form(&mut self) -> Result<String, String> {
11 let entry = self.forms.host.to_entry();
12 let alias = entry.alias.clone();
13 let duplicate = if self.forms.host.is_pattern {
14 self.hosts_state.ssh_config.has_host_block(&alias)
15 } else {
16 self.hosts_state.ssh_config.has_host(&alias)
17 };
18 if duplicate {
19 return Err(if self.forms.host.is_pattern {
20 crate::messages::pattern_already_exists(&alias)
21 } else {
22 crate::messages::host_alias_already_exists(&alias)
23 });
24 }
25 let len_before = self.hosts_state.ssh_config.elements.len();
26 self.hosts_state.ssh_config.add_host(&entry);
27 if !entry.tags.is_empty() {
28 let tags_wired = self
29 .hosts_state
30 .ssh_config
31 .set_host_tags(&alias, &entry.tags);
32 debug_assert!(
33 tags_wired,
34 "add_host_from_form: alias '{}' missing immediately after add_host (set_host_tags)",
35 alias
36 );
37 }
38 if let Some(ref source) = entry.askpass {
39 let askpass_wired = self.hosts_state.ssh_config.set_host_askpass(&alias, source);
40 debug_assert!(
41 askpass_wired,
42 "add_host_from_form: alias '{}' missing immediately after add_host (set_host_askpass)",
43 alias
44 );
45 }
46 if let Some(ref role) = entry.vault_ssh {
47 let role_wired = self.hosts_state.ssh_config.set_host_vault_ssh(&alias, role);
52 debug_assert!(
53 role_wired,
54 "add_host_from_form: alias '{}' missing immediately after upsert (set_host_vault_ssh)",
55 alias
56 );
57 let addr = entry.vault_addr.as_deref().unwrap_or("");
61 let addr_wired = self
62 .hosts_state
63 .ssh_config
64 .set_host_vault_addr(&alias, addr);
65 debug_assert!(
66 addr_wired,
67 "add_host_from_form: alias '{}' missing immediately after upsert (set_host_vault_addr)",
68 alias
69 );
70 if crate::should_write_certificate_file(&entry.certificate_file) {
75 let cert_path = crate::vault_ssh::cert_path_for(self.env().paths(), &alias)
76 .map_err(|e| crate::messages::cert_path_resolve_failed(&e))?;
77 let wired = self
80 .hosts_state
81 .ssh_config
82 .set_host_certificate_file(&alias, &cert_path.to_string_lossy());
83 debug_assert!(
84 wired,
85 "add_host_from_form: alias '{}' missing immediately after upsert",
86 alias
87 );
88 }
89 }
90 if let Err(e) = self.hosts_state.ssh_config.write() {
91 self.hosts_state.ssh_config.elements.truncate(len_before);
92 return Err(crate::messages::failed_to_save(&e));
93 }
94 self.vault.pending_config_write = false;
96 self.update_last_modified();
97 self.reload_hosts();
98 self.select_host_by_alias(&alias);
99 self.refresh_cert_cache(&alias);
103 Ok(crate::messages::welcome_aboard(&alias))
104 }
105
106 pub fn edit_host_from_form(&mut self, old_alias: &str) -> Result<String, String> {
108 let entry = self.forms.host.to_entry();
109 let alias = entry.alias.clone();
110 let exists = if self.forms.host.is_pattern {
111 self.hosts_state.ssh_config.has_host_block(old_alias)
112 } else {
113 self.hosts_state.ssh_config.has_host(old_alias)
114 };
115 if !exists {
116 return Err(if self.forms.host.is_pattern {
117 crate::messages::PATTERN_NO_LONGER_EXISTS.to_string()
118 } else {
119 crate::messages::HOST_NO_LONGER_EXISTS.to_string()
120 });
121 }
122 let duplicate = if self.forms.host.is_pattern {
123 alias != old_alias && self.hosts_state.ssh_config.has_host_block(&alias)
124 } else {
125 alias != old_alias && self.hosts_state.ssh_config.has_host(&alias)
126 };
127 if duplicate {
128 return Err(if self.forms.host.is_pattern {
129 crate::messages::pattern_already_exists(&alias)
130 } else {
131 crate::messages::host_alias_already_exists(&alias)
132 });
133 }
134 let old_entry = if self.forms.host.is_pattern {
135 self.hosts_state
136 .patterns
137 .iter()
138 .find(|p| p.pattern == old_alias)
139 .map(|p| HostEntry {
140 alias: p.pattern.clone(),
141 hostname: p.hostname.clone(),
142 user: p.user.clone(),
143 port: p.port,
144 identity_file: p.identity_file.clone(),
145 proxy_jump: p.proxy_jump.clone(),
146 tags: p.tags.clone(),
147 askpass: p.askpass.clone(),
148 ..Default::default()
149 })
150 .unwrap_or_default()
151 } else {
152 self.hosts_state
153 .list
154 .iter()
155 .find(|h| h.alias == old_alias)
156 .cloned()
157 .unwrap_or_default()
158 };
159 self.hosts_state.ssh_config.update_host(old_alias, &entry);
160 if !self.forms.host.is_pattern {
164 let tags_wired = self
165 .hosts_state
166 .ssh_config
167 .set_host_tags(&entry.alias, &entry.tags);
168 debug_assert!(
169 tags_wired,
170 "edit_host_from_form: alias '{}' missing immediately after update_host (set_host_tags)",
171 entry.alias
172 );
173 let askpass_wired = self
174 .hosts_state
175 .ssh_config
176 .set_host_askpass(&entry.alias, entry.askpass.as_deref().unwrap_or(""));
177 debug_assert!(
178 askpass_wired,
179 "edit_host_from_form: alias '{}' missing immediately after update_host (set_host_askpass)",
180 entry.alias
181 );
182 } else {
183 let _ = self
186 .hosts_state
187 .ssh_config
188 .set_host_tags(&entry.alias, &entry.tags);
189 let _ = self
190 .hosts_state
191 .ssh_config
192 .set_host_askpass(&entry.alias, entry.askpass.as_deref().unwrap_or(""));
193 }
194 if !self.forms.host.is_pattern {
200 let role_wired = self
201 .hosts_state
202 .ssh_config
203 .set_host_vault_ssh(&entry.alias, entry.vault_ssh.as_deref().unwrap_or(""));
204 debug_assert!(
205 role_wired,
206 "edit_host_from_form: alias '{}' missing immediately after update_host (set_host_vault_ssh)",
207 entry.alias
208 );
209 let addr_wired = self
210 .hosts_state
211 .ssh_config
212 .set_host_vault_addr(&entry.alias, entry.vault_addr.as_deref().unwrap_or(""));
213 debug_assert!(
214 addr_wired,
215 "edit_host_from_form: alias '{}' missing immediately after update_host (set_host_vault_addr)",
216 entry.alias
217 );
218 }
219 if entry.vault_ssh.is_some() {
225 if crate::should_write_certificate_file(&old_entry.certificate_file) {
226 let cert_path = crate::vault_ssh::cert_path_for(self.env().paths(), &entry.alias)
227 .map_err(|e| crate::messages::cert_path_resolve_failed(&e))?;
228 let wired = self
231 .hosts_state
232 .ssh_config
233 .set_host_certificate_file(&entry.alias, &cert_path.to_string_lossy());
234 debug_assert!(
235 wired,
236 "edit_host_from_form: alias '{}' missing immediately after update_host",
237 entry.alias
238 );
239 }
240 } else {
241 let purple_managed =
247 crate::vault_ssh::cert_path_for(self.env().paths(), &entry.alias).ok();
248 let existing_resolved = if old_entry.certificate_file.is_empty() {
249 None
250 } else {
251 crate::vault_ssh::resolve_cert_path(
252 self.env().paths(),
253 &entry.alias,
254 &old_entry.certificate_file,
255 )
256 .ok()
257 };
258 if purple_managed.is_some() && purple_managed == existing_resolved {
259 let _ = self
260 .hosts_state
261 .ssh_config
262 .set_host_certificate_file(&entry.alias, "");
263 }
264 }
265 if let Err(e) = self.hosts_state.ssh_config.write() {
266 self.hosts_state
267 .ssh_config
268 .update_host(&entry.alias, &old_entry);
269 let _ = self
270 .hosts_state
271 .ssh_config
272 .set_host_tags(&old_entry.alias, &old_entry.tags);
273 let _ = self
274 .hosts_state
275 .ssh_config
276 .set_host_askpass(&old_entry.alias, old_entry.askpass.as_deref().unwrap_or(""));
277 if !self.forms.host.is_pattern {
278 let _ = self.hosts_state.ssh_config.set_host_vault_ssh(
279 &old_entry.alias,
280 old_entry.vault_ssh.as_deref().unwrap_or(""),
281 );
282 let _ = self.hosts_state.ssh_config.set_host_vault_addr(
283 &old_entry.alias,
284 old_entry.vault_addr.as_deref().unwrap_or(""),
285 );
286 }
287 if old_entry.vault_ssh.is_some() {
288 let _ = self
293 .hosts_state
294 .ssh_config
295 .set_host_certificate_file(&old_entry.alias, &old_entry.certificate_file);
296 } else {
297 let _ = self
298 .hosts_state
299 .ssh_config
300 .set_host_certificate_file(&old_entry.alias, "");
301 }
302 return Err(crate::messages::failed_to_save(&e));
303 }
304 self.vault.pending_config_write = false;
306 self.update_last_modified();
307 let renames: Vec<(String, String)> = if alias != old_alias {
308 vec![(old_alias.to_string(), alias.clone())]
309 } else {
310 Vec::new()
311 };
312 self.rename_aliases(&renames);
313 if alias != old_alias {
318 self.vault.cert_cache.remove(old_alias);
319 }
320 self.refresh_cert_cache(&alias);
321 Ok(format!("{} got a makeover.", alias))
322 }
323
324 pub(crate) fn rename_aliases(&mut self, renames: &[(String, String)]) {
330 self.migrate_alias_keyed_caches(renames);
331 self.cleanup_stale_cert_files_for_renames(renames);
332 self.reload_hosts();
333 self.apply_alias_renames(renames);
334 }
335
336 fn cleanup_stale_cert_files_for_renames(&mut self, renames: &[(String, String)]) {
341 if crate::demo_flag::is_demo() {
342 return;
343 }
344 for (old_alias, new_alias) in renames {
345 if old_alias == new_alias {
346 continue;
347 }
348 let Ok(old_cert) = crate::vault_ssh::cert_path_for(self.env().paths(), old_alias)
349 else {
350 continue;
351 };
352 match std::fs::remove_file(&old_cert) {
353 Ok(()) => {}
354 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
355 Err(e) => {
356 self.vault.cleanup_warning = Some(format!(
357 "Warning: failed to clean up old Vault SSH cert {}: {}",
358 old_cert.display(),
359 e
360 ));
361 }
362 }
363 }
364 }
365
366 pub(crate) fn apply_alias_renames(&mut self, renames: &[(String, String)]) {
372 let mut applied = false;
373 for (old_alias, new_alias) in renames {
374 if old_alias == new_alias {
375 continue;
376 }
377 applied = true;
378 log::debug!("[purple] apply_alias_renames: {old_alias} -> {new_alias}");
379 self.history.rename(old_alias, new_alias);
380 let mut recents = crate::app::jump::load_recents();
381 if crate::app::jump::rename_host_recent(&mut recents, old_alias, new_alias) {
382 if let Err(e) = crate::app::jump::save_recents(&recents) {
383 log::warn!("[config] failed to save recents after rename: {e}");
384 }
385 }
386 }
387 if applied {
388 self.apply_sort();
389 }
390 }
391
392 pub(crate) fn migrate_alias_keyed_caches(&mut self, renames: &[(String, String)]) {
400 let mut container_cache_changed = false;
401 let mut collapsed_hosts_changed = false;
402 for (old_alias, new_alias) in renames {
403 if old_alias == new_alias {
404 continue;
405 }
406 log::debug!("[purple] migrate_alias_keyed_caches: {old_alias} -> {new_alias}");
407 self.ping.migrate_alias(old_alias, new_alias);
408 if self.container_state.migrate_alias(old_alias, new_alias) {
409 container_cache_changed = true;
410 }
411 if self.containers_overview.migrate_alias(old_alias, new_alias) {
416 collapsed_hosts_changed = true;
417 }
418 self.vault.migrate_alias(old_alias, new_alias);
423 self.tunnels.migrate_alias(old_alias, new_alias);
424 self.file_browser_state.migrate_alias(old_alias, new_alias);
425 }
426 if container_cache_changed {
427 crate::containers::save_container_cache(
428 self.env().paths(),
429 self.container_state.cache(),
430 );
431 }
432 if collapsed_hosts_changed {
433 if let Err(e) = crate::preferences::save_containers_collapsed_hosts(
434 self.env().paths(),
435 self.containers_overview.collapsed_hosts(),
436 ) {
437 log::warn!("[config] failed to save collapsed_hosts after rename: {e}");
438 }
439 }
440 }
441
442 pub fn select_host_by_alias(&mut self, alias: &str) {
444 if self.search.query.is_some() {
445 for (i, &host_idx) in self.search.filtered_indices.iter().enumerate() {
447 if self
448 .hosts_state
449 .list
450 .get(host_idx)
451 .is_some_and(|h| h.alias == alias)
452 {
453 self.ui.list_state.select(Some(i));
454 return;
455 }
456 }
457 let host_count = self.search.filtered_indices.len();
459 for (i, &pat_idx) in self.search.filtered_pattern_indices.iter().enumerate() {
460 if self
461 .hosts_state
462 .patterns
463 .get(pat_idx)
464 .is_some_and(|p| p.pattern == alias)
465 {
466 self.ui.list_state.select(Some(host_count + i));
467 return;
468 }
469 }
470 } else {
471 for (i, item) in self.hosts_state.display_list.iter().enumerate() {
472 match item {
473 HostListItem::Host { index } => {
474 if self
475 .hosts_state
476 .list
477 .get(*index)
478 .is_some_and(|h| h.alias == alias)
479 {
480 self.ui.list_state.select(Some(i));
481 return;
482 }
483 }
484 HostListItem::Pattern { index } => {
485 if self
486 .hosts_state
487 .patterns
488 .get(*index)
489 .is_some_and(|p| p.pattern == alias)
490 {
491 self.ui.list_state.select(Some(i));
492 return;
493 }
494 }
495 HostListItem::GroupHeader(_) => {}
496 }
497 }
498 }
499 }
500
501 pub fn apply_sync_result(
508 &mut self,
509 provider: &str,
510 hosts: Vec<crate::providers::ProviderHost>,
511 partial: bool,
512 ) -> (String, bool, usize, usize, usize, usize) {
513 let id: crate::providers::config::ProviderConfigId = match provider.parse() {
514 Ok(id) => id,
515 Err(_) => crate::providers::config::ProviderConfigId::bare(provider),
516 };
517 let section = match self.providers.config.section_by_id(&id).cloned() {
518 Some(s) => s,
519 None => {
520 return (
521 format!(
522 "{} sync skipped: no config.",
523 crate::providers::provider_display_name(&id.provider)
524 ),
525 true,
526 0,
527 0,
528 0,
529 0,
530 );
531 }
532 };
533 let provider_impl = match crate::providers::get_provider_with_config(§ion) {
534 Some(p) => p,
535 None => {
536 return (
537 format!(
538 "Unknown provider: {}.",
539 crate::providers::provider_display_name(provider)
540 ),
541 true,
542 0,
543 0,
544 0,
545 0,
546 );
547 }
548 };
549 let config_backup = self.hosts_state.ssh_config.clone();
550 let result = crate::providers::sync::sync_provider(
551 &mut self.hosts_state.ssh_config,
552 &*provider_impl,
553 &hosts,
554 §ion,
555 false,
556 partial, false,
558 );
559 let total = result.added + result.updated + result.unchanged;
560 if result.added > 0 || result.updated > 0 || result.stale > 0 {
561 if self.external_config_changed() {
569 self.hosts_state.ssh_config = config_backup;
570 return (
571 crate::messages::sync_skipped_external_change().to_string(),
572 true,
573 total,
574 0,
575 0,
576 0,
577 );
578 }
579 if let Err(e) = self.hosts_state.ssh_config.write() {
580 self.hosts_state.ssh_config = config_backup;
581 return (format!("Sync failed to save: {}", e), true, total, 0, 0, 0);
582 }
583 self.hosts_state.undo_stack.clear();
584 self.update_last_modified();
585 self.rename_aliases(&result.renames);
586 }
587 let name = crate::providers::provider_display_name(provider);
588 let mut msg = format!(
589 "Synced {}: added {}, updated {}, unchanged {}",
590 name, result.added, result.updated, result.unchanged
591 );
592 if result.stale > 0 {
593 msg.push_str(&format!(", stale {}", result.stale));
594 }
595 msg.push('.');
596 (
597 msg,
598 false,
599 total,
600 result.added,
601 result.updated,
602 result.stale,
603 )
604 }
605
606 pub fn clear_stale_group_tag(&mut self) -> bool {
609 if let GroupBy::Tag(ref tag) = self.hosts_state.group_by {
610 if tag.is_empty() {
612 return false;
613 }
614 let tag_exists = self
615 .hosts_state
616 .list
617 .iter()
618 .any(|h| h.tags.iter().any(|t| t == tag))
619 || self
620 .hosts_state
621 .patterns
622 .iter()
623 .any(|p| p.tags.iter().any(|t| t == tag));
624 if !tag_exists {
625 self.hosts_state.set_group_by(GroupBy::None);
626 return true;
627 }
628 }
629 false
630 }
631}
632
633pub fn migrate_renames_persistent_state(
647 paths: Option<&crate::runtime::env::Paths>,
648 renames: &[(String, String)],
649) {
650 for (old_alias, new_alias) in renames {
651 if old_alias == new_alias {
652 continue;
653 }
654 let mut history = crate::history::ConnectionHistory::load();
656 history.rename(old_alias, new_alias);
657
658 let mut recents = crate::app::jump::load_recents();
659 if crate::app::jump::rename_host_recent(&mut recents, old_alias, new_alias) {
660 if let Err(e) = crate::app::jump::save_recents(&recents) {
661 log::warn!("[config] failed to save recents after cli sync rename: {e}");
662 }
663 }
664
665 let mut collapsed = crate::preferences::load_containers_collapsed_hosts(paths);
666 if collapsed.remove(old_alias) {
667 collapsed.insert(new_alias.clone());
668 if let Err(e) = crate::preferences::save_containers_collapsed_hosts(paths, &collapsed) {
669 log::warn!("[config] failed to save collapsed_hosts after cli sync rename: {e}");
670 }
671 }
672 }
673}