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(&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(&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 = crate::vault_ssh::cert_path_for(&entry.alias).ok();
247 let existing_resolved = if old_entry.certificate_file.is_empty() {
248 None
249 } else {
250 crate::vault_ssh::resolve_cert_path(&entry.alias, &old_entry.certificate_file).ok()
251 };
252 if purple_managed.is_some() && purple_managed == existing_resolved {
253 let _ = self
254 .hosts_state
255 .ssh_config
256 .set_host_certificate_file(&entry.alias, "");
257 }
258 }
259 if let Err(e) = self.hosts_state.ssh_config.write() {
260 self.hosts_state
261 .ssh_config
262 .update_host(&entry.alias, &old_entry);
263 let _ = self
264 .hosts_state
265 .ssh_config
266 .set_host_tags(&old_entry.alias, &old_entry.tags);
267 let _ = self
268 .hosts_state
269 .ssh_config
270 .set_host_askpass(&old_entry.alias, old_entry.askpass.as_deref().unwrap_or(""));
271 if !self.forms.host.is_pattern {
272 let _ = self.hosts_state.ssh_config.set_host_vault_ssh(
273 &old_entry.alias,
274 old_entry.vault_ssh.as_deref().unwrap_or(""),
275 );
276 let _ = self.hosts_state.ssh_config.set_host_vault_addr(
277 &old_entry.alias,
278 old_entry.vault_addr.as_deref().unwrap_or(""),
279 );
280 }
281 if old_entry.vault_ssh.is_some() {
282 let _ = self
287 .hosts_state
288 .ssh_config
289 .set_host_certificate_file(&old_entry.alias, &old_entry.certificate_file);
290 } else {
291 let _ = self
292 .hosts_state
293 .ssh_config
294 .set_host_certificate_file(&old_entry.alias, "");
295 }
296 return Err(crate::messages::failed_to_save(&e));
297 }
298 self.vault.pending_config_write = false;
300 self.update_last_modified();
301 let renames: Vec<(String, String)> = if alias != old_alias {
302 vec![(old_alias.to_string(), alias.clone())]
303 } else {
304 Vec::new()
305 };
306 self.rename_aliases(&renames);
307 if alias != old_alias {
312 self.vault.cert_cache.remove(old_alias);
313 }
314 self.refresh_cert_cache(&alias);
315 Ok(format!("{} got a makeover.", alias))
316 }
317
318 pub(crate) fn rename_aliases(&mut self, renames: &[(String, String)]) {
324 self.migrate_alias_keyed_caches(renames);
325 self.cleanup_stale_cert_files_for_renames(renames);
326 self.reload_hosts();
327 self.apply_alias_renames(renames);
328 }
329
330 fn cleanup_stale_cert_files_for_renames(&mut self, renames: &[(String, String)]) {
335 if crate::demo_flag::is_demo() {
336 return;
337 }
338 for (old_alias, new_alias) in renames {
339 if old_alias == new_alias {
340 continue;
341 }
342 let Ok(old_cert) = crate::vault_ssh::cert_path_for(old_alias) else {
343 continue;
344 };
345 match std::fs::remove_file(&old_cert) {
346 Ok(()) => {}
347 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
348 Err(e) => {
349 self.vault.cleanup_warning = Some(format!(
350 "Warning: failed to clean up old Vault SSH cert {}: {}",
351 old_cert.display(),
352 e
353 ));
354 }
355 }
356 }
357 }
358
359 pub(crate) fn apply_alias_renames(&mut self, renames: &[(String, String)]) {
365 let mut applied = false;
366 for (old_alias, new_alias) in renames {
367 if old_alias == new_alias {
368 continue;
369 }
370 applied = true;
371 log::debug!("[purple] apply_alias_renames: {old_alias} -> {new_alias}");
372 self.history.rename(old_alias, new_alias);
373 let mut recents = crate::app::jump::load_recents();
374 if crate::app::jump::rename_host_recent(&mut recents, old_alias, new_alias) {
375 if let Err(e) = crate::app::jump::save_recents(&recents) {
376 log::warn!("[config] failed to save recents after rename: {e}");
377 }
378 }
379 }
380 if applied {
381 self.apply_sort();
382 }
383 }
384
385 pub(crate) fn migrate_alias_keyed_caches(&mut self, renames: &[(String, String)]) {
393 let mut container_cache_changed = false;
394 let mut collapsed_hosts_changed = false;
395 for (old_alias, new_alias) in renames {
396 if old_alias == new_alias {
397 continue;
398 }
399 log::debug!("[purple] migrate_alias_keyed_caches: {old_alias} -> {new_alias}");
400 if let Some(v) = self.ping.status.remove(old_alias) {
401 self.ping.status.insert(new_alias.clone(), v);
402 }
403 if let Some(v) = self.ping.last_checked.remove(old_alias) {
404 self.ping.last_checked.insert(new_alias.clone(), v);
405 }
406 if let Some(v) = self.container_state.cache.remove(old_alias) {
407 self.container_state.cache.insert(new_alias.clone(), v);
408 container_cache_changed = true;
409 }
410 if self
411 .containers_overview
412 .auto_list_in_flight
413 .remove(old_alias)
414 {
415 self.containers_overview
416 .auto_list_in_flight
417 .insert(new_alias.clone());
418 }
419 if self.vault.cert_checks_in_flight.remove(old_alias) {
420 self.vault.cert_checks_in_flight.insert(new_alias.clone());
421 }
422 if let Some(t) = self.tunnels.active.remove(old_alias) {
423 self.tunnels.active.insert(new_alias.clone(), t);
424 }
425 if let Some(v) = self.file_browser_state.host_paths.remove(old_alias) {
426 self.file_browser_state
427 .host_paths
428 .insert(new_alias.clone(), v);
429 }
430 if let Some(batch) = self.containers_overview.refresh_batch.as_mut() {
431 if batch.in_flight_aliases.remove(old_alias) {
432 batch.in_flight_aliases.insert(new_alias.clone());
433 }
434 }
435 {
440 let mut sign = match self.vault.sign_in_flight.lock() {
441 Ok(g) => g,
442 Err(p) => p.into_inner(),
443 };
444 if sign.remove(old_alias) {
445 sign.insert(new_alias.clone());
446 }
447 }
448 if self.containers_overview.collapsed_hosts.remove(old_alias) {
452 self.containers_overview
453 .collapsed_hosts
454 .insert(new_alias.clone());
455 collapsed_hosts_changed = true;
456 }
457 }
458 if container_cache_changed {
459 crate::containers::save_container_cache(&self.container_state.cache);
460 }
461 if collapsed_hosts_changed {
462 if let Err(e) = crate::preferences::save_containers_collapsed_hosts(
463 &self.containers_overview.collapsed_hosts,
464 ) {
465 log::warn!("[config] failed to save collapsed_hosts after rename: {e}");
466 }
467 }
468 }
469
470 pub fn select_host_by_alias(&mut self, alias: &str) {
472 if self.search.query.is_some() {
473 for (i, &host_idx) in self.search.filtered_indices.iter().enumerate() {
475 if self
476 .hosts_state
477 .list
478 .get(host_idx)
479 .is_some_and(|h| h.alias == alias)
480 {
481 self.ui.list_state.select(Some(i));
482 return;
483 }
484 }
485 let host_count = self.search.filtered_indices.len();
487 for (i, &pat_idx) in self.search.filtered_pattern_indices.iter().enumerate() {
488 if self
489 .hosts_state
490 .patterns
491 .get(pat_idx)
492 .is_some_and(|p| p.pattern == alias)
493 {
494 self.ui.list_state.select(Some(host_count + i));
495 return;
496 }
497 }
498 } else {
499 for (i, item) in self.hosts_state.display_list.iter().enumerate() {
500 match item {
501 HostListItem::Host { index } => {
502 if self
503 .hosts_state
504 .list
505 .get(*index)
506 .is_some_and(|h| h.alias == alias)
507 {
508 self.ui.list_state.select(Some(i));
509 return;
510 }
511 }
512 HostListItem::Pattern { index } => {
513 if self
514 .hosts_state
515 .patterns
516 .get(*index)
517 .is_some_and(|p| p.pattern == alias)
518 {
519 self.ui.list_state.select(Some(i));
520 return;
521 }
522 }
523 HostListItem::GroupHeader(_) => {}
524 }
525 }
526 }
527 }
528
529 pub fn apply_sync_result(
536 &mut self,
537 provider: &str,
538 hosts: Vec<crate::providers::ProviderHost>,
539 partial: bool,
540 ) -> (String, bool, usize, usize, usize, usize) {
541 let id: crate::providers::config::ProviderConfigId = match provider.parse() {
542 Ok(id) => id,
543 Err(_) => crate::providers::config::ProviderConfigId::bare(provider),
544 };
545 let section = match self.providers.config.section_by_id(&id).cloned() {
546 Some(s) => s,
547 None => {
548 return (
549 format!(
550 "{} sync skipped: no config.",
551 crate::providers::provider_display_name(&id.provider)
552 ),
553 true,
554 0,
555 0,
556 0,
557 0,
558 );
559 }
560 };
561 let provider_impl = match crate::providers::get_provider_with_config(§ion) {
562 Some(p) => p,
563 None => {
564 return (
565 format!(
566 "Unknown provider: {}.",
567 crate::providers::provider_display_name(provider)
568 ),
569 true,
570 0,
571 0,
572 0,
573 0,
574 );
575 }
576 };
577 let config_backup = self.hosts_state.ssh_config.clone();
578 let result = crate::providers::sync::sync_provider(
579 &mut self.hosts_state.ssh_config,
580 &*provider_impl,
581 &hosts,
582 §ion,
583 false,
584 partial, false,
586 );
587 let total = result.added + result.updated + result.unchanged;
588 if result.added > 0 || result.updated > 0 || result.stale > 0 {
589 if self.external_config_changed() {
597 self.hosts_state.ssh_config = config_backup;
598 return (
599 crate::messages::sync_skipped_external_change().to_string(),
600 true,
601 total,
602 0,
603 0,
604 0,
605 );
606 }
607 if let Err(e) = self.hosts_state.ssh_config.write() {
608 self.hosts_state.ssh_config = config_backup;
609 return (format!("Sync failed to save: {}", e), true, total, 0, 0, 0);
610 }
611 self.hosts_state.undo_stack.clear();
612 self.update_last_modified();
613 self.rename_aliases(&result.renames);
614 }
615 let name = crate::providers::provider_display_name(provider);
616 let mut msg = format!(
617 "Synced {}: added {}, updated {}, unchanged {}",
618 name, result.added, result.updated, result.unchanged
619 );
620 if result.stale > 0 {
621 msg.push_str(&format!(", stale {}", result.stale));
622 }
623 msg.push('.');
624 (
625 msg,
626 false,
627 total,
628 result.added,
629 result.updated,
630 result.stale,
631 )
632 }
633
634 pub fn clear_stale_group_tag(&mut self) -> bool {
637 if let GroupBy::Tag(ref tag) = self.hosts_state.group_by {
638 if tag.is_empty() {
640 return false;
641 }
642 let tag_exists = self
643 .hosts_state
644 .list
645 .iter()
646 .any(|h| h.tags.iter().any(|t| t == tag))
647 || self
648 .hosts_state
649 .patterns
650 .iter()
651 .any(|p| p.tags.iter().any(|t| t == tag));
652 if !tag_exists {
653 self.hosts_state.set_group_by(GroupBy::None);
654 return true;
655 }
656 }
657 false
658 }
659}
660
661pub fn migrate_renames_persistent_state(renames: &[(String, String)]) {
675 for (old_alias, new_alias) in renames {
676 if old_alias == new_alias {
677 continue;
678 }
679 let mut history = crate::history::ConnectionHistory::load();
681 history.rename(old_alias, new_alias);
682
683 let mut recents = crate::app::jump::load_recents();
684 if crate::app::jump::rename_host_recent(&mut recents, old_alias, new_alias) {
685 if let Err(e) = crate::app::jump::save_recents(&recents) {
686 log::warn!("[config] failed to save recents after cli sync rename: {e}");
687 }
688 }
689
690 let mut collapsed = crate::preferences::load_containers_collapsed_hosts();
691 if collapsed.remove(old_alias) {
692 collapsed.insert(new_alias.clone());
693 if let Err(e) = crate::preferences::save_containers_collapsed_hosts(&collapsed) {
694 log::warn!("[config] failed to save collapsed_hosts after cli sync rename: {e}");
695 }
696 }
697 }
698}