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