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 log::warn!("[config] failed to save new host: alias={alias}: {e}");
93 return Err(crate::messages::failed_to_save(&e));
94 }
95 log::debug!(
96 "[purple] host added: alias={alias} is_pattern={}",
97 self.forms.host.is_pattern
98 );
99 self.vault.pending_config_write = false;
101 self.update_last_modified();
102 self.reload_hosts();
103 self.select_host_by_alias(&alias);
104 self.refresh_cert_cache(&alias);
108 Ok(crate::messages::welcome_aboard(&alias))
109 }
110
111 pub fn edit_host_from_form(&mut self, old_alias: &str) -> Result<String, String> {
113 let entry = self.forms.host.to_entry();
114 let alias = entry.alias.clone();
115 let exists = if self.forms.host.is_pattern {
116 self.hosts_state.ssh_config.has_host_block(old_alias)
117 } else {
118 self.hosts_state.ssh_config.has_host(old_alias)
119 };
120 if !exists {
121 return Err(if self.forms.host.is_pattern {
122 crate::messages::PATTERN_NO_LONGER_EXISTS.to_string()
123 } else {
124 crate::messages::HOST_NO_LONGER_EXISTS.to_string()
125 });
126 }
127 let duplicate = if self.forms.host.is_pattern {
128 alias != old_alias && self.hosts_state.ssh_config.has_host_block(&alias)
129 } else {
130 alias != old_alias && self.hosts_state.ssh_config.has_host(&alias)
131 };
132 if duplicate {
133 return Err(if self.forms.host.is_pattern {
134 crate::messages::pattern_already_exists(&alias)
135 } else {
136 crate::messages::host_alias_already_exists(&alias)
137 });
138 }
139 let old_entry = if self.forms.host.is_pattern {
140 self.hosts_state
141 .patterns
142 .iter()
143 .find(|p| p.pattern == old_alias)
144 .map(|p| HostEntry {
145 alias: p.pattern.clone(),
146 hostname: p.hostname.clone(),
147 user: p.user.clone(),
148 port: p.port,
149 identity_file: p.identity_file.clone(),
150 proxy_jump: p.proxy_jump.clone(),
151 tags: p.tags.clone(),
152 askpass: p.askpass.clone(),
153 ..Default::default()
154 })
155 .unwrap_or_default()
156 } else {
157 self.hosts_state
158 .list
159 .iter()
160 .find(|h| h.alias == old_alias)
161 .cloned()
162 .unwrap_or_default()
163 };
164 self.hosts_state.ssh_config.update_host(old_alias, &entry);
165 if !self.forms.host.is_pattern {
169 let tags_wired = self
170 .hosts_state
171 .ssh_config
172 .set_host_tags(&entry.alias, &entry.tags);
173 debug_assert!(
174 tags_wired,
175 "edit_host_from_form: alias '{}' missing immediately after update_host (set_host_tags)",
176 entry.alias
177 );
178 let askpass_wired = self
179 .hosts_state
180 .ssh_config
181 .set_host_askpass(&entry.alias, entry.askpass.as_deref().unwrap_or(""));
182 debug_assert!(
183 askpass_wired,
184 "edit_host_from_form: alias '{}' missing immediately after update_host (set_host_askpass)",
185 entry.alias
186 );
187 } else {
188 let _ = self
191 .hosts_state
192 .ssh_config
193 .set_host_tags(&entry.alias, &entry.tags);
194 let _ = self
195 .hosts_state
196 .ssh_config
197 .set_host_askpass(&entry.alias, entry.askpass.as_deref().unwrap_or(""));
198 }
199 if !self.forms.host.is_pattern {
205 let role_wired = self
206 .hosts_state
207 .ssh_config
208 .set_host_vault_ssh(&entry.alias, entry.vault_ssh.as_deref().unwrap_or(""));
209 debug_assert!(
210 role_wired,
211 "edit_host_from_form: alias '{}' missing immediately after update_host (set_host_vault_ssh)",
212 entry.alias
213 );
214 let addr_wired = self
215 .hosts_state
216 .ssh_config
217 .set_host_vault_addr(&entry.alias, entry.vault_addr.as_deref().unwrap_or(""));
218 debug_assert!(
219 addr_wired,
220 "edit_host_from_form: alias '{}' missing immediately after update_host (set_host_vault_addr)",
221 entry.alias
222 );
223 }
224 if entry.vault_ssh.is_some() {
230 if crate::should_write_certificate_file(&old_entry.certificate_file) {
231 let cert_path = crate::vault_ssh::cert_path_for(self.env().paths(), &entry.alias)
232 .map_err(|e| crate::messages::cert_path_resolve_failed(&e))?;
233 let wired = self
236 .hosts_state
237 .ssh_config
238 .set_host_certificate_file(&entry.alias, &cert_path.to_string_lossy());
239 debug_assert!(
240 wired,
241 "edit_host_from_form: alias '{}' missing immediately after update_host",
242 entry.alias
243 );
244 }
245 } else {
246 let purple_managed =
252 crate::vault_ssh::cert_path_for(self.env().paths(), &entry.alias).ok();
253 let existing_resolved = if old_entry.certificate_file.is_empty() {
254 None
255 } else {
256 crate::vault_ssh::resolve_cert_path(
257 self.env().paths(),
258 &entry.alias,
259 &old_entry.certificate_file,
260 )
261 .ok()
262 };
263 if purple_managed.is_some() && purple_managed == existing_resolved {
264 let _ = self
265 .hosts_state
266 .ssh_config
267 .set_host_certificate_file(&entry.alias, "");
268 }
269 }
270 if let Err(e) = self.hosts_state.ssh_config.write() {
271 self.hosts_state
272 .ssh_config
273 .update_host(&entry.alias, &old_entry);
274 let _ = self
275 .hosts_state
276 .ssh_config
277 .set_host_tags(&old_entry.alias, &old_entry.tags);
278 let _ = self
279 .hosts_state
280 .ssh_config
281 .set_host_askpass(&old_entry.alias, old_entry.askpass.as_deref().unwrap_or(""));
282 if !self.forms.host.is_pattern {
283 let _ = self.hosts_state.ssh_config.set_host_vault_ssh(
284 &old_entry.alias,
285 old_entry.vault_ssh.as_deref().unwrap_or(""),
286 );
287 let _ = self.hosts_state.ssh_config.set_host_vault_addr(
288 &old_entry.alias,
289 old_entry.vault_addr.as_deref().unwrap_or(""),
290 );
291 }
292 if old_entry.vault_ssh.is_some() {
293 let _ = self
298 .hosts_state
299 .ssh_config
300 .set_host_certificate_file(&old_entry.alias, &old_entry.certificate_file);
301 } else {
302 let _ = self
303 .hosts_state
304 .ssh_config
305 .set_host_certificate_file(&old_entry.alias, "");
306 }
307 log::warn!(
308 "[config] failed to save host edit: alias={alias} old_alias={old_alias}: {e}"
309 );
310 return Err(crate::messages::failed_to_save(&e));
311 }
312 log::debug!(
313 "[purple] host edited: alias={alias} old_alias={old_alias} renamed={}",
314 alias != old_alias
315 );
316 self.vault.pending_config_write = false;
318 self.update_last_modified();
319 let renames: Vec<(String, String)> = if alias != old_alias {
320 vec![(old_alias.to_string(), alias.clone())]
321 } else {
322 Vec::new()
323 };
324 self.rename_aliases(&renames);
325 if alias != old_alias {
330 self.vault.cert_cache.remove(old_alias);
331 }
332 self.refresh_cert_cache(&alias);
333 Ok(format!("{} got a makeover.", alias))
334 }
335
336 pub(crate) fn rename_aliases(&mut self, renames: &[(String, String)]) {
342 self.migrate_alias_keyed_caches(renames);
343 self.cleanup_stale_cert_files_for_renames(renames);
344 self.reload_hosts();
345 self.apply_alias_renames(renames);
346 }
347
348 fn cleanup_stale_cert_files_for_renames(&mut self, renames: &[(String, String)]) {
353 if crate::demo_flag::is_demo() {
354 return;
355 }
356 for (old_alias, new_alias) in renames {
357 if old_alias == new_alias {
358 continue;
359 }
360 let Ok(old_cert) = crate::vault_ssh::cert_path_for(self.env().paths(), old_alias)
361 else {
362 continue;
363 };
364 match std::fs::remove_file(&old_cert) {
365 Ok(()) => {}
366 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
367 Err(e) => {
368 self.vault.cleanup_warning = Some(format!(
369 "Warning: failed to clean up old Vault SSH cert {}: {}",
370 old_cert.display(),
371 e
372 ));
373 }
374 }
375 }
376 }
377
378 pub(crate) fn apply_alias_renames(&mut self, renames: &[(String, String)]) {
384 let mut applied = false;
385 let paths = self.env.paths().cloned();
386 for (old_alias, new_alias) in renames {
387 if old_alias == new_alias {
388 continue;
389 }
390 applied = true;
391 log::debug!("[purple] apply_alias_renames: {old_alias} -> {new_alias}");
392 self.history.rename(old_alias, new_alias);
393 let mut recents = crate::app::jump::load_recents(paths.as_ref());
394 if crate::app::jump::rename_host_recent(&mut recents, old_alias, new_alias) {
395 if let Err(e) = crate::app::jump::save_recents(&recents, paths.as_ref()) {
396 log::warn!("[purple] failed to save recents after rename: {e}");
397 }
398 }
399 }
400 if applied {
401 self.apply_sort();
402 }
403 }
404
405 pub(crate) fn migrate_alias_keyed_caches(&mut self, renames: &[(String, String)]) {
413 let mut container_cache_changed = false;
414 let mut collapsed_hosts_changed = false;
415 for (old_alias, new_alias) in renames {
416 if old_alias == new_alias {
417 continue;
418 }
419 log::debug!("[purple] migrate_alias_keyed_caches: {old_alias} -> {new_alias}");
420 self.ping.migrate_alias(old_alias, new_alias);
421 if self.container_state.migrate_alias(old_alias, new_alias) {
422 container_cache_changed = true;
423 }
424 if self.containers_overview.migrate_alias(old_alias, new_alias) {
429 collapsed_hosts_changed = true;
430 }
431 self.vault.migrate_alias(old_alias, new_alias);
436 self.tunnels.migrate_alias(old_alias, new_alias);
437 self.file_browser_state.migrate_alias(old_alias, new_alias);
438 }
439 if container_cache_changed {
440 crate::containers::save_container_cache(
441 self.env().paths(),
442 self.container_state.cache(),
443 );
444 }
445 if collapsed_hosts_changed {
446 if let Err(e) = crate::preferences::save_containers_collapsed_hosts(
447 self.env().paths(),
448 self.containers_overview.collapsed_hosts(),
449 ) {
450 log::warn!("[config] failed to save collapsed_hosts after rename: {e}");
451 }
452 }
453 }
454
455 pub fn select_host_by_alias(&mut self, alias: &str) {
457 if self.search.query.is_some() {
458 for (i, &host_idx) in self.search.filtered_indices.iter().enumerate() {
460 if self
461 .hosts_state
462 .list
463 .get(host_idx)
464 .is_some_and(|h| h.alias == alias)
465 {
466 self.ui.list_state.select(Some(i));
467 return;
468 }
469 }
470 let host_count = self.search.filtered_indices.len();
472 for (i, &pat_idx) in self.search.filtered_pattern_indices.iter().enumerate() {
473 if self
474 .hosts_state
475 .patterns
476 .get(pat_idx)
477 .is_some_and(|p| p.pattern == alias)
478 {
479 self.ui.list_state.select(Some(host_count + i));
480 return;
481 }
482 }
483 } else {
484 for (i, item) in self.hosts_state.display_list.iter().enumerate() {
485 match item {
486 HostListItem::Host { index } => {
487 if self
488 .hosts_state
489 .list
490 .get(*index)
491 .is_some_and(|h| h.alias == alias)
492 {
493 self.ui.list_state.select(Some(i));
494 return;
495 }
496 }
497 HostListItem::Pattern { index } => {
498 if self
499 .hosts_state
500 .patterns
501 .get(*index)
502 .is_some_and(|p| p.pattern == alias)
503 {
504 self.ui.list_state.select(Some(i));
505 return;
506 }
507 }
508 HostListItem::GroupHeader(_) => {}
509 }
510 }
511 }
512 }
513
514 pub fn apply_sync_result(
521 &mut self,
522 provider: &str,
523 hosts: Vec<crate::providers::ProviderHost>,
524 partial: bool,
525 ) -> (String, bool, usize, usize, usize, usize) {
526 let id: crate::providers::config::ProviderConfigId = match provider.parse() {
527 Ok(id) => id,
528 Err(_) => crate::providers::config::ProviderConfigId::bare(provider),
529 };
530 let section = match self.providers.config.section_by_id(&id).cloned() {
531 Some(s) => s,
532 None => {
533 log::warn!("[config] sync skipped: no config for provider={provider}");
534 return (
535 format!(
536 "{} sync skipped: no config.",
537 crate::providers::provider_display_name(&id.provider)
538 ),
539 true,
540 0,
541 0,
542 0,
543 0,
544 );
545 }
546 };
547 let provider_impl = match crate::providers::get_provider_with_config(§ion) {
548 Some(p) => p,
549 None => {
550 log::warn!("[config] sync skipped: unknown provider={provider}");
551 return (
552 format!(
553 "Unknown provider: {}.",
554 crate::providers::provider_display_name(provider)
555 ),
556 true,
557 0,
558 0,
559 0,
560 0,
561 );
562 }
563 };
564 let config_backup = self.hosts_state.ssh_config.clone();
565 let result = crate::providers::sync::sync_provider(
566 &mut self.hosts_state.ssh_config,
567 &*provider_impl,
568 &hosts,
569 §ion,
570 false,
571 partial, false,
573 );
574 let total = result.added + result.updated + result.unchanged;
575 if result.added > 0 || result.updated > 0 || result.stale > 0 {
576 if self.external_config_changed() {
584 self.hosts_state.ssh_config = config_backup;
585 log::warn!(
586 "[config] sync write refused: external config change for provider={provider}"
587 );
588 return (
589 crate::messages::sync_skipped_external_change().to_string(),
590 true,
591 total,
592 0,
593 0,
594 0,
595 );
596 }
597 if let Err(e) = self.hosts_state.ssh_config.write() {
598 self.hosts_state.ssh_config = config_backup;
599 log::warn!("[purple] sync write failed for provider={provider}, rolled back: {e}");
600 return (format!("Sync failed to save: {}", e), true, total, 0, 0, 0);
601 }
602 self.hosts_state.undo_stack.clear();
603 self.update_last_modified();
604 self.rename_aliases(&result.renames);
605 }
606 log::debug!(
607 "[purple] sync applied: provider={provider} added={} updated={} unchanged={} stale={} renames={}",
608 result.added,
609 result.updated,
610 result.unchanged,
611 result.stale,
612 result.renames.len()
613 );
614 let name = crate::providers::provider_display_name(provider);
615 let mut msg = format!(
616 "Synced {}: added {}, updated {}, unchanged {}",
617 name, result.added, result.updated, result.unchanged
618 );
619 if result.stale > 0 {
620 msg.push_str(&format!(", stale {}", result.stale));
621 }
622 msg.push('.');
623 (
624 msg,
625 false,
626 total,
627 result.added,
628 result.updated,
629 result.stale,
630 )
631 }
632
633 pub fn clear_stale_group_tag(&mut self) -> bool {
636 if let GroupBy::Tag(ref tag) = self.hosts_state.group_by {
637 if tag.is_empty() {
639 return false;
640 }
641 let tag_exists = self
642 .hosts_state
643 .list
644 .iter()
645 .any(|h| h.tags.iter().any(|t| t == tag))
646 || self
647 .hosts_state
648 .patterns
649 .iter()
650 .any(|p| p.tags.iter().any(|t| t == tag));
651 if !tag_exists {
652 self.hosts_state.set_group_by(GroupBy::None);
653 return true;
654 }
655 }
656 false
657 }
658}
659
660pub fn migrate_renames_persistent_state(
674 paths: Option<&crate::runtime::env::Paths>,
675 renames: &[(String, String)],
676) {
677 for (old_alias, new_alias) in renames {
678 if old_alias == new_alias {
679 continue;
680 }
681 let mut history = crate::history::ConnectionHistory::load(paths);
683 history.rename(old_alias, new_alias);
684
685 let mut recents = crate::app::jump::load_recents(paths);
686 if crate::app::jump::rename_host_recent(&mut recents, old_alias, new_alias) {
687 if let Err(e) = crate::app::jump::save_recents(&recents, paths) {
688 log::warn!("[purple] failed to save recents after cli sync rename: {e}");
689 }
690 }
691
692 let mut collapsed = crate::preferences::load_containers_collapsed_hosts(paths);
693 if collapsed.remove(old_alias) {
694 collapsed.insert(new_alias.clone());
695 if let Err(e) = crate::preferences::save_containers_collapsed_hosts(paths, &collapsed) {
696 log::warn!("[config] failed to save collapsed_hosts after cli sync rename: {e}");
697 }
698 }
699 }
700}