1use std::collections::HashMap;
8
9use crate::providers::ProviderKind;
10use crate::ssh_config::model::{HostEntry, PatternEntry};
11use crate::tunnel::{TunnelRule, TunnelType};
12
13#[derive(Debug, Clone, Copy, PartialEq)]
14pub enum FormField {
15 Alias,
16 Hostname,
17 User,
18 Port,
19 IdentityFile,
20 ProxyJump,
21 AskPass,
22 VaultSsh,
23 VaultAddr,
24 Tags,
25}
26
27impl FormField {
28 pub const ALL: [FormField; 10] = [
29 FormField::Alias,
30 FormField::Hostname,
31 FormField::User,
32 FormField::Port,
33 FormField::IdentityFile,
34 FormField::VaultSsh,
35 FormField::VaultAddr,
36 FormField::ProxyJump,
37 FormField::AskPass,
38 FormField::Tags,
39 ];
40
41 #[cfg(test)]
46 pub fn next(self) -> Self {
47 let idx = FormField::ALL.iter().position(|f| *f == self).unwrap_or(0);
48 FormField::ALL[(idx + 1) % FormField::ALL.len()]
49 }
50
51 #[cfg(test)]
53 pub fn prev(self) -> Self {
54 let idx = FormField::ALL.iter().position(|f| *f == self).unwrap_or(0);
55 FormField::ALL[(idx + FormField::ALL.len() - 1) % FormField::ALL.len()]
56 }
57
58 pub fn label(self) -> &'static str {
59 match self {
60 FormField::Alias => "Name",
61 FormField::Hostname => "Host / IP",
62 FormField::User => "User",
63 FormField::Port => "Port",
64 FormField::IdentityFile => "Identity File",
65 FormField::ProxyJump => "ProxyJump",
66 FormField::AskPass => "Password Source",
67 FormField::VaultSsh => "Vault SSH Role",
68 FormField::VaultAddr => "Vault SSH Address",
69 FormField::Tags => "Tags",
70 }
71 }
72
73 pub fn is_picker(self) -> bool {
85 matches!(
86 self,
87 FormField::IdentityFile
88 | FormField::ProxyJump
89 | FormField::AskPass
90 | FormField::VaultSsh
91 )
92 }
93
94 pub fn kind(self) -> crate::ui::design::FieldKind {
97 if self.is_picker() {
98 crate::ui::design::FieldKind::Picker
99 } else {
100 crate::ui::design::FieldKind::Text
101 }
102 }
103}
104
105#[derive(Debug, Clone)]
107pub struct HostForm {
108 pub alias: String,
109 pub hostname: String,
110 pub user: String,
111 pub port: String,
112 pub identity_file: String,
113 pub proxy_jump: String,
114 pub askpass: String,
115 pub vault_ssh: String,
116 pub vault_addr: String,
121 pub tags: String,
122 pub focused_field: FormField,
123 pub cursor_pos: usize,
124 pub form_hint: Option<String>,
126 pub is_pattern: bool,
128 pub expanded: bool,
131 pub inherited: crate::ssh_config::model::InheritedHints,
134}
135
136impl HostForm {
137 pub(crate) fn new() -> Self {
138 Self {
139 alias: String::new(),
140 hostname: String::new(),
141 user: String::new(),
142 port: "22".to_string(),
143 identity_file: String::new(),
144 proxy_jump: String::new(),
145 askpass: String::new(),
146 vault_ssh: String::new(),
147 vault_addr: String::new(),
148 tags: String::new(),
149 focused_field: FormField::Alias,
150 cursor_pos: 0,
151 form_hint: None,
152 is_pattern: false,
153 expanded: false,
154 inherited: Default::default(),
155 }
156 }
157
158 pub fn new_pattern() -> Self {
159 Self {
160 is_pattern: true,
161 expanded: true,
162 ..Self::new()
163 }
164 }
165
166 pub fn from_entry(
169 entry: &HostEntry,
170 inherited: crate::ssh_config::model::InheritedHints,
171 ) -> Self {
172 let alias = entry.alias.clone();
173 let cursor_pos = alias.chars().count();
174 Self {
175 alias,
176 hostname: entry.hostname.clone(),
177 user: entry.user.clone(),
178 port: entry.port.to_string(),
179 identity_file: entry.identity_file.clone(),
180 proxy_jump: entry.proxy_jump.clone(),
181 askpass: entry.askpass.clone().unwrap_or_default(),
182 vault_ssh: entry.vault_ssh.clone().unwrap_or_default(),
183 vault_addr: entry.vault_addr.clone().unwrap_or_default(),
184 tags: entry.tags.join(", "),
185 focused_field: FormField::Alias,
186 cursor_pos,
187 form_hint: None,
188 is_pattern: false,
189 expanded: true,
190 inherited,
191 }
192 }
193
194 pub fn from_entry_duplicate(
205 entry: &HostEntry,
206 inherited: crate::ssh_config::model::InheritedHints,
207 ) -> (Self, bool) {
208 let mut form = Self::from_entry(entry, inherited);
209 let had_vault_ssh = !form.vault_ssh.is_empty();
210 form.vault_ssh.clear();
211 form.vault_addr.clear();
216 (form, had_vault_ssh)
217 }
218
219 pub fn from_pattern_entry(entry: &PatternEntry) -> Self {
220 let alias = entry.pattern.clone();
221 let cursor_pos = alias.chars().count();
222 Self {
223 alias,
224 hostname: entry.hostname.clone(),
225 user: entry.user.clone(),
226 port: entry.port.to_string(),
227 identity_file: entry.identity_file.clone(),
228 proxy_jump: entry.proxy_jump.clone(),
229 askpass: entry.askpass.clone().unwrap_or_default(),
230 vault_ssh: String::new(),
231 vault_addr: String::new(),
232 tags: entry.tags.join(", "),
233 focused_field: FormField::Alias,
234 cursor_pos,
235 form_hint: None,
236 is_pattern: true,
237 expanded: true,
238 inherited: Default::default(),
239 }
240 }
241
242 pub fn focused_value(&self) -> &str {
243 match self.focused_field {
244 FormField::Alias => &self.alias,
245 FormField::Hostname => &self.hostname,
246 FormField::User => &self.user,
247 FormField::Port => &self.port,
248 FormField::IdentityFile => &self.identity_file,
249 FormField::ProxyJump => &self.proxy_jump,
250 FormField::AskPass => &self.askpass,
251 FormField::VaultSsh => &self.vault_ssh,
252 FormField::VaultAddr => &self.vault_addr,
253 FormField::Tags => &self.tags,
254 }
255 }
256
257 pub fn focused_value_mut(&mut self) -> &mut String {
259 match self.focused_field {
260 FormField::Alias => &mut self.alias,
261 FormField::Hostname => &mut self.hostname,
262 FormField::User => &mut self.user,
263 FormField::Port => &mut self.port,
264 FormField::IdentityFile => &mut self.identity_file,
265 FormField::ProxyJump => &mut self.proxy_jump,
266 FormField::AskPass => &mut self.askpass,
267 FormField::VaultSsh => &mut self.vault_ssh,
268 FormField::VaultAddr => &mut self.vault_addr,
269 FormField::Tags => &mut self.tags,
270 }
271 }
272
273 pub fn visible_fields(&self) -> Vec<FormField> {
281 let role_set = !self.vault_ssh.trim().is_empty();
282 FormField::ALL
283 .iter()
284 .copied()
285 .filter(|f| *f != FormField::VaultAddr || role_set)
286 .collect()
287 }
288
289 pub fn focus_next_visible(&mut self) {
296 let visible = self.visible_fields();
297 if visible.is_empty() {
298 return;
299 }
300 self.focused_field = match visible.iter().position(|f| *f == self.focused_field) {
301 Some(idx) => visible[(idx + 1) % visible.len()],
302 None => visible[0],
303 };
304 }
305
306 pub fn focus_prev_visible(&mut self) {
311 let visible = self.visible_fields();
312 if visible.is_empty() {
313 return;
314 }
315 self.focused_field = match visible.iter().position(|f| *f == self.focused_field) {
316 Some(idx) => visible[(idx + visible.len() - 1) % visible.len()],
317 None => visible[visible.len() - 1],
318 };
319 }
320
321 pub fn insert_char(&mut self, c: char) {
322 let pos = self.cursor_pos;
323 let val = self.focused_value_mut();
324 let byte_pos = char_to_byte_pos(val, pos);
325 val.insert(byte_pos, c);
326 self.cursor_pos = pos + 1;
327 }
328
329 pub fn delete_char_before_cursor(&mut self) {
330 if self.cursor_pos == 0 {
331 return;
332 }
333 let pos = self.cursor_pos;
334 let val = self.focused_value_mut();
335 let byte_pos = char_to_byte_pos(val, pos);
336 let prev = char_to_byte_pos(val, pos - 1);
337 val.drain(prev..byte_pos);
338 self.cursor_pos = pos - 1;
339 }
340
341 pub fn sync_cursor_to_end(&mut self) {
342 self.cursor_pos = self.focused_value().chars().count();
343 }
344
345 pub fn apply_password_source(&mut self, value: String, refocus_askpass: bool) {
351 self.askpass = value;
352 if refocus_askpass {
353 self.focused_field = FormField::AskPass;
354 }
355 self.cursor_pos = self.askpass.chars().count();
356 }
357
358 pub fn apply_smart_paste(
362 &mut self,
363 parsed: crate::quick_add::ParsedTarget,
364 clean_alias: String,
365 ) {
366 if self.hostname.is_empty() {
367 self.hostname = parsed.hostname;
368 }
369 if self.user.is_empty() && !parsed.user.is_empty() {
370 self.user = parsed.user;
371 }
372 if self.port == "22" && parsed.port != 22 {
373 self.port = parsed.port.to_string();
374 }
375 self.alias = clean_alias;
376 }
377
378 pub fn update_hint(&mut self) {
380 self.form_hint = match self.focused_field {
381 FormField::Alias => {
382 let v = self.alias.trim();
383 if v.is_empty() {
384 None } else if self.is_pattern {
386 if !crate::ssh_config::model::is_host_pattern(v) {
387 Some("Pattern needs a wildcard (*, ?, [) or multiple hosts".into())
388 } else {
389 None
390 }
391 } else if v.contains(char::is_whitespace) {
392 Some("Alias can't contain whitespace".into())
393 } else if v.contains('#') {
394 Some("Alias can't contain '#'".into())
395 } else if crate::ssh_config::model::is_host_pattern(v) {
396 Some("Alias can't contain pattern characters".into())
397 } else {
398 None
399 }
400 }
401 FormField::Hostname => {
402 let v = self.hostname.trim();
403 if !v.is_empty() && v.contains(char::is_whitespace) {
404 Some("Hostname can't contain whitespace".into())
405 } else {
406 None
407 }
408 }
409 FormField::User => {
410 let v = self.user.trim();
411 if !v.is_empty() && v.contains(char::is_whitespace) {
412 Some("User can't contain whitespace".into())
413 } else {
414 None
415 }
416 }
417 FormField::Port => {
418 let v = &self.port;
419 if v.is_empty() || v == "22" {
420 None
421 } else {
422 match v.parse::<u16>() {
423 Ok(0) => Some("Port must be 1-65535".into()),
424 Err(_) => Some("Not a valid port number".into()),
425 _ => None,
426 }
427 }
428 }
429 _ => None,
430 };
431 }
432
433 pub fn validate(&self) -> Result<(), String> {
435 let alias = self.alias.trim();
436 if alias.is_empty() {
437 return Err(if self.is_pattern {
438 crate::messages::HOST_PATTERN_EMPTY.to_string()
439 } else {
440 crate::messages::HOST_ALIAS_EMPTY.to_string()
441 });
442 }
443 if self.is_pattern && !crate::ssh_config::model::is_host_pattern(alias) {
444 return Err(crate::messages::HOST_PATTERN_NEEDS_WILDCARD.to_string());
445 } else if !self.is_pattern {
446 if alias.contains(char::is_whitespace) {
447 return Err(crate::messages::HOST_ALIAS_WHITESPACE.to_string());
448 }
449 if alias.contains('#') {
450 return Err(crate::messages::HOST_ALIAS_HASH.to_string());
451 }
452 if crate::ssh_config::model::is_host_pattern(alias) {
455 return Err(crate::messages::HOST_ALIAS_PATTERN_CHARS.to_string());
456 }
457 }
458 let fields = [
460 (
461 &self.alias,
462 if self.is_pattern { "Pattern" } else { "Alias" },
463 ),
464 (&self.hostname, "Hostname"),
465 (&self.user, "User"),
466 (&self.port, "Port"),
467 (&self.identity_file, "Identity File"),
468 (&self.proxy_jump, "ProxyJump"),
469 (&self.askpass, "Password Source"),
470 (&self.vault_ssh, "Vault SSH Role"),
471 (&self.vault_addr, "Vault SSH Address"),
472 (&self.tags, "Tags"),
473 ];
474 for (value, name) in &fields {
475 if value.chars().any(|c| c.is_control()) {
476 return Err(crate::messages::field_control_chars(name));
477 }
478 }
479 if !self.is_pattern && self.hostname.trim().is_empty() {
480 return Err(crate::messages::HOST_HOSTNAME_EMPTY.to_string());
481 }
482 if self.hostname.trim().contains(char::is_whitespace) {
483 return Err(crate::messages::HOST_HOSTNAME_WHITESPACE.to_string());
484 }
485 if self.user.trim().contains(char::is_whitespace) {
486 return Err(crate::messages::USER_NO_WHITESPACE.to_string());
487 }
488 let port: u16 = self
489 .port
490 .parse()
491 .map_err(|_| crate::messages::HOST_PORT_INVALID.to_string())?;
492 if port == 0 {
493 return Err(crate::messages::HOST_PORT_ZERO.to_string());
494 }
495 let vault_role = self.vault_ssh.trim();
496 if !vault_role.is_empty() && !crate::vault_ssh::is_valid_role(vault_role) {
497 return Err(crate::messages::HOST_VAULT_ROLE_INVALID.to_string());
498 }
499 if !vault_role.is_empty() {
504 let addr = self.vault_addr.trim();
505 if !addr.is_empty() && !crate::vault_ssh::is_valid_vault_addr(addr) {
506 return Err(crate::messages::HOST_VAULT_ADDR_INVALID.to_string());
507 }
508 }
509 Ok(())
510 }
511
512 pub fn to_entry(&self) -> HostEntry {
514 let askpass_trimmed = self.askpass.trim().to_string();
515 let vault_ssh_trimmed = self.vault_ssh.trim().to_string();
516 let vault_addr_trimmed = if vault_ssh_trimmed.is_empty() {
519 String::new()
520 } else {
521 self.vault_addr.trim().to_string()
522 };
523 HostEntry {
524 alias: self.alias.trim().to_string(),
525 hostname: self.hostname.trim().to_string(),
526 user: self.user.trim().to_string(),
527 port: self.port.parse().unwrap_or(22),
528 identity_file: self.identity_file.trim().to_string(),
529 proxy_jump: self.proxy_jump.trim().to_string(),
530 tags: self
531 .tags
532 .split(',')
533 .map(|t| t.trim().to_string())
534 .filter(|t| !t.is_empty())
535 .collect(),
536 askpass: if askpass_trimmed.is_empty() {
537 None
538 } else {
539 Some(askpass_trimmed)
540 },
541 vault_ssh: if vault_ssh_trimmed.is_empty() {
542 None
543 } else {
544 Some(vault_ssh_trimmed)
545 },
546 vault_addr: if vault_addr_trimmed.is_empty() {
547 None
548 } else {
549 Some(vault_addr_trimmed)
550 },
551 ..Default::default()
552 }
553 }
554}
555
556#[derive(Debug, Clone, Copy, PartialEq)]
564pub enum ProviderFormField {
565 Label,
566 Url,
567 Token,
568 Profile,
569 Project,
570 Compartment,
571 Regions,
572 Filter,
573 Ssm,
574 AliasPrefix,
575 User,
576 IdentityFile,
577 VerifyTls,
578 VaultRole,
579 VaultAddr,
580 AutoSync,
581}
582
583impl ProviderFormField {
584 const CLOUD_FIELDS: &[ProviderFormField] = &[
585 ProviderFormField::Token,
586 ProviderFormField::AliasPrefix,
587 ProviderFormField::User,
588 ProviderFormField::IdentityFile,
589 ProviderFormField::VaultRole,
590 ProviderFormField::VaultAddr,
591 ProviderFormField::AutoSync,
592 ];
593
594 const PROXMOX_FIELDS: &[ProviderFormField] = &[
595 ProviderFormField::Url,
596 ProviderFormField::Token,
597 ProviderFormField::AliasPrefix,
598 ProviderFormField::User,
599 ProviderFormField::IdentityFile,
600 ProviderFormField::VerifyTls,
601 ProviderFormField::VaultRole,
602 ProviderFormField::VaultAddr,
603 ProviderFormField::AutoSync,
604 ];
605
606 const NETBOX_FIELDS: &[ProviderFormField] = &[
607 ProviderFormField::Url,
608 ProviderFormField::Token,
609 ProviderFormField::Filter,
610 ProviderFormField::AliasPrefix,
611 ProviderFormField::User,
612 ProviderFormField::IdentityFile,
613 ProviderFormField::VerifyTls,
614 ProviderFormField::VaultRole,
615 ProviderFormField::VaultAddr,
616 ProviderFormField::AutoSync,
617 ];
618
619 const AWS_FIELDS: &[ProviderFormField] = &[
620 ProviderFormField::Token,
621 ProviderFormField::Profile,
622 ProviderFormField::Regions,
623 ProviderFormField::Ssm,
624 ProviderFormField::AliasPrefix,
625 ProviderFormField::User,
626 ProviderFormField::IdentityFile,
627 ProviderFormField::VaultRole,
628 ProviderFormField::VaultAddr,
629 ProviderFormField::AutoSync,
630 ];
631
632 const SCALEWAY_FIELDS: &[ProviderFormField] = &[
633 ProviderFormField::Token,
634 ProviderFormField::Regions,
635 ProviderFormField::AliasPrefix,
636 ProviderFormField::User,
637 ProviderFormField::IdentityFile,
638 ProviderFormField::VaultRole,
639 ProviderFormField::VaultAddr,
640 ProviderFormField::AutoSync,
641 ];
642
643 const GCP_FIELDS: &[ProviderFormField] = &[
644 ProviderFormField::Token,
645 ProviderFormField::Project,
646 ProviderFormField::Regions,
647 ProviderFormField::AliasPrefix,
648 ProviderFormField::User,
649 ProviderFormField::IdentityFile,
650 ProviderFormField::VaultRole,
651 ProviderFormField::VaultAddr,
652 ProviderFormField::AutoSync,
653 ];
654
655 const AZURE_FIELDS: &[ProviderFormField] = &[
656 ProviderFormField::Token,
657 ProviderFormField::Regions,
658 ProviderFormField::AliasPrefix,
659 ProviderFormField::User,
660 ProviderFormField::IdentityFile,
661 ProviderFormField::VaultRole,
662 ProviderFormField::VaultAddr,
663 ProviderFormField::AutoSync,
664 ];
665
666 const ORACLE_FIELDS: &[ProviderFormField] = &[
667 ProviderFormField::Token,
668 ProviderFormField::Compartment,
669 ProviderFormField::Regions,
670 ProviderFormField::AliasPrefix,
671 ProviderFormField::User,
672 ProviderFormField::IdentityFile,
673 ProviderFormField::VaultRole,
674 ProviderFormField::VaultAddr,
675 ProviderFormField::AutoSync,
676 ];
677
678 const TELEPORT_FIELDS: &[ProviderFormField] = &[
682 ProviderFormField::User,
683 ProviderFormField::AliasPrefix,
684 ProviderFormField::IdentityFile,
685 ProviderFormField::AutoSync,
686 ];
687
688 const OVH_FIELDS: &[ProviderFormField] = &[
689 ProviderFormField::Token,
690 ProviderFormField::Project,
691 ProviderFormField::Regions,
692 ProviderFormField::AliasPrefix,
693 ProviderFormField::User,
694 ProviderFormField::IdentityFile,
695 ProviderFormField::VaultRole,
696 ProviderFormField::VaultAddr,
697 ProviderFormField::AutoSync,
698 ];
699
700 pub fn fields_for(provider: &str) -> &'static [ProviderFormField] {
701 let Ok(kind) = provider.parse::<ProviderKind>() else {
702 return Self::CLOUD_FIELDS;
703 };
704 match kind {
705 ProviderKind::Proxmox => Self::PROXMOX_FIELDS,
706 ProviderKind::NetBox => Self::NETBOX_FIELDS,
707 ProviderKind::Aws => Self::AWS_FIELDS,
708 ProviderKind::Scaleway => Self::SCALEWAY_FIELDS,
709 ProviderKind::Gcp => Self::GCP_FIELDS,
710 ProviderKind::Azure => Self::AZURE_FIELDS,
711 ProviderKind::Oracle => Self::ORACLE_FIELDS,
712 ProviderKind::Ovh => Self::OVH_FIELDS,
713 ProviderKind::Teleport => Self::TELEPORT_FIELDS,
714 ProviderKind::DigitalOcean
715 | ProviderKind::Hetzner
716 | ProviderKind::I3d
717 | ProviderKind::Leaseweb
718 | ProviderKind::Linode
719 | ProviderKind::Tailscale
720 | ProviderKind::Transip
721 | ProviderKind::UpCloud
722 | ProviderKind::Vultr => Self::CLOUD_FIELDS,
723 }
724 }
725
726 #[cfg(test)]
729 pub fn required_fields_for(provider: &str) -> Vec<ProviderFormField> {
730 let all = Self::fields_for(provider);
731 all.iter()
732 .filter(|f| Self::is_required_field(**f, provider))
733 .copied()
734 .collect()
735 }
736
737 #[cfg(test)]
740 pub fn optional_fields_for(provider: &str) -> Vec<ProviderFormField> {
741 let all = Self::fields_for(provider);
742 all.iter()
743 .filter(|f| !Self::is_required_field(**f, provider))
744 .copied()
745 .collect()
746 }
747
748 pub fn is_mandatory_field(field: ProviderFormField, provider: &str) -> bool {
760 let kind = provider.parse::<ProviderKind>().ok();
761 match field {
762 ProviderFormField::Label => true,
763 ProviderFormField::Url => true,
764 ProviderFormField::Token => !kind.is_some_and(ProviderKind::token_is_optional),
765 ProviderFormField::Profile => false,
766 ProviderFormField::Project => kind.is_some_and(ProviderKind::has_project_field),
767 ProviderFormField::Compartment => kind == Some(ProviderKind::Oracle),
768 ProviderFormField::Regions => {
769 kind.is_some_and(ProviderKind::regions_field_is_mandatory)
770 }
771 _ => false,
772 }
773 }
774
775 pub fn is_required_field(field: ProviderFormField, provider: &str) -> bool {
779 let kind = provider.parse::<ProviderKind>().ok();
780 match field {
781 ProviderFormField::Label => true,
782 ProviderFormField::Token => true,
783 ProviderFormField::Url => kind.is_some_and(ProviderKind::requires_url),
784 ProviderFormField::Profile => kind == Some(ProviderKind::Aws),
785 ProviderFormField::Project => kind.is_some_and(ProviderKind::has_project_field),
786 ProviderFormField::Compartment => kind == Some(ProviderKind::Oracle),
787 ProviderFormField::Regions => kind.is_some_and(ProviderKind::has_regions_field),
788 ProviderFormField::User => kind == Some(ProviderKind::Teleport),
791 _ => false,
792 }
793 }
794
795 pub fn next(self, fields: &[Self]) -> Self {
796 debug_assert!(
797 fields.contains(&self),
798 "focused field {:?} not in fields slice",
799 self
800 );
801 let idx = fields.iter().position(|f| *f == self).unwrap_or(0);
802 fields[(idx + 1) % fields.len()]
803 }
804
805 pub fn prev(self, fields: &[Self]) -> Self {
806 debug_assert!(
807 fields.contains(&self),
808 "focused field {:?} not in fields slice",
809 self
810 );
811 let idx = fields.iter().position(|f| *f == self).unwrap_or(0);
812 fields[(idx + fields.len() - 1) % fields.len()]
813 }
814
815 pub fn label(self) -> &'static str {
816 match self {
817 ProviderFormField::Label => "Name",
818 ProviderFormField::Url => "URL",
819 ProviderFormField::Token => "Token",
820 ProviderFormField::Profile => "Profile",
821 ProviderFormField::Project => "Project ID",
822 ProviderFormField::Compartment => "Compartment",
823 ProviderFormField::Regions => "Regions",
824 ProviderFormField::Filter => "Filter",
825 ProviderFormField::Ssm => "Session Manager",
826 ProviderFormField::AliasPrefix => "Alias Prefix",
827 ProviderFormField::User => "User",
828 ProviderFormField::IdentityFile => "Identity File",
829 ProviderFormField::VerifyTls => "Verify TLS",
830 ProviderFormField::VaultRole => "Vault SSH Role",
831 ProviderFormField::VaultAddr => "Vault SSH Address",
832 ProviderFormField::AutoSync => "Auto Sync",
833 }
834 }
835
836 pub fn is_toggle(self) -> bool {
840 matches!(
841 self,
842 ProviderFormField::VerifyTls | ProviderFormField::AutoSync | ProviderFormField::Ssm
843 )
844 }
845
846 pub fn is_picker(self, provider: &str) -> bool {
855 match self {
856 ProviderFormField::IdentityFile => true,
857 ProviderFormField::Profile => provider.parse::<ProviderKind>() == Ok(ProviderKind::Aws),
858 ProviderFormField::Regions => provider
859 .parse::<ProviderKind>()
860 .ok()
861 .is_some_and(ProviderKind::regions_field_is_picker),
862 _ => false,
863 }
864 }
865
866 pub fn kind(self, provider: &str) -> crate::ui::design::FieldKind {
869 if self.is_toggle() {
870 crate::ui::design::FieldKind::Toggle
871 } else if self.is_picker(provider) {
872 crate::ui::design::FieldKind::Picker
873 } else {
874 crate::ui::design::FieldKind::Text
875 }
876 }
877}
878
879#[derive(Debug, Clone)]
881pub struct ProviderFormFields {
882 pub label: String,
887 pub label_entry: bool,
891 pub url: String,
892 pub token: String,
893 pub profile: String,
894 pub project: String,
895 pub compartment: String,
896 pub regions: String,
897 pub filter: String,
899 pub alias_prefix: String,
900 pub user: String,
901 pub identity_file: String,
902 pub verify_tls: bool,
903 pub auto_sync: bool,
904 pub vault_role: String,
905 pub ssm: crate::providers::aws_ssm::SsmMode,
907 pub vault_addr: String,
911 pub focused_field: ProviderFormField,
912 pub cursor_pos: usize,
913 pub expanded: bool,
916}
917
918impl ProviderFormFields {
919 pub(crate) fn new() -> Self {
920 Self {
921 label: String::new(),
922 label_entry: false,
923 url: String::new(),
924 token: String::new(),
925 profile: String::new(),
926 project: String::new(),
927 compartment: String::new(),
928 regions: String::new(),
929 filter: String::new(),
930 alias_prefix: String::new(),
931 user: "root".to_string(),
932 identity_file: String::new(),
933 verify_tls: true,
934 auto_sync: true,
935 vault_role: String::new(),
936 ssm: crate::providers::aws_ssm::SsmMode::default(),
937 vault_addr: String::new(),
938 focused_field: ProviderFormField::Token,
939 cursor_pos: 0,
940 expanded: false,
941 }
942 }
943
944 pub fn focused_value(&self) -> &str {
945 match self.focused_field {
946 ProviderFormField::Label => &self.label,
947 ProviderFormField::Url => &self.url,
948 ProviderFormField::Token => &self.token,
949 ProviderFormField::Profile => &self.profile,
950 ProviderFormField::Project => &self.project,
951 ProviderFormField::Compartment => &self.compartment,
952 ProviderFormField::Regions => &self.regions,
953 ProviderFormField::Filter => &self.filter,
954 ProviderFormField::AliasPrefix => &self.alias_prefix,
955 ProviderFormField::User => &self.user,
956 ProviderFormField::IdentityFile => &self.identity_file,
957 ProviderFormField::VaultRole => &self.vault_role,
958 ProviderFormField::VaultAddr => &self.vault_addr,
959 ProviderFormField::Ssm => self.ssm.as_str(),
960 ProviderFormField::VerifyTls | ProviderFormField::AutoSync => "",
961 }
962 }
963
964 pub fn focused_value_mut(&mut self) -> Option<&mut String> {
965 match self.focused_field {
966 ProviderFormField::Label => Some(&mut self.label),
967 ProviderFormField::Url => Some(&mut self.url),
968 ProviderFormField::Token => Some(&mut self.token),
969 ProviderFormField::Profile => Some(&mut self.profile),
970 ProviderFormField::Project => Some(&mut self.project),
971 ProviderFormField::Compartment => Some(&mut self.compartment),
972 ProviderFormField::Regions => Some(&mut self.regions),
973 ProviderFormField::Filter => Some(&mut self.filter),
974 ProviderFormField::AliasPrefix => Some(&mut self.alias_prefix),
975 ProviderFormField::User => Some(&mut self.user),
976 ProviderFormField::IdentityFile => Some(&mut self.identity_file),
977 ProviderFormField::VaultRole => Some(&mut self.vault_role),
978 ProviderFormField::VaultAddr => Some(&mut self.vault_addr),
979 ProviderFormField::Ssm | ProviderFormField::VerifyTls | ProviderFormField::AutoSync => {
981 None
982 }
983 }
984 }
985
986 pub fn visible_fields(&self, provider: &str) -> Vec<ProviderFormField> {
993 let role_set = !self.vault_role.trim().is_empty();
994 let base = ProviderFormField::fields_for(provider)
995 .iter()
996 .copied()
997 .filter(|f| *f != ProviderFormField::VaultAddr || role_set);
998 if self.label_entry {
999 std::iter::once(ProviderFormField::Label)
1000 .chain(base)
1001 .collect()
1002 } else {
1003 base.collect()
1004 }
1005 }
1006
1007 pub fn insert_char(&mut self, c: char) {
1008 let pos = self.cursor_pos;
1009 if let Some(val) = self.focused_value_mut() {
1010 let byte_pos = char_to_byte_pos(val, pos);
1011 val.insert(byte_pos, c);
1012 self.cursor_pos = pos + 1;
1013 }
1014 }
1015
1016 pub fn delete_char_before_cursor(&mut self) {
1017 if self.cursor_pos == 0 {
1018 return;
1019 }
1020 let pos = self.cursor_pos;
1021 if let Some(val) = self.focused_value_mut() {
1022 let byte_pos = char_to_byte_pos(val, pos);
1023 let prev = char_to_byte_pos(val, pos - 1);
1024 val.drain(prev..byte_pos);
1025 self.cursor_pos = pos - 1;
1026 }
1027 }
1028
1029 pub fn sync_cursor_to_end(&mut self) {
1030 self.cursor_pos = self.focused_value().chars().count();
1031 }
1032}
1033
1034pub(crate) fn char_to_byte_pos(s: &str, char_pos: usize) -> usize {
1035 s.char_indices()
1036 .nth(char_pos)
1037 .map(|(i, _)| i)
1038 .unwrap_or(s.len())
1039}
1040
1041#[derive(Debug, Clone, Copy, PartialEq)]
1043pub enum TunnelFormField {
1044 Type,
1045 BindPort,
1046 RemoteHost,
1047 RemotePort,
1048}
1049
1050impl TunnelFormField {
1051 pub fn next(self, tunnel_type: TunnelType) -> Self {
1053 match (self, tunnel_type) {
1054 (TunnelFormField::Type, _) => TunnelFormField::BindPort,
1055 (TunnelFormField::BindPort, TunnelType::Dynamic) => TunnelFormField::Type,
1056 (TunnelFormField::BindPort, _) => TunnelFormField::RemoteHost,
1057 (TunnelFormField::RemoteHost, _) => TunnelFormField::RemotePort,
1058 (TunnelFormField::RemotePort, _) => TunnelFormField::Type,
1059 }
1060 }
1061
1062 pub fn prev(self, tunnel_type: TunnelType) -> Self {
1064 match (self, tunnel_type) {
1065 (TunnelFormField::Type, TunnelType::Dynamic) => TunnelFormField::BindPort,
1066 (TunnelFormField::Type, _) => TunnelFormField::RemotePort,
1067 (TunnelFormField::BindPort, _) => TunnelFormField::Type,
1068 (TunnelFormField::RemoteHost, _) => TunnelFormField::BindPort,
1069 (TunnelFormField::RemotePort, _) => TunnelFormField::RemoteHost,
1070 }
1071 }
1072
1073 pub fn label(self) -> &'static str {
1074 match self {
1075 TunnelFormField::Type => "Type",
1076 TunnelFormField::BindPort => "Bind Port",
1077 TunnelFormField::RemoteHost => "Remote Host",
1078 TunnelFormField::RemotePort => "Remote Port",
1079 }
1080 }
1081}
1082
1083#[derive(Debug, Clone)]
1085pub struct TunnelForm {
1086 pub tunnel_type: TunnelType,
1087 pub bind_port: String,
1088 pub remote_host: String,
1089 pub remote_port: String,
1090 pub bind_address: String,
1092 pub focused_field: TunnelFormField,
1093 pub cursor_pos: usize,
1094}
1095
1096impl TunnelForm {
1097 pub(crate) fn new() -> Self {
1098 Self {
1099 tunnel_type: TunnelType::Local,
1100 bind_port: String::new(),
1101 remote_host: "localhost".to_string(),
1102 remote_port: String::new(),
1103 bind_address: String::new(),
1104 focused_field: TunnelFormField::Type,
1105 cursor_pos: 0,
1106 }
1107 }
1108
1109 pub fn from_rule(rule: &TunnelRule) -> Self {
1110 Self {
1111 tunnel_type: rule.tunnel_type,
1112 bind_port: rule.bind_port.to_string(),
1113 remote_host: rule.remote_host.clone(),
1114 remote_port: if rule.remote_port > 0 {
1115 rule.remote_port.to_string()
1116 } else {
1117 String::new()
1118 },
1119 bind_address: rule.bind_address.clone(),
1120 focused_field: TunnelFormField::Type,
1121 cursor_pos: 0,
1122 }
1123 }
1124
1125 pub fn focus_next(&mut self) {
1129 self.focused_field = self.focused_field.next(self.tunnel_type);
1130 self.sync_cursor_to_end();
1131 }
1132
1133 pub fn focus_prev(&mut self) {
1137 self.focused_field = self.focused_field.prev(self.tunnel_type);
1138 self.sync_cursor_to_end();
1139 }
1140
1141 pub fn validate(&self) -> Result<(), String> {
1143 let fields = [
1145 (&self.bind_port, "Bind Port"),
1146 (&self.remote_host, "Remote Host"),
1147 (&self.remote_port, "Remote Port"),
1148 ];
1149 for (value, name) in &fields {
1150 if value.chars().any(|c| c.is_control()) {
1151 return Err(crate::messages::field_control_chars_short(name));
1152 }
1153 }
1154 let port: u16 = self
1155 .bind_port
1156 .parse()
1157 .map_err(|_| crate::messages::TUNNEL_BIND_PORT_INVALID.to_string())?;
1158 if port == 0 {
1159 return Err(crate::messages::TUNNEL_BIND_PORT_ZERO.to_string());
1160 }
1161 if self.tunnel_type != TunnelType::Dynamic {
1162 let host = self.remote_host.trim();
1163 if host.is_empty() {
1164 return Err(crate::messages::TUNNEL_REMOTE_HOST_EMPTY.to_string());
1165 }
1166 if host.contains(char::is_whitespace) {
1167 return Err(crate::messages::TUNNEL_REMOTE_HOST_SPACES.to_string());
1168 }
1169 let rport: u16 = self
1170 .remote_port
1171 .parse()
1172 .map_err(|_| crate::messages::TUNNEL_REMOTE_PORT_INVALID.to_string())?;
1173 if rport == 0 {
1174 return Err(crate::messages::TUNNEL_REMOTE_PORT_ZERO.to_string());
1175 }
1176 }
1177 Ok(())
1178 }
1179
1180 pub fn to_directive(&self) -> (&'static str, String) {
1183 let key = self.tunnel_type.directive_key();
1184 let bind_port: u16 = self.bind_port.parse().unwrap_or(0);
1185 let remote_port: u16 = self.remote_port.parse().unwrap_or(0);
1186 let rule = TunnelRule {
1187 tunnel_type: self.tunnel_type,
1188 bind_address: self.bind_address.clone(),
1189 bind_port,
1190 remote_host: self.remote_host.clone(),
1191 remote_port,
1192 };
1193 (key, rule.to_directive_value())
1194 }
1195
1196 pub fn focused_value(&self) -> Option<&str> {
1197 match self.focused_field {
1198 TunnelFormField::Type => None,
1199 TunnelFormField::BindPort => Some(&self.bind_port),
1200 TunnelFormField::RemoteHost => Some(&self.remote_host),
1201 TunnelFormField::RemotePort => Some(&self.remote_port),
1202 }
1203 }
1204
1205 pub fn focused_value_mut(&mut self) -> Option<&mut String> {
1208 match self.focused_field {
1209 TunnelFormField::Type => None,
1210 TunnelFormField::BindPort => Some(&mut self.bind_port),
1211 TunnelFormField::RemoteHost => Some(&mut self.remote_host),
1212 TunnelFormField::RemotePort => Some(&mut self.remote_port),
1213 }
1214 }
1215
1216 pub fn insert_char(&mut self, c: char) {
1217 let pos = self.cursor_pos;
1218 if let Some(val) = self.focused_value_mut() {
1219 let byte_pos = char_to_byte_pos(val, pos);
1220 val.insert(byte_pos, c);
1221 self.cursor_pos = pos + 1;
1222 }
1223 }
1224
1225 pub fn delete_char_before_cursor(&mut self) {
1226 if self.cursor_pos == 0 {
1227 return;
1228 }
1229 let pos = self.cursor_pos;
1230 if let Some(val) = self.focused_value_mut() {
1231 let byte_pos = char_to_byte_pos(val, pos);
1232 let prev = char_to_byte_pos(val, pos - 1);
1233 val.drain(prev..byte_pos);
1234 self.cursor_pos = pos - 1;
1235 }
1236 }
1237
1238 pub fn sync_cursor_to_end(&mut self) {
1239 self.cursor_pos = self.focused_value().map(|v| v.chars().count()).unwrap_or(0);
1240 }
1241}
1242
1243#[derive(Debug, Clone, Copy, PartialEq)]
1245pub enum SnippetFormField {
1246 Name,
1247 Command,
1248 Description,
1249 Interactive,
1251 DefaultHosts,
1254}
1255
1256impl SnippetFormField {
1257 pub const ALL: &[SnippetFormField] = &[
1258 SnippetFormField::Name,
1259 SnippetFormField::Command,
1260 SnippetFormField::Description,
1261 SnippetFormField::Interactive,
1262 SnippetFormField::DefaultHosts,
1263 ];
1264
1265 pub fn is_picker(self) -> bool {
1268 matches!(self, SnippetFormField::DefaultHosts)
1269 }
1270
1271 pub fn is_toggle(self) -> bool {
1274 matches!(self, SnippetFormField::Interactive)
1275 }
1276
1277 pub fn kind(self) -> crate::ui::design::FieldKind {
1280 if self.is_toggle() {
1281 crate::ui::design::FieldKind::Toggle
1282 } else if self.is_picker() {
1283 crate::ui::design::FieldKind::Picker
1284 } else {
1285 crate::ui::design::FieldKind::Text
1286 }
1287 }
1288
1289 pub fn next(self) -> Self {
1290 let idx = Self::ALL.iter().position(|f| *f == self).unwrap_or(0);
1291 Self::ALL[(idx + 1) % Self::ALL.len()]
1292 }
1293
1294 pub fn prev(self) -> Self {
1295 let idx = Self::ALL.iter().position(|f| *f == self).unwrap_or(0);
1296 Self::ALL[(idx + Self::ALL.len() - 1) % Self::ALL.len()]
1297 }
1298
1299 pub fn label(self) -> &'static str {
1300 match self {
1301 SnippetFormField::Name => "Name",
1302 SnippetFormField::Command => "Command",
1303 SnippetFormField::Description => "Description",
1304 SnippetFormField::Interactive => "Interactive shell",
1305 SnippetFormField::DefaultHosts => "Default hosts",
1306 }
1307 }
1308}
1309
1310#[derive(Debug, Clone)]
1312pub struct SnippetForm {
1313 pub name: String,
1314 pub command: String,
1315 pub description: String,
1316 pub interactive: bool,
1317 pub default_hosts: Vec<String>,
1321 pub focused_field: SnippetFormField,
1322 pub cursor_pos: usize,
1323}
1324
1325impl SnippetForm {
1326 pub(crate) fn new() -> Self {
1327 Self {
1328 name: String::new(),
1329 command: String::new(),
1330 description: String::new(),
1331 interactive: false,
1332 default_hosts: Vec::new(),
1333 focused_field: SnippetFormField::Name,
1334 cursor_pos: 0,
1335 }
1336 }
1337
1338 pub fn from_snippet(snippet: &crate::snippet::Snippet) -> Self {
1339 Self {
1340 name: snippet.name.clone(),
1341 command: snippet.command.clone(),
1342 description: snippet.description.clone(),
1343 interactive: snippet.interactive,
1344 default_hosts: Vec::new(),
1347 focused_field: SnippetFormField::Name,
1348 cursor_pos: snippet.name.chars().count(),
1349 }
1350 }
1351
1352 pub fn focus_next(&mut self) {
1355 self.focused_field = self.focused_field.next();
1356 self.sync_cursor_to_end();
1357 }
1358
1359 pub fn focus_prev(&mut self) {
1362 self.focused_field = self.focused_field.prev();
1363 self.sync_cursor_to_end();
1364 }
1365
1366 pub fn focused_value(&self) -> &str {
1367 match self.focused_field {
1368 SnippetFormField::Name => &self.name,
1369 SnippetFormField::Command => &self.command,
1370 SnippetFormField::Description => &self.description,
1371 SnippetFormField::Interactive | SnippetFormField::DefaultHosts => "",
1373 }
1374 }
1375
1376 pub fn focused_value_mut(&mut self) -> Option<&mut String> {
1379 match self.focused_field {
1380 SnippetFormField::Name => Some(&mut self.name),
1381 SnippetFormField::Command => Some(&mut self.command),
1382 SnippetFormField::Description => Some(&mut self.description),
1383 SnippetFormField::Interactive | SnippetFormField::DefaultHosts => None,
1384 }
1385 }
1386
1387 pub fn insert_char(&mut self, c: char) {
1388 let pos = self.cursor_pos;
1389 let Some(val) = self.focused_value_mut() else {
1390 return;
1391 };
1392 let byte_pos = char_to_byte_pos(val, pos);
1393 val.insert(byte_pos, c);
1394 self.cursor_pos = pos + 1;
1395 }
1396
1397 pub fn delete_char_before_cursor(&mut self) {
1398 if self.cursor_pos == 0 {
1399 return;
1400 }
1401 let pos = self.cursor_pos;
1402 let Some(val) = self.focused_value_mut() else {
1403 return;
1404 };
1405 let byte_pos = char_to_byte_pos(val, pos);
1406 let prev = char_to_byte_pos(val, pos - 1);
1407 val.drain(prev..byte_pos);
1408 self.cursor_pos = pos - 1;
1409 }
1410
1411 pub fn sync_cursor_to_end(&mut self) {
1412 self.cursor_pos = self.focused_value().chars().count();
1413 }
1414
1415 pub fn validate(&self) -> Result<(), String> {
1416 crate::snippet::validate_name(&self.name)?;
1417 crate::snippet::validate_command(&self.command)?;
1418 if self.description.contains(|c: char| c.is_control()) {
1419 return Err(crate::messages::SNIPPET_DESCRIPTION_CONTROL_CHARS.to_string());
1420 }
1421 Ok(())
1422 }
1423}
1424
1425#[derive(Debug, Clone)]
1427pub struct SnippetHostOutput {
1428 pub alias: String,
1429 pub stdout: String,
1430 pub stderr: String,
1431 pub exit_code: Option<i32>,
1432}
1433
1434impl SnippetHostOutput {
1435 pub fn shows_not_found_hint(&self, interactive: bool) -> bool {
1439 crate::snippet::not_found_hint_applies(interactive, self.exit_code)
1440 }
1441}
1442
1443#[derive(Debug, Clone)]
1445pub struct SnippetOutputState {
1446 pub run_id: u64,
1447 pub results: Vec<SnippetHostOutput>,
1448 pub scroll_offset: usize,
1449 pub completed: usize,
1450 pub total: usize,
1451 pub all_done: bool,
1452 pub cancel: std::sync::Arc<std::sync::atomic::AtomicBool>,
1453 pub interactive: bool,
1455}
1456
1457#[derive(Debug, Clone)]
1459pub struct SnippetParamFormState {
1460 pub params: Vec<crate::snippet::SnippetParam>,
1461 pub values: Vec<String>,
1462 pub focused_index: usize,
1463 pub cursor_pos: usize,
1464 pub scroll_offset: usize,
1465 pub visible_count: usize,
1467}
1468
1469impl SnippetParamFormState {
1470 pub fn new(params: &[crate::snippet::SnippetParam]) -> Self {
1471 let values: Vec<String> = params
1472 .iter()
1473 .map(|p| p.default.clone().unwrap_or_default())
1474 .collect();
1475 let cursor_pos = values.first().map(|v| v.chars().count()).unwrap_or(0);
1476 Self {
1477 params: params.to_vec(),
1478 values,
1479 focused_index: 0,
1480 cursor_pos,
1481 scroll_offset: 0,
1482 visible_count: params.len().min(8),
1483 }
1484 }
1485
1486 pub fn insert_char(&mut self, c: char) {
1487 let idx = self.focused_index;
1488 let pos = self.cursor_pos;
1489 let Some(val) = self.values.get_mut(idx) else {
1490 return;
1491 };
1492 let byte_pos = char_to_byte_pos(val, pos);
1493 val.insert(byte_pos, c);
1494 self.cursor_pos = pos + 1;
1495 }
1496
1497 pub fn delete_char_before_cursor(&mut self) {
1498 if self.cursor_pos == 0 {
1499 return;
1500 }
1501 let idx = self.focused_index;
1502 let pos = self.cursor_pos;
1503 let Some(val) = self.values.get_mut(idx) else {
1504 return;
1505 };
1506 let byte_pos = char_to_byte_pos(val, pos);
1507 let prev = char_to_byte_pos(val, pos - 1);
1508 val.drain(prev..byte_pos);
1509 self.cursor_pos = pos - 1;
1510 }
1511
1512 pub fn values_map(&self) -> HashMap<String, String> {
1514 self.params
1515 .iter()
1516 .enumerate()
1517 .map(|(i, p)| (p.name.clone(), self.values[i].clone()))
1518 .collect()
1519 }
1520
1521 pub fn is_dirty(&self) -> bool {
1523 self.params.iter().enumerate().any(|(i, p)| {
1524 let default = p.default.as_deref().unwrap_or("");
1525 self.values[i] != default
1526 })
1527 }
1528}
1529
1530#[cfg(test)]
1531mod host_form_method_tests {
1532 use super::*;
1533 use crate::quick_add::ParsedTarget;
1534
1535 #[test]
1536 fn apply_password_source_sets_value_and_keeps_focus_when_refocus_false() {
1537 let mut f = HostForm::new();
1538 f.focused_field = FormField::Alias;
1539 f.apply_password_source("vault:secret/ssh#pw".to_string(), false);
1540 assert_eq!(f.askpass, "vault:secret/ssh#pw");
1541 assert_eq!(f.focused_field, FormField::Alias);
1542 assert_eq!(f.cursor_pos, "vault:secret/ssh#pw".chars().count());
1543 }
1544
1545 #[test]
1546 fn apply_password_source_moves_focus_to_askpass_when_refocus_true() {
1547 let mut f = HostForm::new();
1548 f.focused_field = FormField::Hostname;
1549 f.apply_password_source("op://Vault/Item/pw".to_string(), true);
1550 assert_eq!(f.askpass, "op://Vault/Item/pw");
1551 assert_eq!(f.focused_field, FormField::AskPass);
1552 assert_eq!(f.cursor_pos, "op://Vault/Item/pw".chars().count());
1553 }
1554
1555 #[test]
1556 fn apply_password_source_clears_askpass_with_empty_value() {
1557 let mut f = HostForm::new();
1558 f.askpass = "old".into();
1559 f.focused_field = FormField::AskPass;
1560 f.apply_password_source(String::new(), false);
1561 assert_eq!(f.askpass, "");
1562 assert_eq!(f.cursor_pos, 0);
1563 }
1564
1565 #[test]
1566 fn apply_smart_paste_fills_empty_fields_and_overwrites_alias() {
1567 let mut f = HostForm::new();
1568 f.alias = "user@host:2222".into();
1569 let parsed = ParsedTarget {
1570 user: "alice".into(),
1571 hostname: "db.example.com".into(),
1572 port: 2222,
1573 };
1574 f.apply_smart_paste(parsed, "db".into());
1575 assert_eq!(f.alias, "db");
1576 assert_eq!(f.hostname, "db.example.com");
1577 assert_eq!(f.user, "alice");
1578 assert_eq!(f.port, "2222");
1579 }
1580
1581 #[test]
1582 fn apply_smart_paste_does_not_overwrite_non_empty_hostname() {
1583 let mut f = HostForm::new();
1584 f.hostname = "existing.com".into();
1585 let parsed = ParsedTarget {
1586 user: "u".into(),
1587 hostname: "parsed.com".into(),
1588 port: 22,
1589 };
1590 f.apply_smart_paste(parsed, "x".into());
1591 assert_eq!(f.hostname, "existing.com");
1592 }
1593
1594 #[test]
1595 fn apply_smart_paste_does_not_overwrite_non_empty_user() {
1596 let mut f = HostForm::new();
1597 f.user = "bob".into();
1598 let parsed = ParsedTarget {
1599 user: "alice".into(),
1600 hostname: "host".into(),
1601 port: 22,
1602 };
1603 f.apply_smart_paste(parsed, "x".into());
1604 assert_eq!(f.user, "bob");
1605 }
1606
1607 #[test]
1608 fn apply_smart_paste_keeps_default_port_when_parsed_port_is_default() {
1609 let mut f = HostForm::new();
1610 let parsed = ParsedTarget {
1611 user: "u".into(),
1612 hostname: "h".into(),
1613 port: 22,
1614 };
1615 f.apply_smart_paste(parsed, "x".into());
1616 assert_eq!(f.port, "22");
1617 }
1618
1619 #[test]
1620 fn apply_smart_paste_does_not_overwrite_user_when_parsed_user_is_empty() {
1621 let mut f = HostForm::new();
1622 let parsed = ParsedTarget {
1623 user: String::new(),
1624 hostname: "h".into(),
1625 port: 22,
1626 };
1627 f.apply_smart_paste(parsed, "x".into());
1628 assert_eq!(f.user, "");
1629 }
1630
1631 #[test]
1632 fn apply_smart_paste_keeps_user_custom_port_even_when_parsed_port_differs() {
1633 let mut f = HostForm::new();
1634 f.port = "8022".into();
1635 let parsed = ParsedTarget {
1636 user: "u".into(),
1637 hostname: "h".into(),
1638 port: 2222,
1639 };
1640 f.apply_smart_paste(parsed, "x".into());
1641 assert_eq!(f.port, "8022");
1642 }
1643
1644 #[test]
1645 fn apply_password_source_empty_value_with_refocus_moves_focus_and_zeros_cursor() {
1646 let mut f = HostForm::new();
1647 f.focused_field = FormField::Hostname;
1648 f.apply_password_source(String::new(), true);
1649 assert_eq!(f.askpass, "");
1650 assert_eq!(f.focused_field, FormField::AskPass);
1651 assert_eq!(f.cursor_pos, 0);
1652 }
1653
1654 #[test]
1655 fn tunnel_form_focus_next_from_type_goes_to_bind_port_and_resets_cursor() {
1656 let mut f = TunnelForm::new();
1657 f.bind_port = "8080".into();
1658 f.cursor_pos = 99;
1659 assert_eq!(f.focused_field, TunnelFormField::Type);
1660 f.focus_next();
1661 assert_eq!(f.focused_field, TunnelFormField::BindPort);
1662 assert_eq!(f.cursor_pos, 4);
1664 }
1665
1666 #[test]
1667 fn tunnel_form_focus_next_wraps_from_remote_port_back_to_type() {
1668 let mut f = TunnelForm::new();
1669 f.focused_field = TunnelFormField::RemotePort;
1670 f.remote_port = "443".into();
1674 f.cursor_pos = 99;
1675 f.focus_next();
1676 assert_eq!(f.focused_field, TunnelFormField::Type);
1677 assert_eq!(f.cursor_pos, 0);
1678 }
1679
1680 #[test]
1681 fn tunnel_form_focus_next_dynamic_skips_remote_fields() {
1682 let mut f = TunnelForm::new();
1683 f.tunnel_type = TunnelType::Dynamic;
1684 f.focused_field = TunnelFormField::BindPort;
1685 f.bind_port = "8080".into();
1688 f.cursor_pos = 99;
1689 f.focus_next();
1690 assert_eq!(f.focused_field, TunnelFormField::Type);
1692 assert_eq!(f.cursor_pos, 0);
1693 }
1694
1695 #[test]
1696 fn tunnel_form_focus_prev_from_bind_port_goes_to_type() {
1697 let mut f = TunnelForm::new();
1698 f.focused_field = TunnelFormField::BindPort;
1699 f.bind_port = "8080".into();
1702 f.cursor_pos = 99;
1703 f.focus_prev();
1704 assert_eq!(f.focused_field, TunnelFormField::Type);
1705 assert_eq!(f.cursor_pos, 0);
1706 }
1707
1708 #[test]
1709 fn tunnel_form_focus_next_preserves_field_values_and_tunnel_type() {
1710 let mut f = TunnelForm {
1711 tunnel_type: TunnelType::Local,
1712 bind_port: "1234".into(),
1713 remote_host: "example.com".into(),
1714 remote_port: "5678".into(),
1715 bind_address: "127.0.0.1".into(),
1716 focused_field: TunnelFormField::Type,
1717 cursor_pos: 0,
1718 };
1719 f.focus_next();
1720 assert_eq!(f.bind_port, "1234");
1721 assert_eq!(f.remote_host, "example.com");
1722 assert_eq!(f.remote_port, "5678");
1723 assert_eq!(f.bind_address, "127.0.0.1");
1724 assert_eq!(f.tunnel_type, TunnelType::Local);
1725 }
1726
1727 #[test]
1728 fn snippet_form_focus_next_advances_field_and_syncs_cursor_to_target() {
1729 let mut f = SnippetForm::new();
1730 f.name = "abc".into();
1733 f.command = "de".into();
1734 f.focused_field = SnippetFormField::Name;
1735 f.cursor_pos = 99;
1736 f.focus_next();
1737 assert_eq!(f.focused_field, SnippetFormField::Command);
1738 assert_eq!(f.cursor_pos, 2);
1739 }
1740
1741 #[test]
1742 fn snippet_form_focus_prev_retreats_field_and_syncs_cursor_to_target() {
1743 let mut f = SnippetForm::new();
1744 f.name = "xyz".into();
1747 f.command = "ab".into();
1748 f.focused_field = SnippetFormField::Command;
1749 f.cursor_pos = 99;
1750 f.focus_prev();
1751 assert_eq!(f.focused_field, SnippetFormField::Name);
1752 assert_eq!(f.cursor_pos, 3);
1753 }
1754
1755 #[test]
1756 fn snippet_form_focus_next_preserves_field_values() {
1757 let mut f = SnippetForm::new();
1758 f.name = "foo".into();
1759 f.command = "bar".into();
1760 f.description = "baz".into();
1761 f.focus_next();
1762 assert_eq!(f.name, "foo");
1763 assert_eq!(f.command, "bar");
1764 assert_eq!(f.description, "baz");
1765 }
1766}