use crate::app::App; use crate::ui::component::{panel, panel_with_subtitle}; use crate::ui::{BLUE, GREEN, MUTED, SELECTED_BG, TEXT}; use super::View; use anyhow::Result; use crossterm::event::{KeyCode, KeyEvent}; use ratatui::{ Frame, layout::{Alignment, Constraint, Rect}, style::{Modifier, Style, Stylize}, widgets::{Cell, Paragraph, Row, Table, Widget}, }; pub struct ImportView; impl View for ImportView { fn title(&self) -> &'static str { "Import" } fn hints(&self) -> Vec<(&'static str, &'static str)> { vec![ ("j/k", "move"), ("Space", "toggle"), ("a/A", "all/none"), ("Enter", "import"), ("Esc", "cancel"), ] } fn draw(&self, frame: &mut Frame<'_>, app: &App, area: Rect) { draw_import(frame, app, area); } fn handle_key(&self, app: &mut App, key: KeyEvent) -> Result<()> { handle_import(app, key) } } // ── Rendering ────────────────────────────────────────────────── fn draw_import(frame: &mut Frame<'_>, app: &App, area: Rect) { let shell_len = app.session.import.shell_candidates.len(); let ssh_len = app.session.import.candidates.len(); let total = shell_len + ssh_len; if total == 0 { Paragraph::new("\n No importable hosts or shells found") .fg(MUTED) .alignment(Alignment::Center) .block(panel("Import")) .render(area, frame.buffer_mut()); return; } let mut rows: Vec> = Vec::new(); let mut entry_row: Vec = Vec::new(); // item_idx -> visual row if shell_len > 0 { rows.push(section_row("Shell", shell_len)); } for (idx, item) in app.session.import.shell_candidates.iter().enumerate() { entry_row.push(rows.len()); let selected_row = idx == app.session.import.cursor; let checked = app.session.import.shell_selected.get(idx).copied().unwrap_or(false); let has_conflict = item.conflict.is_some(); let style = if selected_row { Style::default().bg(SELECTED_BG).fg(TEXT).add_modifier(Modifier::BOLD) } else if has_conflict { Style::default().fg(MUTED) } else { Style::default().fg(TEXT) }; let check_style = if checked { Style::default().fg(GREEN).add_modifier(Modifier::BOLD) } else { Style::default().fg(MUTED) }; let status = item .conflict .as_ref() .map(|c| format!("conflict: {}", c.name)) .unwrap_or_else(|| "-".to_string()); let check_cell_style = if selected_row { Style::default().bg(SELECTED_BG) } else { Style::default() }; rows.push(Row::new([ Cell::from(if checked { " [x]" } else { " [ ]" }).style(check_style.patch(check_cell_style)), Cell::from(item.name.clone()).style(style), Cell::from(item.path.display().to_string()).style(style), Cell::from(status).style(style), ])); } if shell_len > 0 && ssh_len > 0 { rows.push(Row::new(["", "", "", ""]).height(1)); } if ssh_len > 0 { rows.push(section_row("SSH", ssh_len)); } for (idx, item) in app.session.import.candidates.iter().enumerate() { entry_row.push(rows.len()); let selected_row = (shell_len + idx) == app.session.import.cursor; let checked = app.session.import.selected.get(idx).copied().unwrap_or(false); let style = if selected_row { Style::default().bg(SELECTED_BG).fg(TEXT).add_modifier(Modifier::BOLD) } else { Style::default().fg(TEXT) }; let check_style = if checked { Style::default().fg(GREEN).add_modifier(Modifier::BOLD) } else { Style::default().fg(MUTED) }; let check_cell_style = if selected_row { Style::default().bg(SELECTED_BG) } else { Style::default() }; rows.push(Row::new([ Cell::from(if checked { " [x]" } else { " [ ]" }).style(check_style.patch(check_cell_style)), Cell::from(item.name.clone()).style(style), Cell::from(format!("{}@{}:{}", item.user, item.host, item.port)).style(style), Cell::from( item.identity_file .as_ref() .map(|p| p.display().to_string()) .unwrap_or_else(|| "-".to_string()), ) .style(style), ])); } // Scroll to keep selected entry visible let visible = area.height.saturating_sub(3) as usize; if visible > 0 && !entry_row.is_empty() { let sel_row = entry_row[app.session.import.cursor.min(entry_row.len() - 1)]; let total_rows = rows.len(); if total_rows > visible { let scroll = if sel_row < visible / 2 { 0 } else if sel_row + visible / 2 >= total_rows { total_rows.saturating_sub(visible) } else { sel_row - visible / 2 }; rows = rows.into_iter().skip(scroll).take(visible).collect(); } } let table = Table::new( rows, [ Constraint::Length(14), Constraint::Length(22), Constraint::Percentage(40), Constraint::Percentage(30), ], ) .header( Row::new([" Use", "Name", "Target", "Identity / Status"]) .style(Style::default().fg(BLUE).add_modifier(Modifier::BOLD)), ) .block(panel_with_subtitle( "Import", "Space toggle a all A none Enter import Esc cancel", )) .column_spacing(0) .row_highlight_style(Style::default().bg(SELECTED_BG)); frame.render_widget(table, area); } fn section_row(label: &str, count: usize) -> Row<'static> { Row::new([ Cell::from(format!(" {label} ({count})")) .style(Style::default().fg(BLUE).add_modifier(Modifier::BOLD)), Cell::from(""), Cell::from(""), Cell::from(""), ]) .height(1) } // ── Key handling ─────────────────────────────────────────────── fn handle_import(app: &mut App, key: KeyEvent) -> Result<()> { let shell_len = app.session.import.shell_candidates.len(); let total = shell_len + app.session.import.candidates.len(); match key.code { KeyCode::Esc => app.session.mode = crate::app::Mode::Home, KeyCode::Down | KeyCode::Char('j') if total > 0 => { app.session.import.cursor = (app.session.import.cursor + 1) % total; } KeyCode::Up | KeyCode::Char('k') if total > 0 => { app.session.import.cursor = (app.session.import.cursor + total - 1) % total; } KeyCode::Char(' ') => { if app.session.import.cursor < shell_len { let can_toggle = app .session .import .shell_candidates .get(app.session.import.cursor) .is_some_and(|c| c.conflict.is_none()); if can_toggle && let Some(v) = app.session.import.shell_selected.get_mut(app.session.import.cursor) { *v = !*v; } } else { let ssh_idx = app.session.import.cursor - shell_len; if let Some(v) = app.session.import.selected.get_mut(ssh_idx) { *v = !*v; } } } KeyCode::Char('a') => { app.session .import .shell_selected .iter_mut() .zip(&app.session.import.shell_candidates) .for_each(|(sel, c)| *sel = c.conflict.is_none()); app.session.import.selected.fill(true); } KeyCode::Char('A') => { app.session.import.shell_selected.fill(false); app.session.import.selected.fill(false); } KeyCode::Enter => { let mut imported = 0; let picked_shells: Vec<_> = app .session .import .shell_candidates .iter() .zip(&app.session.import.shell_selected) .filter_map(|(item, &selected)| selected.then_some(item.clone())) .collect(); for candidate in &picked_shells { if candidate.conflict.is_none() { app.config.add_local_shell(candidate)?; imported += 1; } } let picked_ssh: Vec<_> = app .session .import .candidates .iter() .zip(&app.session.import.selected) .filter_map(|(item, &selected)| selected.then_some(item.clone())) .collect(); if !picked_ssh.is_empty() { match crate::import::import_candidates(&mut app.config, &picked_ssh) { Ok(count) => imported += count, Err(err) => { app.toast(err.to_string(), false); app.session.mode = crate::app::Mode::Home; return Ok(()); } } } if imported > 0 { app.config.save()?; app.toast(format!("imported {imported} items"), true); } else { app.toast("nothing selected", false); } app.session.mode = crate::app::Mode::Home; } _ => {} } Ok(()) }