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