Skip to main content

purple_ssh/ui/
host_form.rs

1use ratatui::Frame;
2use ratatui::layout::Rect;
3use ratatui::text::{Line, Span};
4use ratatui::widgets::{Clear, ListItem, Paragraph};
5use unicode_width::UnicodeWidthStr;
6
7use super::design;
8use super::theme;
9use crate::app::{App, FormField, Screen};
10
11fn placeholder_for(
12    field: FormField,
13    is_pattern: bool,
14    paths: Option<&crate::runtime::env::Paths>,
15) -> String {
16    use crate::messages::hints;
17    match field {
18        FormField::AskPass => {
19            if let Some(default) = crate::preferences::load_askpass_default(paths) {
20                hints::askpass_default(&default)
21            } else {
22                hints::HOST_ASKPASS_PICK.to_string()
23            }
24        }
25        FormField::Alias if is_pattern => hints::HOST_ALIAS_PATTERN.to_string(),
26        FormField::Alias => hints::HOST_ALIAS.to_string(),
27        FormField::Hostname => hints::HOST_HOSTNAME.to_string(),
28        FormField::User => hints::DEFAULT_SSH_USER.to_string(),
29        FormField::Port => hints::HOST_PORT.to_string(),
30        FormField::IdentityFile => hints::IDENTITY_FILE_PICK.to_string(),
31        FormField::ProxyJump => hints::HOST_PROXY_JUMP.to_string(),
32        // SSH secrets engine role (signs SSH certificates). Distinct from
33        // Vault KV used in Password Source (vault:path/to/secret).
34        FormField::VaultSsh => hints::HOST_VAULT_SSH.to_string(),
35        FormField::VaultAddr => hints::HOST_VAULT_ADDR.to_string(),
36        FormField::Tags => hints::HOST_TAGS.to_string(),
37    }
38}
39
40/// Required fields (always visible).
41const REQUIRED_FIELDS: &[(FormField, bool)] =
42    &[(FormField::Alias, true), (FormField::Hostname, true)];
43
44/// All fields in order: required first, then optional. `VaultAddr` lives
45/// immediately after `VaultSsh` and is progressively disclosed at render
46/// time by filtering against `HostForm::visible_fields()` — the constant
47/// keeps the full schema so dirty-check, baselines and non-render callers
48/// see a consistent ordering.
49const ALL_FIELDS: &[(FormField, bool)] = &[
50    (FormField::Alias, true),
51    (FormField::Hostname, true),
52    (FormField::User, false),
53    (FormField::Port, false),
54    (FormField::IdentityFile, false),
55    (FormField::VaultSsh, false),
56    (FormField::VaultAddr, false),
57    (FormField::ProxyJump, false),
58    (FormField::AskPass, false),
59    (FormField::Tags, false),
60];
61
62pub fn render(frame: &mut Frame, app: &mut App) {
63    // Determine visible fields based on progressive disclosure state.
64    // The Vault SSH Role override field follows the same expand/collapse rule
65    // as every other optional field: hidden in collapsed state, shown in
66    // expanded state. The Vault SSH Address field has an additional gate:
67    // it is only rendered when a Vault SSH Role is set on this form (the
68    // address is meaningless without a role, and hiding it keeps the form
69    // compact for the 99% of hosts that do not use Vault SSH).
70    let expanded = app.forms.host().expanded;
71    let role_set = !app.forms.host().vault_ssh.trim().is_empty();
72    let base: &[(FormField, bool)] = if expanded {
73        ALL_FIELDS
74    } else {
75        REQUIRED_FIELDS
76    };
77    let filtered: Vec<(FormField, bool)> = base
78        .iter()
79        .copied()
80        .filter(|(f, _)| *f != FormField::VaultAddr || role_set)
81        .collect();
82    let visible_fields: &[(FormField, bool)] = &filtered;
83    // Block: top(1) + fields * 2 (divider + content) + bottom(1)
84    let block_height = 2 + visible_fields.len() as u16 * 2;
85    let total_height = block_height + 1; // + footer
86
87    let form_area = design::overlay_area(frame, design::OVERLAY_W, design::OVERLAY_H, total_height);
88
89    let title = if app.forms.host().is_pattern {
90        match &app.screen {
91            Screen::AddHost => "Add Pattern".to_string(),
92            Screen::EditHost { alias } => {
93                let max_alias = (form_area.width as usize).saturating_sub(14);
94                let truncated = super::truncate(alias, max_alias);
95                format!("Edit: {}", truncated)
96            }
97            _ => "Pattern".to_string(),
98        }
99    } else {
100        match &app.screen {
101            Screen::AddHost => "Add New Host".to_string(),
102            Screen::EditHost { alias } => {
103                let max_alias = (form_area.width as usize).saturating_sub(12);
104                let truncated = super::truncate(alias, max_alias);
105                format!("Edit: {}", truncated)
106            }
107            _ => "Host".to_string(),
108        }
109    };
110    frame.render_widget(Clear, form_area);
111
112    let block_area = Rect::new(form_area.x, form_area.y, form_area.width, block_height);
113
114    let block = design::overlay_block(&title);
115    let inner = block.inner(block_area);
116    frame.render_widget(block, block_area);
117
118    // Suppress cursor when a picker overlay is visible above this form
119    let picker_open = app.ui.key_picker().open
120        || app.ui.proxyjump_picker().open
121        || app.ui.password_picker().open
122        || app.ui.vault_role_picker().open;
123    let has_vault_roles = !app.vault_role_candidates().is_empty();
124
125    // Compute provider vault role hint for the VaultSsh field placeholder
126    let vault_provider_hint: Option<(String, String)> =
127        if let Screen::EditHost { alias } = &app.screen {
128            app.hosts_state
129                .list()
130                .iter()
131                .find(|h| h.alias == *alias)
132                .and_then(|h| h.provider.as_ref())
133                .and_then(|prov| {
134                    app.providers.config().section(prov).and_then(|s| {
135                        if s.vault_role.is_empty() {
136                            None
137                        } else {
138                            Some((s.vault_role.clone(), prov.clone()))
139                        }
140                    })
141                })
142        } else {
143            None
144        };
145
146    // Symmetric hint for the VaultAddr field: show the provider default
147    // address (if any) when the host-level field is empty, so the user
148    // knows a provider default is already in play without having to save
149    // and re-open the detail panel to find out.
150    let vault_addr_provider_hint: Option<(String, String)> =
151        if let Screen::EditHost { alias } = &app.screen {
152            app.hosts_state
153                .list()
154                .iter()
155                .find(|h| h.alias == *alias)
156                .and_then(|h| h.provider.as_ref())
157                .and_then(|prov| {
158                    app.providers.config().section(prov).and_then(|s| {
159                        if s.vault_addr.is_empty() {
160                            None
161                        } else {
162                            Some((s.vault_addr.clone(), prov.clone()))
163                        }
164                    })
165                })
166        } else {
167            None
168        };
169
170    for (idx, &(field, field_required)) in visible_fields.iter().enumerate() {
171        let divider_y = design::form_divider_y(inner, idx);
172        let content_y = divider_y + 1;
173
174        let is_focused = app.forms.host().focused_field == field;
175        let label_style = if is_focused {
176            theme::accent_bold()
177        } else {
178            theme::muted()
179        };
180        let field_label = if app.forms.host().is_pattern && field == FormField::Alias {
181            "Pattern"
182        } else {
183            field.label()
184        };
185        let is_required = if app.forms.host().is_pattern && field == FormField::Hostname {
186            false
187        } else {
188            field_required
189        };
190        let label = if is_required {
191            format!(" {}* ", field_label)
192        } else {
193            format!(" {} ", field_label)
194        };
195        super::render_divider(
196            frame,
197            block_area,
198            divider_y,
199            &label,
200            label_style,
201            theme::border_dim(),
202        );
203
204        let content_area = Rect::new(inner.x + 1, content_y, inner.width.saturating_sub(1), 1);
205        render_field_content(
206            frame,
207            content_area,
208            field,
209            app.forms.host(),
210            picker_open,
211            vault_provider_hint.as_ref(),
212            vault_addr_provider_hint.as_ref(),
213            has_vault_roles,
214            app.env().paths(),
215        );
216    }
217
218    // Footer below the block. Discard prompt takes precedence; otherwise the
219    // dynamic form-save footer reflects the focused field's kind (text /
220    // toggle / picker) so users discover Space-pick on picker fields.
221    let footer_area = design::render_overlay_footer(frame, block_area);
222    if app.forms.is_discard_pending() {
223        design::render_discard_prompt(frame, footer_area, app);
224    } else {
225        let mode = if !expanded {
226            design::FormFooterMode::Collapsed
227        } else {
228            design::FormFooterMode::Expanded(app.forms.host().focused_field.kind())
229        };
230        let mut footer_spans = design::form_save_footer(mode).into_spans();
231        if let Some(ref hint) = app.forms.host().form_hint {
232            let hint_width: usize = hint.width() + 4; // " ⚠ {hint} "
233            let shortcuts_width: usize = footer_spans.iter().map(|s| s.width()).sum();
234            let total = footer_area.width as usize;
235            let gap = total.saturating_sub(shortcuts_width + hint_width);
236            if gap > 0 {
237                footer_spans.push(Span::raw(" ".repeat(gap)));
238                footer_spans.push(Span::styled(
239                    format!("{} {} ", design::ICON_WARNING, hint),
240                    theme::warning(),
241                ));
242            }
243            // Hint takes the right-hand status slot, so render directly to
244            // avoid double status overlay.
245            frame.render_widget(Paragraph::new(Line::from(footer_spans)), footer_area);
246        } else {
247            super::render_footer_with_status(frame, footer_area, footer_spans, app);
248        }
249    }
250
251    // Key picker popup overlay
252    if app.ui.key_picker().open {
253        render_key_picker_overlay(frame, app);
254    }
255
256    // ProxyJump picker popup overlay
257    if app.ui.proxyjump_picker().open {
258        render_proxyjump_picker_overlay(frame, app);
259    }
260
261    // Password source picker popup overlay
262    if app.ui.password_picker().open {
263        render_password_picker_overlay(frame, app);
264    }
265
266    // Vault role picker popup overlay
267    if app.ui.vault_role_picker().open {
268        render_vault_role_picker_overlay(frame, app);
269    }
270}
271
272/// Render the key picker popup overlay. Public for reuse from provider form.
273pub fn render_key_picker_overlay(frame: &mut Frame, app: &mut App) {
274    if app.keys.list().is_empty() {
275        super::render_picker_empty_overlay(frame, "Select Key", "No keys found in ~/.ssh/");
276        return;
277    }
278
279    // Use the canonical picker width range (60..=72) so the Key picker
280    // looks identical to every other picker that opens from this form.
281    // The 3-column layout (NAME, TYPE, COMMENT) trades comment-column
282    // width for visual consistency: long comments are truncated rather
283    // than widening the overlay.
284    let width = super::picker_overlay_width(frame);
285    // Inner usable: width − 2 borders − 2 highlight gutter − 1 leading
286    // space − 1 trailing margin = width − 6.
287    let usable = (width as usize).saturating_sub(6);
288    let gap: usize = design::COL_GAP as usize;
289
290    let name_w = design::padded_usize(
291        app.keys
292            .list()
293            .iter()
294            .map(|k| k.name.len())
295            .max()
296            .unwrap_or(4)
297            .max(4),
298    );
299    let type_w = design::padded_usize(
300        app.keys
301            .list()
302            .iter()
303            .map(|k| k.type_display().len())
304            .max()
305            .unwrap_or(4)
306            .max(4),
307    );
308    let left = name_w + gap + type_w;
309    let comment_w = usable.saturating_sub(left + gap);
310    let gap_str = design::COL_GAP_STR;
311
312    let items: Vec<ListItem> = app
313        .keys
314        .list()
315        .iter()
316        .map(|key| {
317            let type_display = key.type_display();
318            let comment = if key.comment.is_empty() {
319                String::new()
320            } else {
321                super::truncate(&key.comment, comment_w.saturating_sub(1))
322            };
323            let mut spans = vec![
324                Span::styled(format!(" {:<name_w$}", key.name), theme::bold()),
325                Span::raw(gap_str),
326                Span::styled(format!("{:<type_w$}", type_display), theme::muted()),
327            ];
328            if comment_w > 0 {
329                spans.push(Span::raw(gap_str));
330                spans.push(Span::styled(comment, theme::muted()));
331            }
332            let line = Line::from(spans);
333            ListItem::new(line)
334        })
335        .collect();
336
337    super::render_picker_overlay(
338        frame,
339        "Select Key",
340        None,
341        items,
342        &mut app.ui.key_picker_mut().list,
343    );
344}
345
346fn render_proxyjump_picker_overlay(frame: &mut Frame, app: &mut App) {
347    let candidates = app.proxyjump_candidates();
348
349    if candidates.is_empty() {
350        super::render_picker_empty_overlay(frame, "ProxyJump", "No other hosts configured");
351        return;
352    }
353
354    let width = super::picker_overlay_width(frame);
355    // Row content width used by all items (Host, SectionLabel,
356    // Separator). Matches the password picker's `inner_width` so both
357    // overlays right-align their secondary column against the same
358    // visual edge: overlay width − 2 borders − 2 highlight-gutter − 1
359    // leading space − 1 trailing margin.
360    let inner = (width as usize).saturating_sub(6);
361    let alias_col = 20;
362    let min_gap = 2;
363    let host_max = inner.saturating_sub(alias_col + min_gap);
364
365    let items: Vec<ListItem> = candidates
366        .iter()
367        .map(|candidate| match candidate {
368            crate::app::ProxyJumpCandidate::SectionLabel(label) => ListItem::new(Line::from(
369                Span::styled(format!("  {}", label.to_ascii_uppercase()), theme::muted()),
370            )),
371            crate::app::ProxyJumpCandidate::Separator => ListItem::new(Line::from(Span::styled(
372                // Two leading spaces to match the SectionLabel indent,
373                // then dashes that span the remainder of `inner` so
374                // the separator has the same visual width as a Host
375                // row.
376                "  ".to_string() + &"─".repeat(inner.saturating_sub(2)),
377                theme::muted(),
378            ))),
379            crate::app::ProxyJumpCandidate::Host {
380                alias, hostname, ..
381            } => {
382                let alias_display = super::truncate(alias, alias_col);
383                let host_display = super::truncate(hostname, host_max);
384                // Right-align the hostname by padding the alias to
385                // consume the remainder of `inner`. Use the hostname's
386                // unicode display width (not `chars().count()`) so CJK
387                // and wide glyphs in a hostname do not overflow the
388                // right border. `alias_col` floors the padding so an
389                // unusually long hostname on a narrow terminal never
390                // collapses the alias column below its minimum width.
391                let host_width = host_display.width();
392                let alias_width = inner
393                    .saturating_sub(host_width)
394                    .saturating_sub(1)
395                    .max(alias_col);
396                let line = Line::from(vec![
397                    Span::styled(
398                        format!(" {:<width$}", alias_display, width = alias_width),
399                        theme::bold(),
400                    ),
401                    Span::styled(host_display, theme::muted()),
402                ]);
403                ListItem::new(line)
404            }
405        })
406        .collect();
407
408    super::render_picker_overlay(
409        frame,
410        "ProxyJump",
411        None,
412        items,
413        &mut app.ui.proxyjump_picker_mut().list,
414    );
415}
416
417fn render_vault_role_picker_overlay(frame: &mut Frame, app: &mut App) {
418    let candidates = app.vault_role_candidates();
419
420    let width = super::picker_overlay_width(frame);
421    let max_role = (width as usize).saturating_sub(6);
422    let items: Vec<ListItem> = candidates
423        .iter()
424        .map(|role| {
425            ListItem::new(Line::from(Span::styled(
426                format!("  {}", super::truncate(role, max_role)),
427                theme::bold(),
428            )))
429        })
430        .collect();
431
432    super::render_picker_overlay(
433        frame,
434        "Vault SSH Role",
435        None,
436        items,
437        &mut app.ui.vault_role_picker_mut().list,
438    );
439}
440
441fn render_password_picker_overlay(frame: &mut Frame, app: &mut App) {
442    let sources = crate::askpass::PASSWORD_SOURCES;
443    let width = super::picker_overlay_width(frame);
444    // Inner usable width = overlay width − 2 borders − highlight gutter (2)
445    // − left label pad (1) − one trailing space before the hint.
446    let inner_width = (width as usize).saturating_sub(6);
447    let items: Vec<ListItem> = sources
448        .iter()
449        .map(|src| {
450            let hint_width = src.hint.len();
451            let label_width = inner_width.saturating_sub(hint_width).saturating_sub(1);
452            let line = Line::from(vec![
453                Span::styled(
454                    format!(" {:<width$}", src.label, width = label_width),
455                    theme::bold(),
456                ),
457                Span::styled(src.hint, theme::muted()),
458            ]);
459            ListItem::new(line)
460        })
461        .collect();
462
463    super::render_picker_overlay(
464        frame,
465        "Password Source",
466        Some("Ctrl+D: global default"),
467        items,
468        &mut app.ui.password_picker_mut().list,
469    );
470}
471
472/// Get the placeholder text for a field (public for tests).
473#[cfg(test)]
474pub fn placeholder_text(field: FormField) -> String {
475    placeholder_for(field, false, None)
476}
477
478#[cfg(test)]
479pub fn placeholder_text_pattern(field: FormField) -> String {
480    placeholder_for(field, true, None)
481}
482
483/// Render a single field's content (value or placeholder) and set cursor.
484#[allow(clippy::too_many_arguments)]
485fn render_field_content(
486    frame: &mut Frame,
487    area: Rect,
488    field: FormField,
489    form: &crate::app::HostForm,
490    picker_open: bool,
491    vault_provider_hint: Option<&(String, String)>,
492    vault_addr_provider_hint: Option<&(String, String)>,
493    has_vault_roles: bool,
494    paths: Option<&crate::runtime::env::Paths>,
495) {
496    use crate::messages::hints;
497    let is_focused = form.focused_field == field;
498
499    let value = match field {
500        FormField::Alias => &form.alias,
501        FormField::Hostname => &form.hostname,
502        FormField::User => &form.user,
503        FormField::Port => &form.port,
504        FormField::IdentityFile => &form.identity_file,
505        FormField::ProxyJump => &form.proxy_jump,
506        FormField::AskPass => &form.askpass,
507        FormField::VaultSsh => &form.vault_ssh,
508        FormField::VaultAddr => &form.vault_addr,
509        FormField::Tags => &form.tags,
510    };
511
512    let is_picker = matches!(
513        field,
514        FormField::IdentityFile | FormField::ProxyJump | FormField::AskPass
515    ) || (field == FormField::VaultSsh && has_vault_roles);
516
517    // Inherited hint for this field (value + source pattern).
518    let inherited_hint = match field {
519        FormField::ProxyJump => form.inherited.proxy_jump.as_ref(),
520        FormField::User => form.inherited.user.as_ref(),
521        FormField::IdentityFile => form.inherited.identity_file.as_ref(),
522        _ => None,
523    };
524
525    // Inherited hints are shown regardless of focus (unlike input placeholders) because
526    // they are informational: they show the effective SSH config, not an input prompt.
527    let content = if let (true, Some((inh_val, inh_src))) = (value.is_empty(), inherited_hint) {
528        let inner_width = area.width as usize;
529        // Detect self-referencing ProxyJump loop.
530        let is_loop = field == FormField::ProxyJump
531            && crate::ssh_config::model::proxy_jump_contains_self(inh_val, &form.alias);
532        if is_loop {
533            let msg = format!("loops via {}", inh_src);
534            let display = super::truncate(&msg, inner_width);
535            Line::from(vec![Span::styled(display, theme::error())])
536        } else {
537            let source_suffix = format!("  \u{2190} {}", inh_src);
538            let val_budget = inner_width.saturating_sub(source_suffix.width());
539            let display = super::truncate(inh_val, val_budget);
540            if is_picker && is_focused {
541                let arrow_pos = inner_width.saturating_sub(1);
542                let used = display.width() + source_suffix.width();
543                let gap = arrow_pos.saturating_sub(used);
544                Line::from(vec![
545                    Span::styled(display, theme::muted()),
546                    Span::styled(source_suffix, theme::muted()),
547                    Span::raw(" ".repeat(gap)),
548                    Span::styled(design::PICKER_ARROW, theme::muted()),
549                ])
550            } else {
551                Line::from(vec![
552                    Span::styled(display, theme::muted()),
553                    Span::styled(source_suffix, theme::muted()),
554                ])
555            }
556        }
557    } else if let (true, FormField::VaultSsh, Some((role, prov))) =
558        (value.is_empty(), field, vault_provider_hint)
559    {
560        let hint = hints::inherits_from(role, prov);
561        Line::from(Span::styled(hint, theme::muted()))
562    } else if let (true, FormField::VaultAddr, Some((addr, prov))) =
563        (value.is_empty(), field, vault_addr_provider_hint)
564    {
565        let hint = hints::inherits_from(addr, prov);
566        Line::from(Span::styled(hint, theme::muted()))
567    } else if value.is_empty() && is_focused && !is_picker {
568        let ph = placeholder_for(field, form.is_pattern, paths);
569        Line::from(Span::styled(ph, theme::muted()))
570    } else if is_picker && is_focused {
571        let inner_width = area.width as usize;
572        let arrow_pos = inner_width.saturating_sub(1);
573        let (display, display_style) = if value.is_empty() {
574            let ph = if field == FormField::VaultSsh {
575                hints::HOST_VAULT_SSH_PICKER.to_string()
576            } else {
577                placeholder_for(field, form.is_pattern, paths)
578            };
579            (ph, theme::muted())
580        } else {
581            (value.to_string(), theme::bold())
582        };
583        let val_width = display.width();
584        let gap = arrow_pos.saturating_sub(val_width);
585        Line::from(vec![
586            Span::styled(display, display_style),
587            Span::raw(" ".repeat(gap)),
588            Span::styled(design::PICKER_ARROW, theme::muted()),
589        ])
590    } else if value.is_empty() {
591        Line::from(Span::raw(""))
592    } else {
593        Line::from(Span::styled(value.to_string(), theme::bold()))
594    };
595
596    frame.render_widget(Paragraph::new(content), area);
597
598    if is_focused && !picker_open {
599        let prefix: String = value.chars().take(form.cursor_pos).collect();
600        let cursor_x = area
601            .x
602            .saturating_add(prefix.width().min(u16::MAX as usize) as u16);
603        let cursor_y = area.y;
604        if area.width > 0 && cursor_x < area.x.saturating_add(area.width) {
605            frame.set_cursor_position((cursor_x, cursor_y));
606        }
607    }
608}
609
610#[cfg(test)]
611mod tests {
612    use super::*;
613    use ratatui::Terminal;
614    use ratatui::backend::TestBackend;
615
616    fn make_app() -> App {
617        let config = crate::ssh_config::model::SshConfigFile {
618            elements: crate::ssh_config::model::SshConfigFile::parse_content(""),
619            path: tempfile::tempdir()
620                .expect("tempdir")
621                .keep()
622                .join("test_config"),
623            crlf: false,
624            bom: false,
625        };
626        App::new(config)
627    }
628
629    fn buffer_dump(buf: &ratatui::buffer::Buffer) -> String {
630        let mut out = String::new();
631        for y in 0..buf.area.height {
632            for x in 0..buf.area.width {
633                out.push_str(buf[(x, y)].symbol());
634            }
635            out.push('\n');
636        }
637        out
638    }
639
640    /// Pin the wiring between `render_password_picker_overlay` and the
641    /// shared `render_picker_overlay` helper: the Ctrl+D affordance
642    /// must reach the rendered buffer via the title hint at this
643    /// specific callsite. A future edit that passes `None` or alters
644    /// the literal would fail this test.
645    #[test]
646    fn render_password_picker_overlay_shows_ctrl_d_hint_in_title() {
647        let backend = TestBackend::new(80, 20);
648        let mut terminal = Terminal::new(backend).unwrap();
649        let mut app = make_app();
650        app.ui.password_picker_mut().open_at(0);
651        terminal
652            .draw(|frame| {
653                render_password_picker_overlay(frame, &mut app);
654                let dump = buffer_dump(frame.buffer_mut());
655                assert!(
656                    dump.contains("Password Source · Ctrl+D: global default"),
657                    "password picker must surface Ctrl+D hint in title, got:\n{dump}"
658                );
659            })
660            .unwrap();
661    }
662
663    /// Negative assertion pinning the intent of the refactor: after
664    /// moving the Ctrl+D affordance to the title, no row of the
665    /// rendered overlay should contain a "Ctrl+D" footer. Prevents a
666    /// regression where a future edit accidentally re-introduces a
667    /// divergent per-picker footer.
668    #[test]
669    fn render_password_picker_overlay_has_no_footer_row_with_ctrl_d() {
670        let backend = TestBackend::new(80, 20);
671        let mut terminal = Terminal::new(backend).unwrap();
672        let mut app = make_app();
673        app.ui.password_picker_mut().open_at(0);
674        terminal
675            .draw(|frame| {
676                render_password_picker_overlay(frame, &mut app);
677                let buf = frame.buffer_mut();
678                // Only the title row (top border) may contain "Ctrl+D".
679                // Every other row must be free of that string so a
680                // footer reintroduction is caught immediately.
681                let mut title_row: Option<u16> = None;
682                for y in 0..buf.area.height {
683                    let mut row = String::new();
684                    for x in 0..buf.area.width {
685                        row.push_str(buf[(x, y)].symbol());
686                    }
687                    if row.contains("Password Source") {
688                        title_row = Some(y);
689                        break;
690                    }
691                }
692                let title_row = title_row.expect("title row must exist");
693                for y in 0..buf.area.height {
694                    if y == title_row {
695                        continue;
696                    }
697                    let mut row = String::new();
698                    for x in 0..buf.area.width {
699                        row.push_str(buf[(x, y)].symbol());
700                    }
701                    assert!(
702                        !row.contains("Ctrl+D"),
703                        "row {y} must not contain 'Ctrl+D' (footer regression): {row:?}"
704                    );
705                }
706            })
707            .unwrap();
708    }
709
710    /// Build a ProxyJump picker app with the given SSH config content,
711    /// open the edit screen for `editing_alias`, and select the first
712    /// available host in the picker. Returns an `App` ready for
713    /// `render_proxyjump_picker_overlay`.
714    fn proxyjump_picker_fixture(config_text: &str, editing_alias: &str) -> App {
715        let cfg = crate::ssh_config::model::SshConfigFile {
716            elements: crate::ssh_config::model::SshConfigFile::parse_content(config_text),
717            path: tempfile::tempdir()
718                .expect("tempdir")
719                .keep()
720                .join("test_config"),
721            crlf: false,
722            bom: false,
723        };
724        let mut app = App::new(cfg);
725        app.screen = Screen::EditHost {
726            alias: editing_alias.to_string(),
727        };
728        app.ui.proxyjump_picker_mut().open = true;
729        let idx = app.proxyjump_first_host_index();
730        app.ui.proxyjump_picker_mut().list.select(idx);
731        app
732    }
733
734    /// Locate the first row and column range containing `needle` in a
735    /// terminal buffer by scanning cell-by-cell. Returns (row, end_col)
736    /// where `end_col` is the inclusive column of the last cell of
737    /// the match. Avoids the byte-vs-column mismatch that comes from
738    /// `str::find` on a row with multi-byte border glyphs.
739    fn find_needle_in_buffer(
740        buf: &ratatui::buffer::Buffer,
741        needle: &str,
742    ) -> Option<(u16, u16, u16)> {
743        let chars: Vec<String> = needle.chars().map(|c| c.to_string()).collect();
744        let len = chars.len() as u16;
745        if len == 0 || buf.area.width < len {
746            return None;
747        }
748        for y in 0..buf.area.height {
749            for start_x in 0..=buf.area.width - len {
750                let matches = (0..len).all(|i| buf[(start_x + i, y)].symbol() == chars[i as usize]);
751                if matches {
752                    return Some((y, start_x, start_x + len - 1));
753                }
754            }
755        }
756        None
757    }
758
759    /// Return the rightmost border glyph column on the given row.
760    fn right_border_col(buf: &ratatui::buffer::Buffer, y: u16) -> Option<u16> {
761        for x in (0..buf.area.width).rev() {
762            let s = buf[(x, y)].symbol();
763            if s == "│" || s == "╮" || s == "╯" {
764                return Some(x);
765            }
766        }
767        None
768    }
769
770    /// The ProxyJump picker's hostname column should end at the right
771    /// edge of the inner content area, mirroring the password picker
772    /// layout. We verify this by locating the hostname substring on
773    /// its rendered row and checking that no non-space glyph follows
774    /// before the right border.
775    #[test]
776    fn render_proxyjump_picker_host_column_is_right_aligned() {
777        let backend = TestBackend::new(80, 20);
778        let mut terminal = Terminal::new(backend).unwrap();
779        let mut app = proxyjump_picker_fixture(
780            concat!(
781                "Host editing\n  HostName 9.9.9.9\n",
782                "Host plain\n  HostName 1.1.1.1\n",
783            ),
784            "editing",
785        );
786        terminal
787            .draw(|frame| {
788                render_proxyjump_picker_overlay(frame, &mut app);
789                let buf = frame.buffer_mut();
790                let (y, _start, end_col) = find_needle_in_buffer(buf, "1.1.1.1")
791                    .expect("candidate host row must render '1.1.1.1'");
792                let border = right_border_col(buf, y).expect("right border on host row");
793                let gap = border.saturating_sub(end_col);
794                assert!(
795                    end_col < border && gap <= 3,
796                    "hostname must end flush with right border (end_col={end_col}, border_x={border}, gap={gap})"
797                );
798            })
799            .unwrap();
800    }
801
802    /// A hostname long enough to hit the truncation limit must still
803    /// stay strictly inside the right border — no overflow past the
804    /// inner area regardless of how much the alias + hostname together
805    /// would otherwise claim.
806    #[test]
807    fn render_proxyjump_picker_long_hostname_does_not_overflow() {
808        let backend = TestBackend::new(80, 20);
809        let mut terminal = Terminal::new(backend).unwrap();
810        // 55-char hostname forces truncation (host_max at width=64 is
811        // inner(58) - alias_col(20) - min_gap(2) = 36).
812        let mut app = proxyjump_picker_fixture(
813            concat!(
814                "Host editing\n  HostName 9.9.9.9\n",
815                "Host plain\n  HostName very-long-hostname-that-should-be-truncated.example.com\n",
816            ),
817            "editing",
818        );
819        terminal
820            .draw(|frame| {
821                render_proxyjump_picker_overlay(frame, &mut app);
822                let buf = frame.buffer_mut();
823                // Find the row that renders the truncated hostname
824                // prefix; the full string will not fit so we anchor on
825                // a prefix that is guaranteed to survive truncation.
826                let (y, _start, end_col) = find_needle_in_buffer(buf, "very-long-hostname")
827                    .expect("truncated hostname prefix must render");
828                let border = right_border_col(buf, y).expect("right border on host row");
829                assert!(
830                    end_col < border,
831                    "truncated hostname must not overflow right border (end_col={end_col}, border_x={border})"
832                );
833            })
834            .unwrap();
835    }
836
837    /// On the minimum-width overlay (50 cols), the right-align math
838    /// must still place the hostname inside the right border without
839    /// collapsing the alias column below its floor.
840    #[test]
841    fn render_proxyjump_picker_right_aligns_on_narrow_terminal() {
842        // Terminal width equals the overlay minimum width clamp so the
843        // picker uses PICKER_MIN_W (60) exactly.
844        let backend = TestBackend::new(60, 20);
845        let mut terminal = Terminal::new(backend).unwrap();
846        let mut app = proxyjump_picker_fixture(
847            concat!(
848                "Host editing\n  HostName 9.9.9.9\n",
849                "Host plain\n  HostName 1.1.1.1\n",
850            ),
851            "editing",
852        );
853        terminal
854            .draw(|frame| {
855                render_proxyjump_picker_overlay(frame, &mut app);
856                let buf = frame.buffer_mut();
857                let (y, _start, end_col) = find_needle_in_buffer(buf, "1.1.1.1")
858                    .expect("hostname must render on narrow terminal");
859                let border = right_border_col(buf, y).expect("right border present");
860                assert!(
861                    end_col < border && border - end_col <= 3,
862                    "right-align must hold on narrow terminal (end_col={end_col}, border_x={border})"
863                );
864            })
865            .unwrap();
866    }
867
868    /// When a host is promoted into the suggested section and rendered
869    /// below a `SectionLabel`, the right-align layout must still apply
870    /// so the two sections of the picker share a visual right edge.
871    #[test]
872    fn render_proxyjump_picker_right_aligns_suggested_host_below_label() {
873        let backend = TestBackend::new(80, 20);
874        let mut terminal = Terminal::new(backend).unwrap();
875        // `bastion` scores via the keyword heuristic so it is promoted
876        // into the suggested section below a `SectionLabel`.
877        let mut app = proxyjump_picker_fixture(
878            concat!(
879                "Host editing\n  HostName 9.9.9.9\n",
880                "Host bastion\n  HostName 1.2.3.4\n",
881                "Host plain\n  HostName 5.6.7.8\n",
882            ),
883            "editing",
884        );
885        terminal
886            .draw(|frame| {
887                render_proxyjump_picker_overlay(frame, &mut app);
888                let buf = frame.buffer_mut();
889                // Locate the SectionLabel row so we can anchor the
890                // search for the suggested host strictly below it.
891                let (label_y, _, _) = find_needle_in_buffer(buf, "SUGGESTIONS")
892                    .expect("SectionLabel must render above the suggested host");
893                let (y, _start, end_col) = find_needle_in_buffer(buf, "1.2.3.4")
894                    .expect("suggested host must render");
895                assert!(
896                    y > label_y,
897                    "suggested host must render below the SectionLabel (label_y={label_y}, host_y={y})"
898                );
899                let border = right_border_col(buf, y).expect("right border on host row");
900                assert!(
901                    end_col < border && border - end_col <= 3,
902                    "suggested host must right-align (end_col={end_col}, border_x={border})"
903                );
904            })
905            .unwrap();
906    }
907
908    /// Both Host rows in the same picker must share the same right
909    /// edge. Prevents a regression where one row miscomputes the
910    /// right-align math while another keeps it correct.
911    #[test]
912    fn render_proxyjump_picker_multiple_hosts_share_right_edge() {
913        let backend = TestBackend::new(80, 20);
914        let mut terminal = Terminal::new(backend).unwrap();
915        let mut app = proxyjump_picker_fixture(
916            concat!(
917                "Host editing\n  HostName 9.9.9.9\n",
918                "Host host-a\n  HostName 1.1.1.1\n",
919                "Host host-b\n  HostName 2.2.2.2\n",
920            ),
921            "editing",
922        );
923        terminal
924            .draw(|frame| {
925                render_proxyjump_picker_overlay(frame, &mut app);
926                let buf = frame.buffer_mut();
927                let (y1, _, end1) =
928                    find_needle_in_buffer(buf, "1.1.1.1").expect("host-a row must render");
929                let (y2, _, end2) =
930                    find_needle_in_buffer(buf, "2.2.2.2").expect("host-b row must render");
931                assert_ne!(y1, y2, "two distinct rows expected");
932                assert_eq!(
933                    end1, end2,
934                    "both hostnames must end at the same column (end1={end1}, end2={end2})"
935                );
936            })
937            .unwrap();
938    }
939
940    /// The `min_gap = 2` contract must leave at least two spaces
941    /// between the end of the alias column and the start of the
942    /// hostname column so the two visually distinct columns never run
943    /// into each other.
944    #[test]
945    fn render_proxyjump_picker_preserves_minimum_gap_between_columns() {
946        let backend = TestBackend::new(80, 20);
947        let mut terminal = Terminal::new(backend).unwrap();
948        let mut app = proxyjump_picker_fixture(
949            concat!(
950                "Host editing\n  HostName 9.9.9.9\n",
951                "Host a\n  HostName 1.1.1.1\n",
952            ),
953            "editing",
954        );
955        terminal
956            .draw(|frame| {
957                render_proxyjump_picker_overlay(frame, &mut app);
958                let buf = frame.buffer_mut();
959                let (y, host_start, _) = find_needle_in_buffer(buf, "1.1.1.1")
960                    .expect("hostname must render for gap check");
961                // Walk left from the hostname start until we hit a
962                // non-space cell — that is the alias column's last
963                // glyph. Count the intervening spaces.
964                let mut gap = 0_u16;
965                let mut x = host_start;
966                while x > 0 {
967                    x -= 1;
968                    if buf[(x, y)].symbol() == " " {
969                        gap += 1;
970                    } else {
971                        break;
972                    }
973                }
974                assert!(
975                    gap >= 2,
976                    "at least two spaces must separate alias and hostname columns (gap={gap})"
977                );
978            })
979            .unwrap();
980    }
981}