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