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 AliasPrefix,
573 User,
574 IdentityFile,
575 VerifyTls,
576 VaultRole,
577 VaultAddr,
578 AutoSync,
579}
580
581impl ProviderFormField {
582 const CLOUD_FIELDS: &[ProviderFormField] = &[
583 ProviderFormField::Token,
584 ProviderFormField::AliasPrefix,
585 ProviderFormField::User,
586 ProviderFormField::IdentityFile,
587 ProviderFormField::VaultRole,
588 ProviderFormField::VaultAddr,
589 ProviderFormField::AutoSync,
590 ];
591
592 const PROXMOX_FIELDS: &[ProviderFormField] = &[
593 ProviderFormField::Url,
594 ProviderFormField::Token,
595 ProviderFormField::AliasPrefix,
596 ProviderFormField::User,
597 ProviderFormField::IdentityFile,
598 ProviderFormField::VerifyTls,
599 ProviderFormField::VaultRole,
600 ProviderFormField::VaultAddr,
601 ProviderFormField::AutoSync,
602 ];
603
604 const AWS_FIELDS: &[ProviderFormField] = &[
605 ProviderFormField::Token,
606 ProviderFormField::Profile,
607 ProviderFormField::Regions,
608 ProviderFormField::AliasPrefix,
609 ProviderFormField::User,
610 ProviderFormField::IdentityFile,
611 ProviderFormField::VaultRole,
612 ProviderFormField::VaultAddr,
613 ProviderFormField::AutoSync,
614 ];
615
616 const SCALEWAY_FIELDS: &[ProviderFormField] = &[
617 ProviderFormField::Token,
618 ProviderFormField::Regions,
619 ProviderFormField::AliasPrefix,
620 ProviderFormField::User,
621 ProviderFormField::IdentityFile,
622 ProviderFormField::VaultRole,
623 ProviderFormField::VaultAddr,
624 ProviderFormField::AutoSync,
625 ];
626
627 const GCP_FIELDS: &[ProviderFormField] = &[
628 ProviderFormField::Token,
629 ProviderFormField::Project,
630 ProviderFormField::Regions,
631 ProviderFormField::AliasPrefix,
632 ProviderFormField::User,
633 ProviderFormField::IdentityFile,
634 ProviderFormField::VaultRole,
635 ProviderFormField::VaultAddr,
636 ProviderFormField::AutoSync,
637 ];
638
639 const AZURE_FIELDS: &[ProviderFormField] = &[
640 ProviderFormField::Token,
641 ProviderFormField::Regions,
642 ProviderFormField::AliasPrefix,
643 ProviderFormField::User,
644 ProviderFormField::IdentityFile,
645 ProviderFormField::VaultRole,
646 ProviderFormField::VaultAddr,
647 ProviderFormField::AutoSync,
648 ];
649
650 const ORACLE_FIELDS: &[ProviderFormField] = &[
651 ProviderFormField::Token,
652 ProviderFormField::Compartment,
653 ProviderFormField::Regions,
654 ProviderFormField::AliasPrefix,
655 ProviderFormField::User,
656 ProviderFormField::IdentityFile,
657 ProviderFormField::VaultRole,
658 ProviderFormField::VaultAddr,
659 ProviderFormField::AutoSync,
660 ];
661
662 const TELEPORT_FIELDS: &[ProviderFormField] = &[
666 ProviderFormField::User,
667 ProviderFormField::AliasPrefix,
668 ProviderFormField::IdentityFile,
669 ProviderFormField::AutoSync,
670 ];
671
672 const OVH_FIELDS: &[ProviderFormField] = &[
673 ProviderFormField::Token,
674 ProviderFormField::Project,
675 ProviderFormField::Regions,
676 ProviderFormField::AliasPrefix,
677 ProviderFormField::User,
678 ProviderFormField::IdentityFile,
679 ProviderFormField::VaultRole,
680 ProviderFormField::VaultAddr,
681 ProviderFormField::AutoSync,
682 ];
683
684 pub fn fields_for(provider: &str) -> &'static [ProviderFormField] {
685 let Ok(kind) = provider.parse::<ProviderKind>() else {
686 return Self::CLOUD_FIELDS;
687 };
688 match kind {
689 ProviderKind::Proxmox => Self::PROXMOX_FIELDS,
690 ProviderKind::Aws => Self::AWS_FIELDS,
691 ProviderKind::Scaleway => Self::SCALEWAY_FIELDS,
692 ProviderKind::Gcp => Self::GCP_FIELDS,
693 ProviderKind::Azure => Self::AZURE_FIELDS,
694 ProviderKind::Oracle => Self::ORACLE_FIELDS,
695 ProviderKind::Ovh => Self::OVH_FIELDS,
696 ProviderKind::Teleport => Self::TELEPORT_FIELDS,
697 ProviderKind::DigitalOcean
698 | ProviderKind::Hetzner
699 | ProviderKind::I3d
700 | ProviderKind::Leaseweb
701 | ProviderKind::Linode
702 | ProviderKind::Tailscale
703 | ProviderKind::Transip
704 | ProviderKind::UpCloud
705 | ProviderKind::Vultr => Self::CLOUD_FIELDS,
706 }
707 }
708
709 #[cfg(test)]
712 pub fn required_fields_for(provider: &str) -> Vec<ProviderFormField> {
713 let all = Self::fields_for(provider);
714 all.iter()
715 .filter(|f| Self::is_required_field(**f, provider))
716 .copied()
717 .collect()
718 }
719
720 #[cfg(test)]
723 pub fn optional_fields_for(provider: &str) -> Vec<ProviderFormField> {
724 let all = Self::fields_for(provider);
725 all.iter()
726 .filter(|f| !Self::is_required_field(**f, provider))
727 .copied()
728 .collect()
729 }
730
731 pub fn is_mandatory_field(field: ProviderFormField, provider: &str) -> bool {
742 let kind = provider.parse::<ProviderKind>().ok();
743 match field {
744 ProviderFormField::Label => true,
745 ProviderFormField::Url => true,
746 ProviderFormField::Token => {
747 !matches!(kind, Some(ProviderKind::Tailscale | ProviderKind::Teleport))
748 }
749 ProviderFormField::Profile => kind == Some(ProviderKind::Aws),
750 ProviderFormField::Project => kind.is_some_and(ProviderKind::has_project_field),
751 ProviderFormField::Compartment => kind == Some(ProviderKind::Oracle),
752 ProviderFormField::Regions => {
753 kind.is_some_and(ProviderKind::regions_field_is_mandatory)
754 }
755 _ => false,
756 }
757 }
758
759 pub fn is_required_field(field: ProviderFormField, provider: &str) -> bool {
763 let kind = provider.parse::<ProviderKind>().ok();
764 match field {
765 ProviderFormField::Label => true,
766 ProviderFormField::Token => true,
767 ProviderFormField::Url => kind.is_some_and(ProviderKind::requires_url),
768 ProviderFormField::Profile => kind == Some(ProviderKind::Aws),
769 ProviderFormField::Project => kind.is_some_and(ProviderKind::has_project_field),
770 ProviderFormField::Compartment => kind == Some(ProviderKind::Oracle),
771 ProviderFormField::Regions => kind.is_some_and(ProviderKind::has_regions_field),
772 ProviderFormField::User => kind == Some(ProviderKind::Teleport),
775 _ => false,
776 }
777 }
778
779 pub fn next(self, fields: &[Self]) -> Self {
780 debug_assert!(
781 fields.contains(&self),
782 "focused field {:?} not in fields slice",
783 self
784 );
785 let idx = fields.iter().position(|f| *f == self).unwrap_or(0);
786 fields[(idx + 1) % fields.len()]
787 }
788
789 pub fn prev(self, fields: &[Self]) -> Self {
790 debug_assert!(
791 fields.contains(&self),
792 "focused field {:?} not in fields slice",
793 self
794 );
795 let idx = fields.iter().position(|f| *f == self).unwrap_or(0);
796 fields[(idx + fields.len() - 1) % fields.len()]
797 }
798
799 pub fn label(self) -> &'static str {
800 match self {
801 ProviderFormField::Label => "Name",
802 ProviderFormField::Url => "URL",
803 ProviderFormField::Token => "Token",
804 ProviderFormField::Profile => "Profile",
805 ProviderFormField::Project => "Project ID",
806 ProviderFormField::Compartment => "Compartment",
807 ProviderFormField::Regions => "Regions",
808 ProviderFormField::AliasPrefix => "Alias Prefix",
809 ProviderFormField::User => "User",
810 ProviderFormField::IdentityFile => "Identity File",
811 ProviderFormField::VerifyTls => "Verify TLS",
812 ProviderFormField::VaultRole => "Vault SSH Role",
813 ProviderFormField::VaultAddr => "Vault SSH Address",
814 ProviderFormField::AutoSync => "Auto Sync",
815 }
816 }
817
818 pub fn is_toggle(self) -> bool {
820 matches!(
821 self,
822 ProviderFormField::VerifyTls | ProviderFormField::AutoSync
823 )
824 }
825
826 pub fn is_picker(self, provider: &str) -> bool {
833 match self {
834 ProviderFormField::IdentityFile => true,
835 ProviderFormField::Regions => provider
836 .parse::<ProviderKind>()
837 .ok()
838 .is_some_and(ProviderKind::regions_field_is_picker),
839 _ => false,
840 }
841 }
842
843 pub fn kind(self, provider: &str) -> crate::ui::design::FieldKind {
846 if self.is_toggle() {
847 crate::ui::design::FieldKind::Toggle
848 } else if self.is_picker(provider) {
849 crate::ui::design::FieldKind::Picker
850 } else {
851 crate::ui::design::FieldKind::Text
852 }
853 }
854}
855
856#[derive(Debug, Clone)]
858pub struct ProviderFormFields {
859 pub label: String,
864 pub label_entry: bool,
868 pub url: String,
869 pub token: String,
870 pub profile: String,
871 pub project: String,
872 pub compartment: String,
873 pub regions: String,
874 pub alias_prefix: String,
875 pub user: String,
876 pub identity_file: String,
877 pub verify_tls: bool,
878 pub auto_sync: bool,
879 pub vault_role: String,
880 pub vault_addr: String,
884 pub focused_field: ProviderFormField,
885 pub cursor_pos: usize,
886 pub expanded: bool,
889}
890
891impl ProviderFormFields {
892 pub(crate) fn new() -> Self {
893 Self {
894 label: String::new(),
895 label_entry: false,
896 url: String::new(),
897 token: String::new(),
898 profile: String::new(),
899 project: String::new(),
900 compartment: String::new(),
901 regions: String::new(),
902 alias_prefix: String::new(),
903 user: "root".to_string(),
904 identity_file: String::new(),
905 verify_tls: true,
906 auto_sync: true,
907 vault_role: String::new(),
908 vault_addr: String::new(),
909 focused_field: ProviderFormField::Token,
910 cursor_pos: 0,
911 expanded: false,
912 }
913 }
914
915 pub fn focused_value(&self) -> &str {
916 match self.focused_field {
917 ProviderFormField::Label => &self.label,
918 ProviderFormField::Url => &self.url,
919 ProviderFormField::Token => &self.token,
920 ProviderFormField::Profile => &self.profile,
921 ProviderFormField::Project => &self.project,
922 ProviderFormField::Compartment => &self.compartment,
923 ProviderFormField::Regions => &self.regions,
924 ProviderFormField::AliasPrefix => &self.alias_prefix,
925 ProviderFormField::User => &self.user,
926 ProviderFormField::IdentityFile => &self.identity_file,
927 ProviderFormField::VaultRole => &self.vault_role,
928 ProviderFormField::VaultAddr => &self.vault_addr,
929 ProviderFormField::VerifyTls | ProviderFormField::AutoSync => "",
930 }
931 }
932
933 pub fn focused_value_mut(&mut self) -> Option<&mut String> {
934 match self.focused_field {
935 ProviderFormField::Label => Some(&mut self.label),
936 ProviderFormField::Url => Some(&mut self.url),
937 ProviderFormField::Token => Some(&mut self.token),
938 ProviderFormField::Profile => Some(&mut self.profile),
939 ProviderFormField::Project => Some(&mut self.project),
940 ProviderFormField::Compartment => Some(&mut self.compartment),
941 ProviderFormField::Regions => Some(&mut self.regions),
942 ProviderFormField::AliasPrefix => Some(&mut self.alias_prefix),
943 ProviderFormField::User => Some(&mut self.user),
944 ProviderFormField::IdentityFile => Some(&mut self.identity_file),
945 ProviderFormField::VaultRole => Some(&mut self.vault_role),
946 ProviderFormField::VaultAddr => Some(&mut self.vault_addr),
947 ProviderFormField::VerifyTls | ProviderFormField::AutoSync => None,
948 }
949 }
950
951 pub fn visible_fields(&self, provider: &str) -> Vec<ProviderFormField> {
958 let role_set = !self.vault_role.trim().is_empty();
959 let base = ProviderFormField::fields_for(provider)
960 .iter()
961 .copied()
962 .filter(|f| *f != ProviderFormField::VaultAddr || role_set);
963 if self.label_entry {
964 std::iter::once(ProviderFormField::Label)
965 .chain(base)
966 .collect()
967 } else {
968 base.collect()
969 }
970 }
971
972 pub fn insert_char(&mut self, c: char) {
973 let pos = self.cursor_pos;
974 if let Some(val) = self.focused_value_mut() {
975 let byte_pos = char_to_byte_pos(val, pos);
976 val.insert(byte_pos, c);
977 self.cursor_pos = pos + 1;
978 }
979 }
980
981 pub fn delete_char_before_cursor(&mut self) {
982 if self.cursor_pos == 0 {
983 return;
984 }
985 let pos = self.cursor_pos;
986 if let Some(val) = self.focused_value_mut() {
987 let byte_pos = char_to_byte_pos(val, pos);
988 let prev = char_to_byte_pos(val, pos - 1);
989 val.drain(prev..byte_pos);
990 self.cursor_pos = pos - 1;
991 }
992 }
993
994 pub fn sync_cursor_to_end(&mut self) {
995 self.cursor_pos = self.focused_value().chars().count();
996 }
997}
998
999pub(crate) fn char_to_byte_pos(s: &str, char_pos: usize) -> usize {
1000 s.char_indices()
1001 .nth(char_pos)
1002 .map(|(i, _)| i)
1003 .unwrap_or(s.len())
1004}
1005
1006#[derive(Debug, Clone, Copy, PartialEq)]
1008pub enum TunnelFormField {
1009 Type,
1010 BindPort,
1011 RemoteHost,
1012 RemotePort,
1013}
1014
1015impl TunnelFormField {
1016 pub fn next(self, tunnel_type: TunnelType) -> Self {
1018 match (self, tunnel_type) {
1019 (TunnelFormField::Type, _) => TunnelFormField::BindPort,
1020 (TunnelFormField::BindPort, TunnelType::Dynamic) => TunnelFormField::Type,
1021 (TunnelFormField::BindPort, _) => TunnelFormField::RemoteHost,
1022 (TunnelFormField::RemoteHost, _) => TunnelFormField::RemotePort,
1023 (TunnelFormField::RemotePort, _) => TunnelFormField::Type,
1024 }
1025 }
1026
1027 pub fn prev(self, tunnel_type: TunnelType) -> Self {
1029 match (self, tunnel_type) {
1030 (TunnelFormField::Type, TunnelType::Dynamic) => TunnelFormField::BindPort,
1031 (TunnelFormField::Type, _) => TunnelFormField::RemotePort,
1032 (TunnelFormField::BindPort, _) => TunnelFormField::Type,
1033 (TunnelFormField::RemoteHost, _) => TunnelFormField::BindPort,
1034 (TunnelFormField::RemotePort, _) => TunnelFormField::RemoteHost,
1035 }
1036 }
1037
1038 pub fn label(self) -> &'static str {
1039 match self {
1040 TunnelFormField::Type => "Type",
1041 TunnelFormField::BindPort => "Bind Port",
1042 TunnelFormField::RemoteHost => "Remote Host",
1043 TunnelFormField::RemotePort => "Remote Port",
1044 }
1045 }
1046}
1047
1048#[derive(Debug, Clone)]
1050pub struct TunnelForm {
1051 pub tunnel_type: TunnelType,
1052 pub bind_port: String,
1053 pub remote_host: String,
1054 pub remote_port: String,
1055 pub bind_address: String,
1057 pub focused_field: TunnelFormField,
1058 pub cursor_pos: usize,
1059}
1060
1061impl TunnelForm {
1062 pub(crate) fn new() -> Self {
1063 Self {
1064 tunnel_type: TunnelType::Local,
1065 bind_port: String::new(),
1066 remote_host: "localhost".to_string(),
1067 remote_port: String::new(),
1068 bind_address: String::new(),
1069 focused_field: TunnelFormField::Type,
1070 cursor_pos: 0,
1071 }
1072 }
1073
1074 pub fn from_rule(rule: &TunnelRule) -> Self {
1075 Self {
1076 tunnel_type: rule.tunnel_type,
1077 bind_port: rule.bind_port.to_string(),
1078 remote_host: rule.remote_host.clone(),
1079 remote_port: if rule.remote_port > 0 {
1080 rule.remote_port.to_string()
1081 } else {
1082 String::new()
1083 },
1084 bind_address: rule.bind_address.clone(),
1085 focused_field: TunnelFormField::Type,
1086 cursor_pos: 0,
1087 }
1088 }
1089
1090 pub fn focus_next(&mut self) {
1094 self.focused_field = self.focused_field.next(self.tunnel_type);
1095 self.sync_cursor_to_end();
1096 }
1097
1098 pub fn focus_prev(&mut self) {
1102 self.focused_field = self.focused_field.prev(self.tunnel_type);
1103 self.sync_cursor_to_end();
1104 }
1105
1106 pub fn validate(&self) -> Result<(), String> {
1108 let fields = [
1110 (&self.bind_port, "Bind Port"),
1111 (&self.remote_host, "Remote Host"),
1112 (&self.remote_port, "Remote Port"),
1113 ];
1114 for (value, name) in &fields {
1115 if value.chars().any(|c| c.is_control()) {
1116 return Err(crate::messages::field_control_chars_short(name));
1117 }
1118 }
1119 let port: u16 = self
1120 .bind_port
1121 .parse()
1122 .map_err(|_| crate::messages::TUNNEL_BIND_PORT_INVALID.to_string())?;
1123 if port == 0 {
1124 return Err(crate::messages::TUNNEL_BIND_PORT_ZERO.to_string());
1125 }
1126 if self.tunnel_type != TunnelType::Dynamic {
1127 let host = self.remote_host.trim();
1128 if host.is_empty() {
1129 return Err(crate::messages::TUNNEL_REMOTE_HOST_EMPTY.to_string());
1130 }
1131 if host.contains(char::is_whitespace) {
1132 return Err(crate::messages::TUNNEL_REMOTE_HOST_SPACES.to_string());
1133 }
1134 let rport: u16 = self
1135 .remote_port
1136 .parse()
1137 .map_err(|_| crate::messages::TUNNEL_REMOTE_PORT_INVALID.to_string())?;
1138 if rport == 0 {
1139 return Err(crate::messages::TUNNEL_REMOTE_PORT_ZERO.to_string());
1140 }
1141 }
1142 Ok(())
1143 }
1144
1145 pub fn to_directive(&self) -> (&'static str, String) {
1148 let key = self.tunnel_type.directive_key();
1149 let bind_port: u16 = self.bind_port.parse().unwrap_or(0);
1150 let remote_port: u16 = self.remote_port.parse().unwrap_or(0);
1151 let rule = TunnelRule {
1152 tunnel_type: self.tunnel_type,
1153 bind_address: self.bind_address.clone(),
1154 bind_port,
1155 remote_host: self.remote_host.clone(),
1156 remote_port,
1157 };
1158 (key, rule.to_directive_value())
1159 }
1160
1161 pub fn focused_value(&self) -> Option<&str> {
1162 match self.focused_field {
1163 TunnelFormField::Type => None,
1164 TunnelFormField::BindPort => Some(&self.bind_port),
1165 TunnelFormField::RemoteHost => Some(&self.remote_host),
1166 TunnelFormField::RemotePort => Some(&self.remote_port),
1167 }
1168 }
1169
1170 pub fn focused_value_mut(&mut self) -> Option<&mut String> {
1173 match self.focused_field {
1174 TunnelFormField::Type => None,
1175 TunnelFormField::BindPort => Some(&mut self.bind_port),
1176 TunnelFormField::RemoteHost => Some(&mut self.remote_host),
1177 TunnelFormField::RemotePort => Some(&mut self.remote_port),
1178 }
1179 }
1180
1181 pub fn insert_char(&mut self, c: char) {
1182 let pos = self.cursor_pos;
1183 if let Some(val) = self.focused_value_mut() {
1184 let byte_pos = char_to_byte_pos(val, pos);
1185 val.insert(byte_pos, c);
1186 self.cursor_pos = pos + 1;
1187 }
1188 }
1189
1190 pub fn delete_char_before_cursor(&mut self) {
1191 if self.cursor_pos == 0 {
1192 return;
1193 }
1194 let pos = self.cursor_pos;
1195 if let Some(val) = self.focused_value_mut() {
1196 let byte_pos = char_to_byte_pos(val, pos);
1197 let prev = char_to_byte_pos(val, pos - 1);
1198 val.drain(prev..byte_pos);
1199 self.cursor_pos = pos - 1;
1200 }
1201 }
1202
1203 pub fn sync_cursor_to_end(&mut self) {
1204 self.cursor_pos = self.focused_value().map(|v| v.chars().count()).unwrap_or(0);
1205 }
1206}
1207
1208#[derive(Debug, Clone, Copy, PartialEq)]
1210pub enum SnippetFormField {
1211 Name,
1212 Command,
1213 Description,
1214 DefaultHosts,
1217}
1218
1219impl SnippetFormField {
1220 pub const ALL: &[SnippetFormField] = &[
1221 SnippetFormField::Name,
1222 SnippetFormField::Command,
1223 SnippetFormField::Description,
1224 SnippetFormField::DefaultHosts,
1225 ];
1226
1227 pub fn is_picker(self) -> bool {
1230 matches!(self, SnippetFormField::DefaultHosts)
1231 }
1232
1233 pub fn kind(self) -> crate::ui::design::FieldKind {
1236 if self.is_picker() {
1237 crate::ui::design::FieldKind::Picker
1238 } else {
1239 crate::ui::design::FieldKind::Text
1240 }
1241 }
1242
1243 pub fn next(self) -> Self {
1244 let idx = Self::ALL.iter().position(|f| *f == self).unwrap_or(0);
1245 Self::ALL[(idx + 1) % Self::ALL.len()]
1246 }
1247
1248 pub fn prev(self) -> Self {
1249 let idx = Self::ALL.iter().position(|f| *f == self).unwrap_or(0);
1250 Self::ALL[(idx + Self::ALL.len() - 1) % Self::ALL.len()]
1251 }
1252
1253 pub fn label(self) -> &'static str {
1254 match self {
1255 SnippetFormField::Name => "Name",
1256 SnippetFormField::Command => "Command",
1257 SnippetFormField::Description => "Description",
1258 SnippetFormField::DefaultHosts => "Default hosts",
1259 }
1260 }
1261}
1262
1263#[derive(Debug, Clone)]
1265pub struct SnippetForm {
1266 pub name: String,
1267 pub command: String,
1268 pub description: String,
1269 pub default_hosts: Vec<String>,
1273 pub focused_field: SnippetFormField,
1274 pub cursor_pos: usize,
1275}
1276
1277impl SnippetForm {
1278 pub(crate) fn new() -> Self {
1279 Self {
1280 name: String::new(),
1281 command: String::new(),
1282 description: String::new(),
1283 default_hosts: Vec::new(),
1284 focused_field: SnippetFormField::Name,
1285 cursor_pos: 0,
1286 }
1287 }
1288
1289 pub fn from_snippet(snippet: &crate::snippet::Snippet) -> Self {
1290 Self {
1291 name: snippet.name.clone(),
1292 command: snippet.command.clone(),
1293 description: snippet.description.clone(),
1294 default_hosts: Vec::new(),
1297 focused_field: SnippetFormField::Name,
1298 cursor_pos: snippet.name.chars().count(),
1299 }
1300 }
1301
1302 pub fn focus_next(&mut self) {
1305 self.focused_field = self.focused_field.next();
1306 self.sync_cursor_to_end();
1307 }
1308
1309 pub fn focus_prev(&mut self) {
1312 self.focused_field = self.focused_field.prev();
1313 self.sync_cursor_to_end();
1314 }
1315
1316 pub fn focused_value(&self) -> &str {
1317 match self.focused_field {
1318 SnippetFormField::Name => &self.name,
1319 SnippetFormField::Command => &self.command,
1320 SnippetFormField::Description => &self.description,
1321 SnippetFormField::DefaultHosts => "",
1323 }
1324 }
1325
1326 pub fn focused_value_mut(&mut self) -> Option<&mut String> {
1329 match self.focused_field {
1330 SnippetFormField::Name => Some(&mut self.name),
1331 SnippetFormField::Command => Some(&mut self.command),
1332 SnippetFormField::Description => Some(&mut self.description),
1333 SnippetFormField::DefaultHosts => None,
1334 }
1335 }
1336
1337 pub fn insert_char(&mut self, c: char) {
1338 let pos = self.cursor_pos;
1339 let Some(val) = self.focused_value_mut() else {
1340 return;
1341 };
1342 let byte_pos = char_to_byte_pos(val, pos);
1343 val.insert(byte_pos, c);
1344 self.cursor_pos = pos + 1;
1345 }
1346
1347 pub fn delete_char_before_cursor(&mut self) {
1348 if self.cursor_pos == 0 {
1349 return;
1350 }
1351 let pos = self.cursor_pos;
1352 let Some(val) = self.focused_value_mut() else {
1353 return;
1354 };
1355 let byte_pos = char_to_byte_pos(val, pos);
1356 let prev = char_to_byte_pos(val, pos - 1);
1357 val.drain(prev..byte_pos);
1358 self.cursor_pos = pos - 1;
1359 }
1360
1361 pub fn sync_cursor_to_end(&mut self) {
1362 self.cursor_pos = self.focused_value().chars().count();
1363 }
1364
1365 pub fn validate(&self) -> Result<(), String> {
1366 crate::snippet::validate_name(&self.name)?;
1367 crate::snippet::validate_command(&self.command)?;
1368 if self.description.contains(|c: char| c.is_control()) {
1369 return Err(crate::messages::SNIPPET_DESCRIPTION_CONTROL_CHARS.to_string());
1370 }
1371 Ok(())
1372 }
1373}
1374
1375#[derive(Debug, Clone)]
1377pub struct SnippetHostOutput {
1378 pub alias: String,
1379 pub stdout: String,
1380 pub stderr: String,
1381 pub exit_code: Option<i32>,
1382}
1383
1384#[derive(Debug, Clone)]
1386pub struct SnippetOutputState {
1387 pub run_id: u64,
1388 pub results: Vec<SnippetHostOutput>,
1389 pub scroll_offset: usize,
1390 pub completed: usize,
1391 pub total: usize,
1392 pub all_done: bool,
1393 pub cancel: std::sync::Arc<std::sync::atomic::AtomicBool>,
1394}
1395
1396#[derive(Debug, Clone)]
1398pub struct SnippetParamFormState {
1399 pub params: Vec<crate::snippet::SnippetParam>,
1400 pub values: Vec<String>,
1401 pub focused_index: usize,
1402 pub cursor_pos: usize,
1403 pub scroll_offset: usize,
1404 pub visible_count: usize,
1406}
1407
1408impl SnippetParamFormState {
1409 pub fn new(params: &[crate::snippet::SnippetParam]) -> Self {
1410 let values: Vec<String> = params
1411 .iter()
1412 .map(|p| p.default.clone().unwrap_or_default())
1413 .collect();
1414 let cursor_pos = values.first().map(|v| v.chars().count()).unwrap_or(0);
1415 Self {
1416 params: params.to_vec(),
1417 values,
1418 focused_index: 0,
1419 cursor_pos,
1420 scroll_offset: 0,
1421 visible_count: params.len().min(8),
1422 }
1423 }
1424
1425 pub fn insert_char(&mut self, c: char) {
1426 let idx = self.focused_index;
1427 let pos = self.cursor_pos;
1428 let Some(val) = self.values.get_mut(idx) else {
1429 return;
1430 };
1431 let byte_pos = char_to_byte_pos(val, pos);
1432 val.insert(byte_pos, c);
1433 self.cursor_pos = pos + 1;
1434 }
1435
1436 pub fn delete_char_before_cursor(&mut self) {
1437 if self.cursor_pos == 0 {
1438 return;
1439 }
1440 let idx = self.focused_index;
1441 let pos = self.cursor_pos;
1442 let Some(val) = self.values.get_mut(idx) else {
1443 return;
1444 };
1445 let byte_pos = char_to_byte_pos(val, pos);
1446 let prev = char_to_byte_pos(val, pos - 1);
1447 val.drain(prev..byte_pos);
1448 self.cursor_pos = pos - 1;
1449 }
1450
1451 pub fn values_map(&self) -> HashMap<String, String> {
1453 self.params
1454 .iter()
1455 .enumerate()
1456 .map(|(i, p)| (p.name.clone(), self.values[i].clone()))
1457 .collect()
1458 }
1459
1460 pub fn is_dirty(&self) -> bool {
1462 self.params.iter().enumerate().any(|(i, p)| {
1463 let default = p.default.as_deref().unwrap_or("");
1464 self.values[i] != default
1465 })
1466 }
1467}
1468
1469#[cfg(test)]
1470mod host_form_method_tests {
1471 use super::*;
1472 use crate::quick_add::ParsedTarget;
1473
1474 #[test]
1475 fn apply_password_source_sets_value_and_keeps_focus_when_refocus_false() {
1476 let mut f = HostForm::new();
1477 f.focused_field = FormField::Alias;
1478 f.apply_password_source("vault:secret/ssh#pw".to_string(), false);
1479 assert_eq!(f.askpass, "vault:secret/ssh#pw");
1480 assert_eq!(f.focused_field, FormField::Alias);
1481 assert_eq!(f.cursor_pos, "vault:secret/ssh#pw".chars().count());
1482 }
1483
1484 #[test]
1485 fn apply_password_source_moves_focus_to_askpass_when_refocus_true() {
1486 let mut f = HostForm::new();
1487 f.focused_field = FormField::Hostname;
1488 f.apply_password_source("op://Vault/Item/pw".to_string(), true);
1489 assert_eq!(f.askpass, "op://Vault/Item/pw");
1490 assert_eq!(f.focused_field, FormField::AskPass);
1491 assert_eq!(f.cursor_pos, "op://Vault/Item/pw".chars().count());
1492 }
1493
1494 #[test]
1495 fn apply_password_source_clears_askpass_with_empty_value() {
1496 let mut f = HostForm::new();
1497 f.askpass = "old".into();
1498 f.focused_field = FormField::AskPass;
1499 f.apply_password_source(String::new(), false);
1500 assert_eq!(f.askpass, "");
1501 assert_eq!(f.cursor_pos, 0);
1502 }
1503
1504 #[test]
1505 fn apply_smart_paste_fills_empty_fields_and_overwrites_alias() {
1506 let mut f = HostForm::new();
1507 f.alias = "user@host:2222".into();
1508 let parsed = ParsedTarget {
1509 user: "alice".into(),
1510 hostname: "db.example.com".into(),
1511 port: 2222,
1512 };
1513 f.apply_smart_paste(parsed, "db".into());
1514 assert_eq!(f.alias, "db");
1515 assert_eq!(f.hostname, "db.example.com");
1516 assert_eq!(f.user, "alice");
1517 assert_eq!(f.port, "2222");
1518 }
1519
1520 #[test]
1521 fn apply_smart_paste_does_not_overwrite_non_empty_hostname() {
1522 let mut f = HostForm::new();
1523 f.hostname = "existing.com".into();
1524 let parsed = ParsedTarget {
1525 user: "u".into(),
1526 hostname: "parsed.com".into(),
1527 port: 22,
1528 };
1529 f.apply_smart_paste(parsed, "x".into());
1530 assert_eq!(f.hostname, "existing.com");
1531 }
1532
1533 #[test]
1534 fn apply_smart_paste_does_not_overwrite_non_empty_user() {
1535 let mut f = HostForm::new();
1536 f.user = "bob".into();
1537 let parsed = ParsedTarget {
1538 user: "alice".into(),
1539 hostname: "host".into(),
1540 port: 22,
1541 };
1542 f.apply_smart_paste(parsed, "x".into());
1543 assert_eq!(f.user, "bob");
1544 }
1545
1546 #[test]
1547 fn apply_smart_paste_keeps_default_port_when_parsed_port_is_default() {
1548 let mut f = HostForm::new();
1549 let parsed = ParsedTarget {
1550 user: "u".into(),
1551 hostname: "h".into(),
1552 port: 22,
1553 };
1554 f.apply_smart_paste(parsed, "x".into());
1555 assert_eq!(f.port, "22");
1556 }
1557
1558 #[test]
1559 fn apply_smart_paste_does_not_overwrite_user_when_parsed_user_is_empty() {
1560 let mut f = HostForm::new();
1561 let parsed = ParsedTarget {
1562 user: String::new(),
1563 hostname: "h".into(),
1564 port: 22,
1565 };
1566 f.apply_smart_paste(parsed, "x".into());
1567 assert_eq!(f.user, "");
1568 }
1569
1570 #[test]
1571 fn apply_smart_paste_keeps_user_custom_port_even_when_parsed_port_differs() {
1572 let mut f = HostForm::new();
1573 f.port = "8022".into();
1574 let parsed = ParsedTarget {
1575 user: "u".into(),
1576 hostname: "h".into(),
1577 port: 2222,
1578 };
1579 f.apply_smart_paste(parsed, "x".into());
1580 assert_eq!(f.port, "8022");
1581 }
1582
1583 #[test]
1584 fn apply_password_source_empty_value_with_refocus_moves_focus_and_zeros_cursor() {
1585 let mut f = HostForm::new();
1586 f.focused_field = FormField::Hostname;
1587 f.apply_password_source(String::new(), true);
1588 assert_eq!(f.askpass, "");
1589 assert_eq!(f.focused_field, FormField::AskPass);
1590 assert_eq!(f.cursor_pos, 0);
1591 }
1592
1593 #[test]
1594 fn tunnel_form_focus_next_from_type_goes_to_bind_port_and_resets_cursor() {
1595 let mut f = TunnelForm::new();
1596 f.bind_port = "8080".into();
1597 f.cursor_pos = 99;
1598 assert_eq!(f.focused_field, TunnelFormField::Type);
1599 f.focus_next();
1600 assert_eq!(f.focused_field, TunnelFormField::BindPort);
1601 assert_eq!(f.cursor_pos, 4);
1603 }
1604
1605 #[test]
1606 fn tunnel_form_focus_next_wraps_from_remote_port_back_to_type() {
1607 let mut f = TunnelForm::new();
1608 f.focused_field = TunnelFormField::RemotePort;
1609 f.remote_port = "443".into();
1613 f.cursor_pos = 99;
1614 f.focus_next();
1615 assert_eq!(f.focused_field, TunnelFormField::Type);
1616 assert_eq!(f.cursor_pos, 0);
1617 }
1618
1619 #[test]
1620 fn tunnel_form_focus_next_dynamic_skips_remote_fields() {
1621 let mut f = TunnelForm::new();
1622 f.tunnel_type = TunnelType::Dynamic;
1623 f.focused_field = TunnelFormField::BindPort;
1624 f.bind_port = "8080".into();
1627 f.cursor_pos = 99;
1628 f.focus_next();
1629 assert_eq!(f.focused_field, TunnelFormField::Type);
1631 assert_eq!(f.cursor_pos, 0);
1632 }
1633
1634 #[test]
1635 fn tunnel_form_focus_prev_from_bind_port_goes_to_type() {
1636 let mut f = TunnelForm::new();
1637 f.focused_field = TunnelFormField::BindPort;
1638 f.bind_port = "8080".into();
1641 f.cursor_pos = 99;
1642 f.focus_prev();
1643 assert_eq!(f.focused_field, TunnelFormField::Type);
1644 assert_eq!(f.cursor_pos, 0);
1645 }
1646
1647 #[test]
1648 fn tunnel_form_focus_next_preserves_field_values_and_tunnel_type() {
1649 let mut f = TunnelForm {
1650 tunnel_type: TunnelType::Local,
1651 bind_port: "1234".into(),
1652 remote_host: "example.com".into(),
1653 remote_port: "5678".into(),
1654 bind_address: "127.0.0.1".into(),
1655 focused_field: TunnelFormField::Type,
1656 cursor_pos: 0,
1657 };
1658 f.focus_next();
1659 assert_eq!(f.bind_port, "1234");
1660 assert_eq!(f.remote_host, "example.com");
1661 assert_eq!(f.remote_port, "5678");
1662 assert_eq!(f.bind_address, "127.0.0.1");
1663 assert_eq!(f.tunnel_type, TunnelType::Local);
1664 }
1665
1666 #[test]
1667 fn snippet_form_focus_next_advances_field_and_syncs_cursor_to_target() {
1668 let mut f = SnippetForm::new();
1669 f.name = "abc".into();
1672 f.command = "de".into();
1673 f.focused_field = SnippetFormField::Name;
1674 f.cursor_pos = 99;
1675 f.focus_next();
1676 assert_eq!(f.focused_field, SnippetFormField::Command);
1677 assert_eq!(f.cursor_pos, 2);
1678 }
1679
1680 #[test]
1681 fn snippet_form_focus_prev_retreats_field_and_syncs_cursor_to_target() {
1682 let mut f = SnippetForm::new();
1683 f.name = "xyz".into();
1686 f.command = "ab".into();
1687 f.focused_field = SnippetFormField::Command;
1688 f.cursor_pos = 99;
1689 f.focus_prev();
1690 assert_eq!(f.focused_field, SnippetFormField::Name);
1691 assert_eq!(f.cursor_pos, 3);
1692 }
1693
1694 #[test]
1695 fn snippet_form_focus_next_preserves_field_values() {
1696 let mut f = SnippetForm::new();
1697 f.name = "foo".into();
1698 f.command = "bar".into();
1699 f.description = "baz".into();
1700 f.focus_next();
1701 assert_eq!(f.name, "foo");
1702 assert_eq!(f.command, "bar");
1703 assert_eq!(f.description, "baz");
1704 }
1705}