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