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 let paths = self.env.paths().cloned();
374 for (old_alias, new_alias) in renames {
375 if old_alias == new_alias {
376 continue;
377 }
378 applied = true;
379 log::debug!("[purple] apply_alias_renames: {old_alias} -> {new_alias}");
380 self.history.rename(old_alias, new_alias);
381 let mut recents = crate::app::jump::load_recents(paths.as_ref());
382 if crate::app::jump::rename_host_recent(&mut recents, old_alias, new_alias) {
383 if let Err(e) = crate::app::jump::save_recents(&recents, paths.as_ref()) {
384 log::warn!("[config] failed to save recents after rename: {e}");
385 }
386 }
387 }
388 if applied {
389 self.apply_sort();
390 }
391 }
392
393 pub(crate) fn migrate_alias_keyed_caches(&mut self, renames: &[(String, String)]) {
401 let mut container_cache_changed = false;
402 let mut collapsed_hosts_changed = false;
403 for (old_alias, new_alias) in renames {
404 if old_alias == new_alias {
405 continue;
406 }
407 log::debug!("[purple] migrate_alias_keyed_caches: {old_alias} -> {new_alias}");
408 self.ping.migrate_alias(old_alias, new_alias);
409 if self.container_state.migrate_alias(old_alias, new_alias) {
410 container_cache_changed = true;
411 }
412 if self.containers_overview.migrate_alias(old_alias, new_alias) {
417 collapsed_hosts_changed = true;
418 }
419 self.vault.migrate_alias(old_alias, new_alias);
424 self.tunnels.migrate_alias(old_alias, new_alias);
425 self.file_browser_state.migrate_alias(old_alias, new_alias);
426 }
427 if container_cache_changed {
428 crate::containers::save_container_cache(
429 self.env().paths(),
430 self.container_state.cache(),
431 );
432 }
433 if collapsed_hosts_changed {
434 if let Err(e) = crate::preferences::save_containers_collapsed_hosts(
435 self.env().paths(),
436 self.containers_overview.collapsed_hosts(),
437 ) {
438 log::warn!("[config] failed to save collapsed_hosts after rename: {e}");
439 }
440 }
441 }
442
443 pub fn select_host_by_alias(&mut self, alias: &str) {
445 if self.search.query.is_some() {
446 for (i, &host_idx) in self.search.filtered_indices.iter().enumerate() {
448 if self
449 .hosts_state
450 .list
451 .get(host_idx)
452 .is_some_and(|h| h.alias == alias)
453 {
454 self.ui.list_state.select(Some(i));
455 return;
456 }
457 }
458 let host_count = self.search.filtered_indices.len();
460 for (i, &pat_idx) in self.search.filtered_pattern_indices.iter().enumerate() {
461 if self
462 .hosts_state
463 .patterns
464 .get(pat_idx)
465 .is_some_and(|p| p.pattern == alias)
466 {
467 self.ui.list_state.select(Some(host_count + i));
468 return;
469 }
470 }
471 } else {
472 for (i, item) in self.hosts_state.display_list.iter().enumerate() {
473 match item {
474 HostListItem::Host { index } => {
475 if self
476 .hosts_state
477 .list
478 .get(*index)
479 .is_some_and(|h| h.alias == alias)
480 {
481 self.ui.list_state.select(Some(i));
482 return;
483 }
484 }
485 HostListItem::Pattern { index } => {
486 if self
487 .hosts_state
488 .patterns
489 .get(*index)
490 .is_some_and(|p| p.pattern == alias)
491 {
492 self.ui.list_state.select(Some(i));
493 return;
494 }
495 }
496 HostListItem::GroupHeader(_) => {}
497 }
498 }
499 }
500 }
501
502 pub fn apply_sync_result(
509 &mut self,
510 provider: &str,
511 hosts: Vec<crate::providers::ProviderHost>,
512 partial: bool,
513 ) -> (String, bool, usize, usize, usize, usize) {
514 let id: crate::providers::config::ProviderConfigId = match provider.parse() {
515 Ok(id) => id,
516 Err(_) => crate::providers::config::ProviderConfigId::bare(provider),
517 };
518 let section = match self.providers.config.section_by_id(&id).cloned() {
519 Some(s) => s,
520 None => {
521 return (
522 format!(
523 "{} sync skipped: no config.",
524 crate::providers::provider_display_name(&id.provider)
525 ),
526 true,
527 0,
528 0,
529 0,
530 0,
531 );
532 }
533 };
534 let provider_impl = match crate::providers::get_provider_with_config(§ion) {
535 Some(p) => p,
536 None => {
537 return (
538 format!(
539 "Unknown provider: {}.",
540 crate::providers::provider_display_name(provider)
541 ),
542 true,
543 0,
544 0,
545 0,
546 0,
547 );
548 }
549 };
550 let config_backup = self.hosts_state.ssh_config.clone();
551 let result = crate::providers::sync::sync_provider(
552 &mut self.hosts_state.ssh_config,
553 &*provider_impl,
554 &hosts,
555 §ion,
556 false,
557 partial, false,
559 );
560 let total = result.added + result.updated + result.unchanged;
561 if result.added > 0 || result.updated > 0 || result.stale > 0 {
562 if self.external_config_changed() {
570 self.hosts_state.ssh_config = config_backup;
571 return (
572 crate::messages::sync_skipped_external_change().to_string(),
573 true,
574 total,
575 0,
576 0,
577 0,
578 );
579 }
580 if let Err(e) = self.hosts_state.ssh_config.write() {
581 self.hosts_state.ssh_config = config_backup;
582 return (format!("Sync failed to save: {}", e), true, total, 0, 0, 0);
583 }
584 self.hosts_state.undo_stack.clear();
585 self.update_last_modified();
586 self.rename_aliases(&result.renames);
587 }
588 let name = crate::providers::provider_display_name(provider);
589 let mut msg = format!(
590 "Synced {}: added {}, updated {}, unchanged {}",
591 name, result.added, result.updated, result.unchanged
592 );
593 if result.stale > 0 {
594 msg.push_str(&format!(", stale {}", result.stale));
595 }
596 msg.push('.');
597 (
598 msg,
599 false,
600 total,
601 result.added,
602 result.updated,
603 result.stale,
604 )
605 }
606
607 pub fn clear_stale_group_tag(&mut self) -> bool {
610 if let GroupBy::Tag(ref tag) = self.hosts_state.group_by {
611 if tag.is_empty() {
613 return false;
614 }
615 let tag_exists = self
616 .hosts_state
617 .list
618 .iter()
619 .any(|h| h.tags.iter().any(|t| t == tag))
620 || self
621 .hosts_state
622 .patterns
623 .iter()
624 .any(|p| p.tags.iter().any(|t| t == tag));
625 if !tag_exists {
626 self.hosts_state.set_group_by(GroupBy::None);
627 return true;
628 }
629 }
630 false
631 }
632}
633
634pub fn migrate_renames_persistent_state(
648 paths: Option<&crate::runtime::env::Paths>,
649 renames: &[(String, String)],
650) {
651 for (old_alias, new_alias) in renames {
652 if old_alias == new_alias {
653 continue;
654 }
655 let mut history = crate::history::ConnectionHistory::load(paths);
657 history.rename(old_alias, new_alias);
658
659 let mut recents = crate::app::jump::load_recents(paths);
660 if crate::app::jump::rename_host_recent(&mut recents, old_alias, new_alias) {
661 if let Err(e) = crate::app::jump::save_recents(&recents, paths) {
662 log::warn!("[config] failed to save recents after cli sync rename: {e}");
663 }
664 }
665
666 let mut collapsed = crate::preferences::load_containers_collapsed_hosts(paths);
667 if collapsed.remove(old_alias) {
668 collapsed.insert(new_alias.clone());
669 if let Err(e) = crate::preferences::save_containers_collapsed_hosts(paths, &collapsed) {
670 log::warn!("[config] failed to save collapsed_hosts after cli sync rename: {e}");
671 }
672 }
673 }
674}