refactor: replace keybinds with action menu, unify import view, add $ prefix for shells

This commit is contained in:
2026-05-26 23:07:38 +08:00
parent a74eacfebd
commit e36b393a62
17 changed files with 368 additions and 300 deletions
+1 -1
View File
@@ -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 {
+5
View File
@@ -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)
{
+18 -1
View File
@@ -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(())
+5 -13
View File
@@ -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<Self> {
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,
};
+5 -1
View File
@@ -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
-41
View File
@@ -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<usize> {
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)
}
}
+12 -10
View File
@@ -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<Toast>,
pub should_quit: bool,
pub settings: SettingsState,
@@ -180,13 +187,8 @@ pub struct ImportSession {
pub cursor: usize,
pub candidates: Vec<ImportCandidate>,
pub selected: Vec<bool>,
}
#[derive(Debug, Clone, Default)]
pub struct ShellImportSession {
pub cursor: usize,
pub candidates: Vec<crate::config::ShellCandidate>,
pub selected: Vec<bool>,
pub shell_candidates: Vec<crate::config::ShellCandidate>,
pub shell_selected: Vec<bool>,
}
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(),
+19 -3
View File
@@ -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<String> = 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)]
+3 -3
View File
@@ -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),
@@ -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,
+1 -1
View File
@@ -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",
+10 -15
View File
@@ -22,10 +22,7 @@ fn home_hints() -> Vec<Hint> {
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<Hint> {
]
}
fn shell_import_hints() -> Vec<Hint> {
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<Hint> {
vec![
Hint { key: "type", desc: "edit" },
@@ -109,9 +96,18 @@ fn settings_hints() -> Vec<Hint> {
]
}
fn action_menu_hints() -> Vec<Hint> {
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(),
};
+4 -1
View File
@@ -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);
}
+93
View File
@@ -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<ListItem<'_>> = 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(())
}
}
+5 -9
View File
@@ -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(())
+162 -180
View File
@@ -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,124 +25,35 @@ 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<Row<'_>> = app
.session
.import
.candidates
.iter()
.enumerate()
.map(|(idx, item)| {
let mut rows: Vec<Row<'_>> = Vec::new();
let mut entry_row: Vec<usize> = 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
.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 table = Table::new(
rows,
[
Constraint::Length(5),
Constraint::Length(24),
Constraint::Percentage(35),
Constraint::Percentage(45),
],
)
.header(
Row::new([" Use", "Name", "Target", "Identity File"])
.style(Style::default().fg(BLUE).bold()),
)
.block(panel_with_subtitle(
"SSH Config 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<Row<'_>> = 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 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)
Style::default().bg(SELECTED_BG).fg(TEXT).add_modifier(Modifier::BOLD)
} else if has_conflict {
Style::default().fg(MUTED)
} else {
@@ -157,114 +67,186 @@ fn draw_shell_import(frame: &mut Frame<'_>, app: &App, area: Rect) {
let status = item
.conflict
.as_ref()
.map(|conflict| format!("conflict: {}", conflict.name))
.unwrap_or_else(|| "ready".to_string());
Row::new([
.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),
])
})
.collect();
]));
}
let rows = scroll_rows(rows, app.session.shell_import.cursor, area.height);
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(20),
Constraint::Percentage(50),
Constraint::Length(14),
Constraint::Length(22),
Constraint::Percentage(40),
Constraint::Percentage(30),
],
)
.header(Row::new([" Use", "Name", "Path", "Status"]).style(Style::default().fg(BLUE).bold()))
.header(
Row::new([" Use", "Name", "Target", "Identity / Status"])
.style(Style::default().fg(BLUE).add_modifier(Modifier::BOLD)),
)
.block(panel_with_subtitle(
"Detected Shells",
"Space toggle a all A none Enter enable Esc skip",
"Import",
"Space toggle a all A none Enter import Esc cancel",
))
.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
if app.session.import.cursor < shell_len {
let can_toggle = app
.session
.import
.selected
.get_mut(app.session.import.cursor)
.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::Char('a') => app.session.import.selected.fill(true),
KeyCode::Char('A') => 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;
}
+3 -1
View File
@@ -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;