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 && let Err(e) = crate::app::jump::save_recents(&recents, paths.as_ref())
396 {
397 log::warn!("[purple] failed to save recents after rename: {e}");
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 if let Some(password) = self.session_passwords.remove(old_alias) {
442 self.session_passwords.insert(new_alias.clone(), password);
443 }
444 }
445 if container_cache_changed {
446 crate::containers::save_container_cache(
447 self.env().paths(),
448 self.container_state.cache(),
449 );
450 }
451 if collapsed_hosts_changed
452 && let Err(e) = crate::preferences::save_containers_collapsed_hosts(
453 self.env().paths(),
454 self.containers_overview.collapsed_hosts(),
455 )
456 {
457 log::warn!("[config] failed to save collapsed_hosts after rename: {e}");
458 }
459 }
460
461 pub fn select_host_by_alias(&mut self, alias: &str) {
463 if self.search.query.is_some() {
464 for (i, &host_idx) in self.search.filtered_indices.iter().enumerate() {
466 if self
467 .hosts_state
468 .list
469 .get(host_idx)
470 .is_some_and(|h| h.alias == alias)
471 {
472 self.ui.list_state.select(Some(i));
473 return;
474 }
475 }
476 let host_count = self.search.filtered_indices.len();
478 for (i, &pat_idx) in self.search.filtered_pattern_indices.iter().enumerate() {
479 if self
480 .hosts_state
481 .patterns
482 .get(pat_idx)
483 .is_some_and(|p| p.pattern == alias)
484 {
485 self.ui.list_state.select(Some(host_count + i));
486 return;
487 }
488 }
489 } else {
490 for (i, item) in self.hosts_state.display_list.iter().enumerate() {
491 match item {
492 HostListItem::Host { index } => {
493 if self
494 .hosts_state
495 .list
496 .get(*index)
497 .is_some_and(|h| h.alias == alias)
498 {
499 self.ui.list_state.select(Some(i));
500 return;
501 }
502 }
503 HostListItem::Pattern { index } => {
504 if self
505 .hosts_state
506 .patterns
507 .get(*index)
508 .is_some_and(|p| p.pattern == alias)
509 {
510 self.ui.list_state.select(Some(i));
511 return;
512 }
513 }
514 HostListItem::GroupHeader(_) => {}
515 }
516 }
517 }
518 }
519
520 pub fn apply_sync_result(
527 &mut self,
528 provider: &str,
529 hosts: Vec<crate::providers::ProviderHost>,
530 partial: bool,
531 ) -> (String, bool, usize, usize, usize, usize) {
532 let id: crate::providers::config::ProviderConfigId = match provider.parse() {
533 Ok(id) => id,
534 Err(_) => crate::providers::config::ProviderConfigId::bare(provider),
535 };
536 let section = match self.providers.config.section_by_id(&id).cloned() {
537 Some(s) => s,
538 None => {
539 log::warn!("[config] sync skipped: no config for provider={provider}");
540 return (
541 format!(
542 "{} sync skipped: no config.",
543 crate::providers::provider_display_name(&id.provider)
544 ),
545 true,
546 0,
547 0,
548 0,
549 0,
550 );
551 }
552 };
553 let provider_impl = match crate::providers::get_provider_with_config(§ion) {
554 Some(p) => p,
555 None => {
556 log::warn!("[config] sync skipped: unknown provider={provider}");
557 return (
558 format!(
559 "Unknown provider: {}.",
560 crate::providers::provider_display_name(provider)
561 ),
562 true,
563 0,
564 0,
565 0,
566 0,
567 );
568 }
569 };
570 let config_backup = self.hosts_state.ssh_config.clone();
571 let result = crate::providers::sync::sync_provider(
572 &mut self.hosts_state.ssh_config,
573 &*provider_impl,
574 &hosts,
575 §ion,
576 false,
577 partial, false,
579 );
580 let total = result.added + result.updated + result.unchanged;
581 if result.added > 0 || result.updated > 0 || result.stale > 0 {
582 if self.external_config_changed() {
590 self.hosts_state.ssh_config = config_backup;
591 log::warn!(
592 "[config] sync write refused: external config change for provider={provider}"
593 );
594 return (
595 crate::messages::sync_skipped_external_change().to_string(),
596 true,
597 total,
598 0,
599 0,
600 0,
601 );
602 }
603 if let Err(e) = self.hosts_state.ssh_config.write() {
604 self.hosts_state.ssh_config = config_backup;
605 log::warn!("[purple] sync write failed for provider={provider}, rolled back: {e}");
606 return (format!("Sync failed to save: {}", e), true, total, 0, 0, 0);
607 }
608 self.hosts_state.undo_stack.clear();
609 self.update_last_modified();
610 self.rename_aliases(&result.renames);
611 }
612 log::debug!(
613 "[purple] sync applied: provider={provider} added={} updated={} unchanged={} stale={} renames={}",
614 result.added,
615 result.updated,
616 result.unchanged,
617 result.stale,
618 result.renames.len()
619 );
620 let name = crate::providers::provider_display_name(provider);
621 let mut msg = format!(
622 "Synced {}: added {}, updated {}, unchanged {}",
623 name, result.added, result.updated, result.unchanged
624 );
625 if result.stale > 0 {
626 msg.push_str(&format!(", stale {}", result.stale));
627 }
628 msg.push('.');
629 (
630 msg,
631 false,
632 total,
633 result.added,
634 result.updated,
635 result.stale,
636 )
637 }
638
639 pub fn clear_stale_group_tag(&mut self) -> bool {
642 if let GroupBy::Tag(ref tag) = self.hosts_state.group_by {
643 if tag.is_empty() {
645 return false;
646 }
647 let tag_exists = self
648 .hosts_state
649 .list
650 .iter()
651 .any(|h| h.tags.iter().any(|t| t == tag))
652 || self
653 .hosts_state
654 .patterns
655 .iter()
656 .any(|p| p.tags.iter().any(|t| t == tag));
657 if !tag_exists {
658 self.hosts_state.set_group_by(GroupBy::None);
659 return true;
660 }
661 }
662 false
663 }
664}
665
666pub fn migrate_renames_persistent_state(
680 paths: Option<&crate::runtime::env::Paths>,
681 renames: &[(String, String)],
682) {
683 for (old_alias, new_alias) in renames {
684 if old_alias == new_alias {
685 continue;
686 }
687 let mut history = crate::history::ConnectionHistory::load(paths);
689 history.rename(old_alias, new_alias);
690
691 let mut recents = crate::app::jump::load_recents(paths);
692 if crate::app::jump::rename_host_recent(&mut recents, old_alias, new_alias)
693 && let Err(e) = crate::app::jump::save_recents(&recents, paths)
694 {
695 log::warn!("[purple] failed to save recents after cli sync rename: {e}");
696 }
697
698 let mut collapsed = crate::preferences::load_containers_collapsed_hosts(paths);
699 if collapsed.remove(old_alias) {
700 collapsed.insert(new_alias.clone());
701 if let Err(e) = crate::preferences::save_containers_collapsed_hosts(paths, &collapsed) {
702 log::warn!("[config] failed to save collapsed_hosts after cli sync rename: {e}");
703 }
704 }
705 }
706}