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}
1199
1200impl SnippetFormField {
1201 pub const ALL: &[SnippetFormField] = &[
1202 SnippetFormField::Name,
1203 SnippetFormField::Command,
1204 SnippetFormField::Description,
1205 ];
1206
1207 pub fn next(self) -> Self {
1208 let idx = Self::ALL.iter().position(|f| *f == self).unwrap_or(0);
1209 Self::ALL[(idx + 1) % Self::ALL.len()]
1210 }
1211
1212 pub fn prev(self) -> Self {
1213 let idx = Self::ALL.iter().position(|f| *f == self).unwrap_or(0);
1214 Self::ALL[(idx + Self::ALL.len() - 1) % Self::ALL.len()]
1215 }
1216
1217 pub fn label(self) -> &'static str {
1218 match self {
1219 SnippetFormField::Name => "Name",
1220 SnippetFormField::Command => "Command",
1221 SnippetFormField::Description => "Description",
1222 }
1223 }
1224}
1225
1226#[derive(Debug, Clone)]
1228pub struct SnippetForm {
1229 pub name: String,
1230 pub command: String,
1231 pub description: String,
1232 pub focused_field: SnippetFormField,
1233 pub cursor_pos: usize,
1234}
1235
1236impl SnippetForm {
1237 pub(crate) fn new() -> Self {
1238 Self {
1239 name: String::new(),
1240 command: String::new(),
1241 description: String::new(),
1242 focused_field: SnippetFormField::Name,
1243 cursor_pos: 0,
1244 }
1245 }
1246
1247 pub fn from_snippet(snippet: &crate::snippet::Snippet) -> Self {
1248 Self {
1249 name: snippet.name.clone(),
1250 command: snippet.command.clone(),
1251 description: snippet.description.clone(),
1252 focused_field: SnippetFormField::Name,
1253 cursor_pos: snippet.name.chars().count(),
1254 }
1255 }
1256
1257 pub fn focus_next(&mut self) {
1260 self.focused_field = self.focused_field.next();
1261 self.sync_cursor_to_end();
1262 }
1263
1264 pub fn focus_prev(&mut self) {
1267 self.focused_field = self.focused_field.prev();
1268 self.sync_cursor_to_end();
1269 }
1270
1271 pub fn focused_value(&self) -> &str {
1272 match self.focused_field {
1273 SnippetFormField::Name => &self.name,
1274 SnippetFormField::Command => &self.command,
1275 SnippetFormField::Description => &self.description,
1276 }
1277 }
1278
1279 pub fn focused_value_mut(&mut self) -> &mut String {
1280 match self.focused_field {
1281 SnippetFormField::Name => &mut self.name,
1282 SnippetFormField::Command => &mut self.command,
1283 SnippetFormField::Description => &mut self.description,
1284 }
1285 }
1286
1287 pub fn insert_char(&mut self, c: char) {
1288 let pos = self.cursor_pos;
1289 let val = self.focused_value_mut();
1290 let byte_pos = char_to_byte_pos(val, pos);
1291 val.insert(byte_pos, c);
1292 self.cursor_pos = pos + 1;
1293 }
1294
1295 pub fn delete_char_before_cursor(&mut self) {
1296 if self.cursor_pos == 0 {
1297 return;
1298 }
1299 let pos = self.cursor_pos;
1300 let val = self.focused_value_mut();
1301 let byte_pos = char_to_byte_pos(val, pos);
1302 let prev = char_to_byte_pos(val, pos - 1);
1303 val.drain(prev..byte_pos);
1304 self.cursor_pos = pos - 1;
1305 }
1306
1307 pub fn sync_cursor_to_end(&mut self) {
1308 self.cursor_pos = self.focused_value().chars().count();
1309 }
1310
1311 pub fn validate(&self) -> Result<(), String> {
1312 crate::snippet::validate_name(&self.name)?;
1313 crate::snippet::validate_command(&self.command)?;
1314 if self.description.contains(|c: char| c.is_control()) {
1315 return Err(crate::messages::SNIPPET_DESCRIPTION_CONTROL_CHARS.to_string());
1316 }
1317 Ok(())
1318 }
1319}
1320
1321#[derive(Debug, Clone)]
1323pub struct SnippetHostOutput {
1324 pub alias: String,
1325 pub stdout: String,
1326 pub stderr: String,
1327 pub exit_code: Option<i32>,
1328}
1329
1330#[derive(Debug, Clone)]
1332pub struct SnippetOutputState {
1333 pub run_id: u64,
1334 pub results: Vec<SnippetHostOutput>,
1335 pub scroll_offset: usize,
1336 pub completed: usize,
1337 pub total: usize,
1338 pub all_done: bool,
1339 pub cancel: std::sync::Arc<std::sync::atomic::AtomicBool>,
1340}
1341
1342#[derive(Debug, Clone)]
1344pub struct SnippetParamFormState {
1345 pub params: Vec<crate::snippet::SnippetParam>,
1346 pub values: Vec<String>,
1347 pub focused_index: usize,
1348 pub cursor_pos: usize,
1349 pub scroll_offset: usize,
1350 pub visible_count: usize,
1352}
1353
1354impl SnippetParamFormState {
1355 pub fn new(params: &[crate::snippet::SnippetParam]) -> Self {
1356 let values: Vec<String> = params
1357 .iter()
1358 .map(|p| p.default.clone().unwrap_or_default())
1359 .collect();
1360 let cursor_pos = values.first().map(|v| v.chars().count()).unwrap_or(0);
1361 Self {
1362 params: params.to_vec(),
1363 values,
1364 focused_index: 0,
1365 cursor_pos,
1366 scroll_offset: 0,
1367 visible_count: params.len().min(8),
1368 }
1369 }
1370
1371 pub fn insert_char(&mut self, c: char) {
1372 let idx = self.focused_index;
1373 let pos = self.cursor_pos;
1374 let val = &mut self.values[idx];
1375 let byte_pos = char_to_byte_pos(val, pos);
1376 val.insert(byte_pos, c);
1377 self.cursor_pos = pos + 1;
1378 }
1379
1380 pub fn delete_char_before_cursor(&mut self) {
1381 if self.cursor_pos == 0 {
1382 return;
1383 }
1384 let idx = self.focused_index;
1385 let pos = self.cursor_pos;
1386 let val = &mut self.values[idx];
1387 let byte_pos = char_to_byte_pos(val, pos);
1388 let prev = char_to_byte_pos(val, pos - 1);
1389 val.drain(prev..byte_pos);
1390 self.cursor_pos = pos - 1;
1391 }
1392
1393 pub fn values_map(&self) -> HashMap<String, String> {
1395 self.params
1396 .iter()
1397 .enumerate()
1398 .map(|(i, p)| (p.name.clone(), self.values[i].clone()))
1399 .collect()
1400 }
1401
1402 pub fn is_dirty(&self) -> bool {
1404 self.params.iter().enumerate().any(|(i, p)| {
1405 let default = p.default.as_deref().unwrap_or("");
1406 self.values[i] != default
1407 })
1408 }
1409}
1410
1411#[cfg(test)]
1412mod host_form_method_tests {
1413 use super::*;
1414 use crate::quick_add::ParsedTarget;
1415
1416 #[test]
1417 fn apply_password_source_sets_value_and_keeps_focus_when_refocus_false() {
1418 let mut f = HostForm::new();
1419 f.focused_field = FormField::Alias;
1420 f.apply_password_source("vault:secret/ssh#pw".to_string(), false);
1421 assert_eq!(f.askpass, "vault:secret/ssh#pw");
1422 assert_eq!(f.focused_field, FormField::Alias);
1423 assert_eq!(f.cursor_pos, "vault:secret/ssh#pw".chars().count());
1424 }
1425
1426 #[test]
1427 fn apply_password_source_moves_focus_to_askpass_when_refocus_true() {
1428 let mut f = HostForm::new();
1429 f.focused_field = FormField::Hostname;
1430 f.apply_password_source("op://Vault/Item/pw".to_string(), true);
1431 assert_eq!(f.askpass, "op://Vault/Item/pw");
1432 assert_eq!(f.focused_field, FormField::AskPass);
1433 assert_eq!(f.cursor_pos, "op://Vault/Item/pw".chars().count());
1434 }
1435
1436 #[test]
1437 fn apply_password_source_clears_askpass_with_empty_value() {
1438 let mut f = HostForm::new();
1439 f.askpass = "old".into();
1440 f.focused_field = FormField::AskPass;
1441 f.apply_password_source(String::new(), false);
1442 assert_eq!(f.askpass, "");
1443 assert_eq!(f.cursor_pos, 0);
1444 }
1445
1446 #[test]
1447 fn apply_smart_paste_fills_empty_fields_and_overwrites_alias() {
1448 let mut f = HostForm::new();
1449 f.alias = "user@host:2222".into();
1450 let parsed = ParsedTarget {
1451 user: "alice".into(),
1452 hostname: "db.example.com".into(),
1453 port: 2222,
1454 };
1455 f.apply_smart_paste(parsed, "db".into());
1456 assert_eq!(f.alias, "db");
1457 assert_eq!(f.hostname, "db.example.com");
1458 assert_eq!(f.user, "alice");
1459 assert_eq!(f.port, "2222");
1460 }
1461
1462 #[test]
1463 fn apply_smart_paste_does_not_overwrite_non_empty_hostname() {
1464 let mut f = HostForm::new();
1465 f.hostname = "existing.com".into();
1466 let parsed = ParsedTarget {
1467 user: "u".into(),
1468 hostname: "parsed.com".into(),
1469 port: 22,
1470 };
1471 f.apply_smart_paste(parsed, "x".into());
1472 assert_eq!(f.hostname, "existing.com");
1473 }
1474
1475 #[test]
1476 fn apply_smart_paste_does_not_overwrite_non_empty_user() {
1477 let mut f = HostForm::new();
1478 f.user = "bob".into();
1479 let parsed = ParsedTarget {
1480 user: "alice".into(),
1481 hostname: "host".into(),
1482 port: 22,
1483 };
1484 f.apply_smart_paste(parsed, "x".into());
1485 assert_eq!(f.user, "bob");
1486 }
1487
1488 #[test]
1489 fn apply_smart_paste_keeps_default_port_when_parsed_port_is_default() {
1490 let mut f = HostForm::new();
1491 let parsed = ParsedTarget {
1492 user: "u".into(),
1493 hostname: "h".into(),
1494 port: 22,
1495 };
1496 f.apply_smart_paste(parsed, "x".into());
1497 assert_eq!(f.port, "22");
1498 }
1499
1500 #[test]
1501 fn apply_smart_paste_does_not_overwrite_user_when_parsed_user_is_empty() {
1502 let mut f = HostForm::new();
1503 let parsed = ParsedTarget {
1504 user: String::new(),
1505 hostname: "h".into(),
1506 port: 22,
1507 };
1508 f.apply_smart_paste(parsed, "x".into());
1509 assert_eq!(f.user, "");
1510 }
1511
1512 #[test]
1513 fn apply_smart_paste_keeps_user_custom_port_even_when_parsed_port_differs() {
1514 let mut f = HostForm::new();
1515 f.port = "8022".into();
1516 let parsed = ParsedTarget {
1517 user: "u".into(),
1518 hostname: "h".into(),
1519 port: 2222,
1520 };
1521 f.apply_smart_paste(parsed, "x".into());
1522 assert_eq!(f.port, "8022");
1523 }
1524
1525 #[test]
1526 fn apply_password_source_empty_value_with_refocus_moves_focus_and_zeros_cursor() {
1527 let mut f = HostForm::new();
1528 f.focused_field = FormField::Hostname;
1529 f.apply_password_source(String::new(), true);
1530 assert_eq!(f.askpass, "");
1531 assert_eq!(f.focused_field, FormField::AskPass);
1532 assert_eq!(f.cursor_pos, 0);
1533 }
1534
1535 #[test]
1536 fn tunnel_form_focus_next_from_type_goes_to_bind_port_and_resets_cursor() {
1537 let mut f = TunnelForm::new();
1538 f.bind_port = "8080".into();
1539 f.cursor_pos = 99;
1540 assert_eq!(f.focused_field, TunnelFormField::Type);
1541 f.focus_next();
1542 assert_eq!(f.focused_field, TunnelFormField::BindPort);
1543 assert_eq!(f.cursor_pos, 4);
1545 }
1546
1547 #[test]
1548 fn tunnel_form_focus_next_wraps_from_remote_port_back_to_type() {
1549 let mut f = TunnelForm::new();
1550 f.focused_field = TunnelFormField::RemotePort;
1551 f.remote_port = "443".into();
1555 f.cursor_pos = 99;
1556 f.focus_next();
1557 assert_eq!(f.focused_field, TunnelFormField::Type);
1558 assert_eq!(f.cursor_pos, 0);
1559 }
1560
1561 #[test]
1562 fn tunnel_form_focus_next_dynamic_skips_remote_fields() {
1563 let mut f = TunnelForm::new();
1564 f.tunnel_type = TunnelType::Dynamic;
1565 f.focused_field = TunnelFormField::BindPort;
1566 f.bind_port = "8080".into();
1569 f.cursor_pos = 99;
1570 f.focus_next();
1571 assert_eq!(f.focused_field, TunnelFormField::Type);
1573 assert_eq!(f.cursor_pos, 0);
1574 }
1575
1576 #[test]
1577 fn tunnel_form_focus_prev_from_bind_port_goes_to_type() {
1578 let mut f = TunnelForm::new();
1579 f.focused_field = TunnelFormField::BindPort;
1580 f.bind_port = "8080".into();
1583 f.cursor_pos = 99;
1584 f.focus_prev();
1585 assert_eq!(f.focused_field, TunnelFormField::Type);
1586 assert_eq!(f.cursor_pos, 0);
1587 }
1588
1589 #[test]
1590 fn tunnel_form_focus_next_preserves_field_values_and_tunnel_type() {
1591 let mut f = TunnelForm {
1592 tunnel_type: TunnelType::Local,
1593 bind_port: "1234".into(),
1594 remote_host: "example.com".into(),
1595 remote_port: "5678".into(),
1596 bind_address: "127.0.0.1".into(),
1597 focused_field: TunnelFormField::Type,
1598 cursor_pos: 0,
1599 };
1600 f.focus_next();
1601 assert_eq!(f.bind_port, "1234");
1602 assert_eq!(f.remote_host, "example.com");
1603 assert_eq!(f.remote_port, "5678");
1604 assert_eq!(f.bind_address, "127.0.0.1");
1605 assert_eq!(f.tunnel_type, TunnelType::Local);
1606 }
1607
1608 #[test]
1609 fn snippet_form_focus_next_advances_field_and_syncs_cursor_to_target() {
1610 let mut f = SnippetForm::new();
1611 f.name = "abc".into();
1614 f.command = "de".into();
1615 f.focused_field = SnippetFormField::Name;
1616 f.cursor_pos = 99;
1617 f.focus_next();
1618 assert_eq!(f.focused_field, SnippetFormField::Command);
1619 assert_eq!(f.cursor_pos, 2);
1620 }
1621
1622 #[test]
1623 fn snippet_form_focus_prev_retreats_field_and_syncs_cursor_to_target() {
1624 let mut f = SnippetForm::new();
1625 f.name = "xyz".into();
1628 f.command = "ab".into();
1629 f.focused_field = SnippetFormField::Command;
1630 f.cursor_pos = 99;
1631 f.focus_prev();
1632 assert_eq!(f.focused_field, SnippetFormField::Name);
1633 assert_eq!(f.cursor_pos, 3);
1634 }
1635
1636 #[test]
1637 fn snippet_form_focus_next_preserves_field_values() {
1638 let mut f = SnippetForm::new();
1639 f.name = "foo".into();
1640 f.command = "bar".into();
1641 f.description = "baz".into();
1642 f.focus_next();
1643 assert_eq!(f.name, "foo");
1644 assert_eq!(f.command, "bar");
1645 assert_eq!(f.description, "baz");
1646 }
1647}