diff --git a/src/app/form.rs b/src/app/form.rs index 25c2df5..f37599b 100644 --- a/src/app/form.rs +++ b/src/app/form.rs @@ -104,7 +104,7 @@ impl FormState { pub fn from_profile(name: &str, profile: &ConnectionProfile, cfg: &SshellConfig) -> Self { let mut form = Self::blank(); form.edit_name = Some(name.to_string()); - form.name = name.to_string(); + form.name = name.strip_prefix('$').unwrap_or(name).to_string(); form.tags = profile.tags.join(", "); match &profile.kind { ConnectionType::Ssh { diff --git a/src/app/form_ops.rs b/src/app/form_ops.rs index b3da416..8506ddd 100644 --- a/src/app/form_ops.rs +++ b/src/app/form_ops.rs @@ -69,6 +69,11 @@ impl App { if name.is_empty() { bail!("name is required"); } + let name = if self.session.form.is_shell && !name.starts_with('$') { + format!("${name}") + } else { + name + }; if self.session.form.edit_name.as_deref() != Some(&name) && self.config.connections.contains_key(&name) { diff --git a/src/app/home_ops.rs b/src/app/home_ops.rs index 5ba7256..16d5e9d 100644 --- a/src/app/home_ops.rs +++ b/src/app/home_ops.rs @@ -8,6 +8,21 @@ impl App { self.session.should_quit = true; } + pub fn enter_action_menu(&mut self) { + self.session.action_menu.cursor = 0; + self.session.mode = Mode::ActionMenu; + } + + pub fn enter_combined_import(&mut self) -> Result<()> { + self.session.import.candidates = crate::import::load_candidates(&self.config)?; + self.session.import.selected = vec![false; self.session.import.candidates.len()]; + self.session.import.shell_candidates = self.config.local_shell_candidates(); + self.session.import.shell_selected = vec![false; self.session.import.shell_candidates.len()]; + self.session.import.cursor = 0; + self.session.mode = Mode::ImportSelector; + Ok(()) + } + pub fn enter_quick_select(&mut self) { if self.entries().is_empty() { self.toast("no connections available", false); @@ -40,7 +55,9 @@ impl App { pub fn enter_import_selector(&mut self) -> Result<()> { self.session.import.candidates = crate::import::load_candidates(&self.config)?; - self.session.import.selected = vec![true; self.session.import.candidates.len()]; + self.session.import.selected = vec![false; self.session.import.candidates.len()]; + self.session.import.shell_candidates = self.config.local_shell_candidates(); + self.session.import.shell_selected = vec![false; self.session.import.shell_candidates.len()]; self.session.import.cursor = 0; self.session.mode = Mode::ImportSelector; Ok(()) diff --git a/src/app/mod.rs b/src/app/mod.rs index e1f9177..e62ffb6 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -3,7 +3,6 @@ mod form_ops; mod home_ops; mod profile_ext; mod settings_ops; -mod shell_ops; mod types; pub mod cred; @@ -12,7 +11,7 @@ pub mod settings; pub use cred::{CredFormField, CredFormState}; pub use form::{FormField, FormState}; -pub use profile_ext::split_args; +pub use profile_ext::{display_name, split_args}; pub use settings::{SettingsField, SettingsState}; pub use types::*; @@ -28,17 +27,10 @@ pub struct App { impl App { pub fn load() -> Result { let config = SshellConfig::load()?; - let should_pick_shells = !config - .connections - .values() - .any(|profile| matches!(profile.kind, ConnectionType::Shell { .. })); - let mut app = Self { + let app = Self { config, session: Session::new(), }; - if should_pick_shells { - app.enter_shell_import(); - } Ok(app) } @@ -97,14 +89,14 @@ impl App { pub fn move_selection(&mut self, delta: isize) { let len = match self.session.mode { - Mode::ImportSelector => self.session.import.candidates.len(), - Mode::ShellImport => self.session.shell_import.candidates.len(), + Mode::ImportSelector => { + self.session.import.shell_candidates.len() + self.session.import.candidates.len() + } Mode::Credentials => self.config.credentials.entries.len(), _ => self.entries().len(), }; let selected = match self.session.mode { Mode::ImportSelector => &mut self.session.import.cursor, - Mode::ShellImport => &mut self.session.shell_import.cursor, Mode::Credentials => &mut self.session.credentials.selected, _ => &mut self.session.home.selected, }; diff --git a/src/app/profile_ext.rs b/src/app/profile_ext.rs index 0326484..bc9824e 100644 --- a/src/app/profile_ext.rs +++ b/src/app/profile_ext.rs @@ -1,12 +1,16 @@ use crate::config::{ConnectionProfile, ConnectionType}; use std::fs; +pub fn display_name(key: &str) -> &str { + key.strip_prefix('$').unwrap_or(key) +} + pub fn matches_search(name: &str, profile: &ConnectionProfile, query: &str) -> bool { let query = query.trim().to_lowercase(); if query.is_empty() { return true; } - if name.to_lowercase().contains(&query) { + if display_name(name).to_lowercase().contains(&query) { return true; } if profile diff --git a/src/app/shell_ops.rs b/src/app/shell_ops.rs deleted file mode 100644 index a6a1f9c..0000000 --- a/src/app/shell_ops.rs +++ /dev/null @@ -1,41 +0,0 @@ -use anyhow::Result; - -use super::{App, Mode}; - -impl App { - pub fn refresh_local_shells(&mut self) -> Result<()> { - self.enter_shell_import(); - Ok(()) - } - - pub fn enter_shell_import(&mut self) { - self.session.shell_import.candidates = self.config.local_shell_candidates(); - self.session.shell_import.selected = - vec![false; self.session.shell_import.candidates.len()]; - self.session.shell_import.cursor = 0; - self.session.mode = Mode::ShellImport; - } - - pub fn import_selected_shells(&mut self) -> Result { - let picked: Vec<_> = self - .session - .shell_import - .candidates - .iter() - .zip(&self.session.shell_import.selected) - .filter_map(|(item, selected)| { - (*selected && item.conflict.is_none()).then_some(item.clone()) - }) - .collect(); - let mut count = 0; - for candidate in &picked { - self.config.add_local_shell(candidate)?; - count += 1; - } - if count > 0 { - self.config.save()?; - } - self.session.mode = Mode::Home; - Ok(count) - } -} diff --git a/src/app/types.rs b/src/app/types.rs index 4e50adb..db9da3a 100644 --- a/src/app/types.rs +++ b/src/app/types.rs @@ -93,9 +93,9 @@ fn char_to_byte_index(value: &str, char_pos: usize) -> usize { #[derive(Debug, Clone, PartialEq, Eq)] pub enum Mode { Home, + ActionMenu, Search, QuickSelect, - ShellImport, Form, DeleteConfirm, ImportSelector, @@ -147,16 +147,23 @@ pub struct Toast { pub born: std::time::Instant, } +// ── Action Menu ──────────────────────────────────────────────── + +#[derive(Debug, Clone, Default)] +pub struct ActionMenuSession { + pub cursor: usize, +} + // ── Session ────────────────────────────────────────────────── #[derive(Debug, Clone)] pub struct Session { pub mode: Mode, pub home: HomeSession, + pub action_menu: ActionMenuSession, pub form: FormState, pub credentials: CredentialSession, pub import: ImportSession, - pub shell_import: ShellImportSession, pub toast: Option, pub should_quit: bool, pub settings: SettingsState, @@ -180,13 +187,8 @@ pub struct ImportSession { pub cursor: usize, pub candidates: Vec, pub selected: Vec, -} - -#[derive(Debug, Clone, Default)] -pub struct ShellImportSession { - pub cursor: usize, - pub candidates: Vec, - pub selected: Vec, + pub shell_candidates: Vec, + pub shell_selected: Vec, } impl Session { @@ -194,10 +196,10 @@ impl Session { Self { mode: Mode::Home, home: HomeSession::default(), + action_menu: ActionMenuSession::default(), form: FormState::blank(), credentials: CredentialSession::default(), import: ImportSession::default(), - shell_import: ShellImportSession::default(), toast: None, should_quit: false, settings: SettingsState::default(), diff --git a/src/config.rs b/src/config.rs index 134c19b..ab8f95d 100644 --- a/src/config.rs +++ b/src/config.rs @@ -149,6 +149,7 @@ impl SshellConfig { let mut cfg: Self = toml::from_str(&raw).with_context(|| format!("failed to parse {}", path.display()))?; cfg.migrate_path_to_embedded(); + cfg.migrate_shell_prefix(); Ok(cfg) } @@ -209,7 +210,7 @@ impl SshellConfig { } let conflict = self .connections - .contains_key(base_name) + .contains_key(&format!("${base_name}")) .then(|| ShellScanConflict { name: base_name.to_string(), path: path.clone(), @@ -250,7 +251,8 @@ impl SshellConfig { } pub fn add_local_shell(&mut self, candidate: &ShellCandidate) -> Result<()> { - if candidate.conflict.is_some() || self.connections.contains_key(&candidate.name) { + let key = format!("${}", candidate.name); + if candidate.conflict.is_some() || self.connections.contains_key(&key) { bail!("shell name conflict: {}", candidate.name); } let command = candidate.path.to_string_lossy().to_string(); @@ -260,7 +262,7 @@ impl SshellConfig { return Ok(()); } self.connections.insert( - candidate.name.clone(), + key, ConnectionProfile { tags: Vec::new(), local_tags: vec!["local".to_string(), "scanned".to_string()], @@ -296,6 +298,20 @@ impl SshellConfig { *path = None; } } + + fn migrate_shell_prefix(&mut self) { + let keys: Vec = self + .connections + .iter() + .filter(|(key, profile)| { + matches!(&profile.kind, ConnectionType::Shell { .. }) && !key.starts_with('$') + }) + .map(|(key, _)| key.clone()) + .collect(); + for key in keys { + self.connections.shift_remove(&key); + } + } } #[cfg(unix)] diff --git a/src/ui/app.rs b/src/ui/app.rs index 1e64a91..b0cd2c3 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -12,8 +12,8 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::time::Duration; use super::view::{ - CredFormView, CredListView, DeleteConfirmView, FormView, HomeListView, ImportView, - QuickSelectView, SearchView, SettingsView, ShellImportView, View, + ActionMenuView, CredFormView, CredListView, DeleteConfirmView, FormView, HomeListView, + ImportView, QuickSelectView, SearchView, SettingsView, View, }; static TERMINAL_RESTORED: AtomicBool = AtomicBool::new(false); @@ -71,12 +71,12 @@ fn handle_key(app: &mut App, key: KeyEvent) -> Result<()> { } match app.session.mode { Mode::Home => HomeListView.handle_key(app, key), + Mode::ActionMenu => ActionMenuView.handle_key(app, key), Mode::Search => SearchView.handle_key(app, key), Mode::QuickSelect => QuickSelectView.handle_key(app, key), Mode::DeleteConfirm => DeleteConfirmView.handle_key(app, key), Mode::Form => FormView.handle_key(app, key), Mode::ImportSelector => ImportView.handle_key(app, key), - Mode::ShellImport => ShellImportView.handle_key(app, key), Mode::Credentials => CredListView.handle_key(app, key), Mode::CredForm => CredFormView.handle_key(app, key), Mode::Settings => SettingsView.handle_key(app, key), diff --git a/src/ui/component/custom/delete_confirm.rs b/src/ui/component/custom/delete_confirm.rs index e934cd3..055582e 100644 --- a/src/ui/component/custom/delete_confirm.rs +++ b/src/ui/component/custom/delete_confirm.rs @@ -1,9 +1,11 @@ use crate::app::App; +use crate::app::display_name; use crate::ui::RED; use crate::ui::component::dialog::draw_dialog; pub fn draw_delete_confirm(frame: &mut ratatui::Frame<'_>, app: &App) { let name = app.selected_name().unwrap_or_default(); + let name = display_name(&name); draw_dialog( frame, 46, diff --git a/src/ui/component/custom/header.rs b/src/ui/component/custom/header.rs index 1b34fc8..5259d6f 100644 --- a/src/ui/component/custom/header.rs +++ b/src/ui/component/custom/header.rs @@ -11,12 +11,12 @@ use ratatui::{ pub fn draw_header(frame: &mut ratatui::Frame<'_>, app: &App, area: Rect) { let mode = match app.session.mode { Mode::Home => "Home", + Mode::ActionMenu => "Actions", Mode::Search => "Search", Mode::QuickSelect => "Quick Select", Mode::Form => "Editor", Mode::DeleteConfirm => "Delete", Mode::ImportSelector => "Import", - Mode::ShellImport => "Shells", Mode::Credentials => "Credentials", Mode::CredForm => "Cred Editor", Mode::Settings => "Settings", diff --git a/src/ui/component/custom/help_bar.rs b/src/ui/component/custom/help_bar.rs index 9a8b4fa..458e0a7 100644 --- a/src/ui/component/custom/help_bar.rs +++ b/src/ui/component/custom/help_bar.rs @@ -22,10 +22,7 @@ fn home_hints() -> Vec { Hint { key: "a", desc: "add" }, Hint { key: "e", desc: "edit" }, Hint { key: "d", desc: "delete" }, - Hint { key: "r", desc: "scan" }, - Hint { key: "p/P", desc: "push/pull" }, - Hint { key: "c", desc: "creds" }, - Hint { key: "s", desc: "settings" }, + Hint { key: ":", desc: "actions" }, Hint { key: "q", desc: "quit" }, ] } @@ -91,16 +88,6 @@ fn import_hints() -> Vec { ] } -fn shell_import_hints() -> Vec { - vec![ - Hint { key: "j/k", desc: "move" }, - Hint { key: "Space", desc: "toggle" }, - Hint { key: "a/A", desc: "all/none" }, - Hint { key: "Enter", desc: "enable" }, - Hint { key: "Esc", desc: "skip" }, - ] -} - fn settings_hints() -> Vec { vec![ Hint { key: "type", desc: "edit" }, @@ -109,9 +96,18 @@ fn settings_hints() -> Vec { ] } +fn action_menu_hints() -> Vec { + vec![ + Hint { key: "j/k", desc: "move" }, + Hint { key: "Enter", desc: "select" }, + Hint { key: "Esc", desc: "cancel" }, + ] +} + pub fn draw_help(frame: &mut ratatui::Frame<'_>, app: &App, area: Rect) { let hints = match app.session.mode { Mode::Home => home_hints(), + Mode::ActionMenu => action_menu_hints(), Mode::Search => search_hints(), Mode::QuickSelect => quick_select_hints(), Mode::Form => form_hints(), @@ -119,7 +115,6 @@ pub fn draw_help(frame: &mut ratatui::Frame<'_>, app: &App, area: Rect) { Mode::Credentials => credentials_hints(), Mode::CredForm => cred_form_hints(), Mode::ImportSelector => import_hints(), - Mode::ShellImport => shell_import_hints(), Mode::Settings => settings_hints(), }; diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 7f8dfdc..ec11e5b 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -78,12 +78,12 @@ pub fn draw(frame: &mut Frame<'_>, app: &mut crate::app::App) { use view::View; match app.session.mode { Mode::Home => view::HomeListView.draw(frame, app, content), + Mode::ActionMenu => view::HomeListView.draw(frame, app, content), Mode::Search => view::SearchView.draw(frame, app, content), Mode::QuickSelect => view::QuickSelectView.draw(frame, app, content), Mode::DeleteConfirm => view::DeleteConfirmView.draw(frame, app, content), Mode::Form => view::FormView.draw(frame, app, content), Mode::ImportSelector => view::ImportView.draw(frame, app, content), - Mode::ShellImport => view::ShellImportView.draw(frame, app, content), Mode::Credentials => view::CredListView.draw(frame, app, content), Mode::CredForm => view::CredFormView.draw(frame, app, content), Mode::Settings => view::SettingsView.draw(frame, app, content), @@ -93,6 +93,9 @@ pub fn draw(frame: &mut Frame<'_>, app: &mut crate::app::App) { if app.session.mode == Mode::DeleteConfirm { draw_delete_confirm(frame, app); } + if app.session.mode == Mode::ActionMenu { + view::ActionMenuView.draw(frame, app, content); + } if let Some(toast) = &app.session.toast { draw_toast(frame, toast.message.as_str(), toast.success); } diff --git a/src/ui/view/action_menu.rs b/src/ui/view/action_menu.rs new file mode 100644 index 0000000..8551995 --- /dev/null +++ b/src/ui/view/action_menu.rs @@ -0,0 +1,93 @@ +use crate::app::{App, Mode}; +use crate::ui::component::common::layout::centered_rect; +use crate::ui::{ACCENT, PANEL, SELECTED_BG, TEXT}; + +use super::View; + +use anyhow::Result; +use crossterm::event::{KeyCode, KeyEvent}; +use ratatui::{ + Frame, + layout::Rect, + style::{Modifier, Style, Stylize}, + widgets::{Block, Borders, Clear, ListItem, ListState}, +}; + +const ACTIONS: &[(&str, &str)] = &[ + ("Import", "scan shells & import SSH config"), + ("Push Sync", "upload to cloud"), + ("Pull Sync", "download from cloud"), + ("Credentials", "manage passwords & keys"), + ("Settings", "preferences & sync config"), +]; + +pub struct ActionMenuView; + +impl View for ActionMenuView { + fn draw(&self, frame: &mut Frame<'_>, _app: &App, _area: Rect) { + let width = 44u16; + let height = (ACTIONS.len() as u16) + 4; + let area = centered_rect(width, height, frame.area()); + + frame.render_widget(Clear, area); + + let items: Vec> = ACTIONS + .iter() + .map(|(label, desc)| { + ListItem::new(format!(" {label:<14}{desc}")) + }) + .collect(); + + let list = ratatui::widgets::List::new(items) + .block( + Block::default() + .title(" Actions ") + .title_style(Style::default().fg(ACCENT).bold()) + .borders(Borders::ALL) + .border_style(Style::default().fg(ACCENT)) + .bg(PANEL), + ) + .highlight_style( + Style::default() + .bg(SELECTED_BG) + .fg(TEXT) + .add_modifier(Modifier::BOLD), + ) + .highlight_symbol(">"); + + let mut state = ListState::default(); + state.select(Some(_app.session.action_menu.cursor)); + + frame.render_stateful_widget(list, area, &mut state); + } + + fn handle_key(&self, app: &mut App, key: KeyEvent) -> Result<()> { + let len = ACTIONS.len(); + match key.code { + KeyCode::Char(':') | KeyCode::Esc => { + app.session.mode = Mode::Home; + } + KeyCode::Down | KeyCode::Char('j') => { + app.session.action_menu.cursor = + (app.session.action_menu.cursor + 1) % len; + } + KeyCode::Up | KeyCode::Char('k') => { + app.session.action_menu.cursor = + (app.session.action_menu.cursor + len - 1) % len; + } + KeyCode::Enter => { + app.session.mode = Mode::Home; + match app.session.action_menu.cursor { + 0 => app.enter_combined_import()?, + 1 => app.push_sync_with_toast(), + 2 => app.pull_sync_with_toast(), + 3 => app.enter_credentials(), + 4 => app.enter_settings(), + _ => {} + } + } + _ => {} + } + Ok(()) + } +} diff --git a/src/ui/view/home_list.rs b/src/ui/view/home_list.rs index 1b7fbf6..805386f 100644 --- a/src/ui/view/home_list.rs +++ b/src/ui/view/home_list.rs @@ -1,4 +1,5 @@ use crate::app::{App, Mode}; +use crate::app::display_name; use crate::config::{ConnectionSource, ConnectionType, CredentialEntry}; use crate::ui::component::{badge_span, draw_input, panel, tag_badge}; use crate::ui::{ACCENT, BLUE, GREEN, MUTED, PANEL_ALT, PURPLE, RED, SELECTED_BG, TEXT}; @@ -83,7 +84,7 @@ pub fn draw_connection_list(frame: &mut Frame<'_>, app: &App, area: Rect) { }; if entries.is_empty() { let message = if app.session.home.search.is_empty() { - "\n No connections yet\n\n Press a to create one or i to import from ~/.ssh/config" + "\n No connections yet\n\n Press a to add or : for actions" } else { "\n No matching connections\n\n Press Esc to clear search" }; @@ -259,7 +260,7 @@ fn connection_row( }; Row::new([ - Cell::from(format!("{marker} {name}")).style(row_style), + Cell::from(format!("{marker} {}", display_name(name))).style(row_style), Cell::from(type_badge).style( Style::default() .fg(crate::ui::BG) @@ -301,7 +302,7 @@ pub fn draw_detail_panel(frame: &mut Frame<'_>, app: &App, area: Rect) { Span::raw(" "), badge_span(badge, badge_color), Span::raw(" "), - Span::styled((*name).clone(), Style::default().fg(TEXT).bold()), + Span::styled(display_name(name).to_string(), Style::default().fg(TEXT).bold()), ])); lines.push(Line::from(vec![ Span::raw(" "), @@ -452,12 +453,7 @@ fn handle_home(app: &mut App, key: KeyEvent) -> Result<()> { KeyCode::Char('a') => app.new_form(), KeyCode::Char('e') => app.edit_form(), KeyCode::Char('d') => app.enter_delete_confirm_for_selected(), - KeyCode::Char('i') => app.enter_import_selector()?, - KeyCode::Char('r') => app.refresh_local_shells()?, - KeyCode::Char('p') => app.push_sync_with_toast(), - KeyCode::Char('P') => app.pull_sync_with_toast(), - KeyCode::Char('c') => app.enter_credentials(), - KeyCode::Char('s') => app.enter_settings(), + KeyCode::Char(':') => app.enter_action_menu(), _ => {} } Ok(()) diff --git a/src/ui/view/import.rs b/src/ui/view/import.rs index 258e389..cf76f07 100644 --- a/src/ui/view/import.rs +++ b/src/ui/view/import.rs @@ -2,7 +2,7 @@ use crate::app::App; use crate::ui::component::{panel, panel_with_subtitle}; use crate::ui::{BLUE, GREEN, MUTED, SELECTED_BG, TEXT}; -use super::{View, scroll_rows}; +use super::View; use anyhow::Result; use crossterm::event::{KeyCode, KeyEvent}; @@ -14,7 +14,6 @@ use ratatui::{ }; pub struct ImportView; -pub struct ShellImportView; impl View for ImportView { fn draw(&self, frame: &mut Frame<'_>, app: &App, area: Rect) { @@ -26,245 +25,228 @@ impl View for ImportView { } } -impl View for ShellImportView { - fn draw(&self, frame: &mut Frame<'_>, app: &App, area: Rect) { - draw_shell_import(frame, app, area); - } - - fn handle_key(&self, app: &mut App, key: KeyEvent) -> Result<()> { - handle_shell_import(app, key) - } -} - // ── Rendering ────────────────────────────────────────────────── fn draw_import(frame: &mut Frame<'_>, app: &App, area: Rect) { - if app.session.import.candidates.is_empty() { - Paragraph::new("\n No importable hosts found in ~/.ssh/config") + 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("SSH Config Import")) + .block(panel("Import")) .render(area, frame.buffer_mut()); return; } - let rows: Vec> = app - .session - .import - .candidates - .iter() - .enumerate() - .map(|(idx, item)| { - let selected_row = 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) - }; - Row::new([ - Cell::from(if checked { " [x]" } else { " [ ]" }).style(check_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), - ]) - }) - .collect(); - let rows = scroll_rows(rows, app.session.import.cursor, area.height); + 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()); + rows.push(Row::new([ + Cell::from(if checked { " [x]" } else { " [ ]" }).style(check_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) + }; + rows.push(Row::new([ + Cell::from(if checked { " [x]" } else { " [ ]" }).style(check_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(5), - Constraint::Length(24), - Constraint::Percentage(35), - Constraint::Percentage(45), + Constraint::Length(14), + Constraint::Length(22), + Constraint::Percentage(40), + Constraint::Percentage(30), ], ) .header( - Row::new([" Use", "Name", "Target", "Identity File"]) - .style(Style::default().fg(BLUE).bold()), + Row::new([" Use", "Name", "Target", "Identity / Status"]) + .style(Style::default().fg(BLUE).add_modifier(Modifier::BOLD)), ) .block(panel_with_subtitle( - "SSH Config Import", + "Import", "Space toggle a all A none Enter import Esc cancel", )) .column_spacing(2); frame.render_widget(table, area); } -fn draw_shell_import(frame: &mut Frame<'_>, app: &App, area: Rect) { - if app.session.shell_import.candidates.is_empty() { - Paragraph::new("\n No new local shells found\n\n Press Esc to return home") - .fg(MUTED) - .alignment(Alignment::Center) - .block(panel("Detected Shells")) - .render(area, frame.buffer_mut()); - return; - } - - let rows: Vec> = app - .session - .shell_import - .candidates - .iter() - .enumerate() - .map(|(idx, item)| { - let selected_row = idx == app.session.shell_import.cursor; - let checked = app - .session - .shell_import - .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(|conflict| format!("conflict: {}", conflict.name)) - .unwrap_or_else(|| "ready".to_string()); - Row::new([ - Cell::from(if checked { " [x]" } else { " [ ]" }).style(check_style), - Cell::from(item.name.clone()).style(style), - Cell::from(item.path.display().to_string()).style(style), - Cell::from(status).style(style), - ]) - }) - .collect(); - - let rows = scroll_rows(rows, app.session.shell_import.cursor, area.height); - - let table = Table::new( - rows, - [ - Constraint::Length(5), - Constraint::Length(20), - Constraint::Percentage(50), - Constraint::Percentage(30), - ], - ) - .header(Row::new([" Use", "Name", "Path", "Status"]).style(Style::default().fg(BLUE).bold())) - .block(panel_with_subtitle( - "Detected Shells", - "Space toggle a all A none Enter enable Esc skip", - )) - .column_spacing(2); - 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') => app.move_selection(1), - KeyCode::Up | KeyCode::Char('k') => app.move_selection(-1), + 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 let Some(v) = app - .session - .import - .selected - .get_mut(app.session.import.cursor) - { - *v = !*v; + 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.selected.fill(true), - KeyCode::Char('A') => app.session.import.selected.fill(false), + 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 picked: Vec<_> = app + 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())) + .filter_map(|(item, &selected)| selected.then_some(item.clone())) .collect(); - match crate::import::import_candidates(&mut app.config, &picked) { - Ok(count) => app.toast(format!("imported {count} connections"), true), - Err(err) => app.toast(err.to_string(), false), - } - app.session.mode = crate::app::Mode::Home; - } - _ => {} - } - Ok(()) -} - -fn handle_shell_import(app: &mut App, key: KeyEvent) -> Result<()> { - match key.code { - KeyCode::Esc => app.session.mode = crate::app::Mode::Home, - KeyCode::Down | KeyCode::Char('j') => app.move_selection(1), - KeyCode::Up | KeyCode::Char('k') => app.move_selection(-1), - KeyCode::Char(' ') => { - let can_select = app - .session - .shell_import - .candidates - .get(app.session.shell_import.cursor) - .is_some_and(|candidate| candidate.conflict.is_none()); - if can_select - && let Some(v) = app - .session - .shell_import - .selected - .get_mut(app.session.shell_import.cursor) - { - *v = !*v; - } - } - KeyCode::Char('a') => { - for (selected, candidate) in app - .session - .shell_import - .selected - .iter_mut() - .zip(&app.session.shell_import.candidates) - { - *selected = candidate.conflict.is_none(); - } - } - KeyCode::Char('A') => app.session.shell_import.selected.fill(false), - KeyCode::Enter => { - match app.import_selected_shells() { - Ok(count) => app.toast(format!("enabled {count} shells"), true), - Err(err) => app.toast(err.to_string(), false), + 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; } diff --git a/src/ui/view/mod.rs b/src/ui/view/mod.rs index 9788231..e906313 100644 --- a/src/ui/view/mod.rs +++ b/src/ui/view/mod.rs @@ -1,3 +1,4 @@ +mod action_menu; mod cred_form; mod cred_list; mod delete_confirm_view; @@ -23,12 +24,13 @@ pub trait View { fn handle_key(&self, app: &mut App, key: KeyEvent) -> Result<()>; } +pub use action_menu::ActionMenuView; pub use cred_form::CredFormView; pub use cred_list::CredListView; pub use delete_confirm_view::DeleteConfirmView; pub use form::FormView; pub use home_list::HomeListView; -pub use import::{ImportView, ShellImportView}; +pub use import::ImportView; pub use quick_select::QuickSelectView; pub use search::SearchView; pub use settings::SettingsView;