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 if let Some(v) = self.ping.status.remove(old_alias) {
408 self.ping.status.insert(new_alias.clone(), v);
409 }
410 if let Some(v) = self.ping.last_checked.remove(old_alias) {
411 self.ping.last_checked.insert(new_alias.clone(), v);
412 }
413 if self.container_state.migrate_alias(old_alias, new_alias) {
414 container_cache_changed = true;
415 }
416 if self.containers_overview.migrate_alias(old_alias, new_alias) {
421 collapsed_hosts_changed = true;
422 }
423 if self.vault.cert_checks_in_flight.remove(old_alias) {
424 self.vault.cert_checks_in_flight.insert(new_alias.clone());
425 }
426 if let Some(t) = self.tunnels.active.remove(old_alias) {
427 self.tunnels.active.insert(new_alias.clone(), t);
428 }
429 if let Some(v) = self.file_browser_state.host_paths.remove(old_alias) {
430 self.file_browser_state
431 .host_paths
432 .insert(new_alias.clone(), v);
433 }
434 {
439 let mut sign = match self.vault.sign_in_flight.lock() {
440 Ok(g) => g,
441 Err(p) => p.into_inner(),
442 };
443 if sign.remove(old_alias) {
444 sign.insert(new_alias.clone());
445 }
446 }
447 }
448 if container_cache_changed {
449 crate::containers::save_container_cache(
450 self.env().paths(),
451 &self.container_state.cache,
452 );
453 }
454 if collapsed_hosts_changed {
455 if let Err(e) = crate::preferences::save_containers_collapsed_hosts(
456 self.env().paths(),
457 &self.containers_overview.collapsed_hosts,
458 ) {
459 log::warn!("[config] failed to save collapsed_hosts after rename: {e}");
460 }
461 }
462 }
463
464 pub fn select_host_by_alias(&mut self, alias: &str) {
466 if self.search.query.is_some() {
467 for (i, &host_idx) in self.search.filtered_indices.iter().enumerate() {
469 if self
470 .hosts_state
471 .list
472 .get(host_idx)
473 .is_some_and(|h| h.alias == alias)
474 {
475 self.ui.list_state.select(Some(i));
476 return;
477 }
478 }
479 let host_count = self.search.filtered_indices.len();
481 for (i, &pat_idx) in self.search.filtered_pattern_indices.iter().enumerate() {
482 if self
483 .hosts_state
484 .patterns
485 .get(pat_idx)
486 .is_some_and(|p| p.pattern == alias)
487 {
488 self.ui.list_state.select(Some(host_count + i));
489 return;
490 }
491 }
492 } else {
493 for (i, item) in self.hosts_state.display_list.iter().enumerate() {
494 match item {
495 HostListItem::Host { index } => {
496 if self
497 .hosts_state
498 .list
499 .get(*index)
500 .is_some_and(|h| h.alias == alias)
501 {
502 self.ui.list_state.select(Some(i));
503 return;
504 }
505 }
506 HostListItem::Pattern { index } => {
507 if self
508 .hosts_state
509 .patterns
510 .get(*index)
511 .is_some_and(|p| p.pattern == alias)
512 {
513 self.ui.list_state.select(Some(i));
514 return;
515 }
516 }
517 HostListItem::GroupHeader(_) => {}
518 }
519 }
520 }
521 }
522
523 pub fn apply_sync_result(
530 &mut self,
531 provider: &str,
532 hosts: Vec<crate::providers::ProviderHost>,
533 partial: bool,
534 ) -> (String, bool, usize, usize, usize, usize) {
535 let id: crate::providers::config::ProviderConfigId = match provider.parse() {
536 Ok(id) => id,
537 Err(_) => crate::providers::config::ProviderConfigId::bare(provider),
538 };
539 let section = match self.providers.config.section_by_id(&id).cloned() {
540 Some(s) => s,
541 None => {
542 return (
543 format!(
544 "{} sync skipped: no config.",
545 crate::providers::provider_display_name(&id.provider)
546 ),
547 true,
548 0,
549 0,
550 0,
551 0,
552 );
553 }
554 };
555 let provider_impl = match crate::providers::get_provider_with_config(§ion) {
556 Some(p) => p,
557 None => {
558 return (
559 format!(
560 "Unknown provider: {}.",
561 crate::providers::provider_display_name(provider)
562 ),
563 true,
564 0,
565 0,
566 0,
567 0,
568 );
569 }
570 };
571 let config_backup = self.hosts_state.ssh_config.clone();
572 let result = crate::providers::sync::sync_provider(
573 &mut self.hosts_state.ssh_config,
574 &*provider_impl,
575 &hosts,
576 §ion,
577 false,
578 partial, false,
580 );
581 let total = result.added + result.updated + result.unchanged;
582 if result.added > 0 || result.updated > 0 || result.stale > 0 {
583 if self.external_config_changed() {
591 self.hosts_state.ssh_config = config_backup;
592 return (
593 crate::messages::sync_skipped_external_change().to_string(),
594 true,
595 total,
596 0,
597 0,
598 0,
599 );
600 }
601 if let Err(e) = self.hosts_state.ssh_config.write() {
602 self.hosts_state.ssh_config = config_backup;
603 return (format!("Sync failed to save: {}", e), true, total, 0, 0, 0);
604 }
605 self.hosts_state.undo_stack.clear();
606 self.update_last_modified();
607 self.rename_aliases(&result.renames);
608 }
609 let name = crate::providers::provider_display_name(provider);
610 let mut msg = format!(
611 "Synced {}: added {}, updated {}, unchanged {}",
612 name, result.added, result.updated, result.unchanged
613 );
614 if result.stale > 0 {
615 msg.push_str(&format!(", stale {}", result.stale));
616 }
617 msg.push('.');
618 (
619 msg,
620 false,
621 total,
622 result.added,
623 result.updated,
624 result.stale,
625 )
626 }
627
628 pub fn clear_stale_group_tag(&mut self) -> bool {
631 if let GroupBy::Tag(ref tag) = self.hosts_state.group_by {
632 if tag.is_empty() {
634 return false;
635 }
636 let tag_exists = self
637 .hosts_state
638 .list
639 .iter()
640 .any(|h| h.tags.iter().any(|t| t == tag))
641 || self
642 .hosts_state
643 .patterns
644 .iter()
645 .any(|p| p.tags.iter().any(|t| t == tag));
646 if !tag_exists {
647 self.hosts_state.set_group_by(GroupBy::None);
648 return true;
649 }
650 }
651 false
652 }
653}
654
655pub fn migrate_renames_persistent_state(
669 paths: Option<&crate::runtime::env::Paths>,
670 renames: &[(String, String)],
671) {
672 for (old_alias, new_alias) in renames {
673 if old_alias == new_alias {
674 continue;
675 }
676 let mut history = crate::history::ConnectionHistory::load();
678 history.rename(old_alias, new_alias);
679
680 let mut recents = crate::app::jump::load_recents();
681 if crate::app::jump::rename_host_recent(&mut recents, old_alias, new_alias) {
682 if let Err(e) = crate::app::jump::save_recents(&recents) {
683 log::warn!("[config] failed to save recents after cli sync rename: {e}");
684 }
685 }
686
687 let mut collapsed = crate::preferences::load_containers_collapsed_hosts(paths);
688 if collapsed.remove(old_alias) {
689 collapsed.insert(new_alias.clone());
690 if let Err(e) = crate::preferences::save_containers_collapsed_hosts(paths, &collapsed) {
691 log::warn!("[config] failed to save collapsed_hosts after cli sync rename: {e}");
692 }
693 }
694 }
695}