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 { pub fn from_profile(name: &str, profile: &ConnectionProfile, cfg: &SshellConfig) -> Self {
let mut form = Self::blank(); let mut form = Self::blank();
form.edit_name = Some(name.to_string()); 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(", "); form.tags = profile.tags.join(", ");
match &profile.kind { match &profile.kind {
ConnectionType::Ssh { ConnectionType::Ssh {
+5
View File
@@ -69,6 +69,11 @@ impl App {
if name.is_empty() { if name.is_empty() {
bail!("name is required"); 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) if self.session.form.edit_name.as_deref() != Some(&name)
&& self.config.connections.contains_key(&name) && self.config.connections.contains_key(&name)
{ {
+18 -1
View File
@@ -8,6 +8,21 @@ impl App {
self.session.should_quit = true; 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) { pub fn enter_quick_select(&mut self) {
if self.entries().is_empty() { if self.entries().is_empty() {
self.toast("no connections available", false); self.toast("no connections available", false);
@@ -40,7 +55,9 @@ impl App {
pub fn enter_import_selector(&mut self) -> Result<()> { pub fn enter_import_selector(&mut self) -> Result<()> {
self.session.import.candidates = crate::import::load_candidates(&self.config)?; 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.import.cursor = 0;
self.session.mode = Mode::ImportSelector; self.session.mode = Mode::ImportSelector;
Ok(()) Ok(())
+5 -13
View File
@@ -3,7 +3,6 @@ mod form_ops;
mod home_ops; mod home_ops;
mod profile_ext; mod profile_ext;
mod settings_ops; mod settings_ops;
mod shell_ops;
mod types; mod types;
pub mod cred; pub mod cred;
@@ -12,7 +11,7 @@ pub mod settings;
pub use cred::{CredFormField, CredFormState}; pub use cred::{CredFormField, CredFormState};
pub use form::{FormField, FormState}; 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 settings::{SettingsField, SettingsState};
pub use types::*; pub use types::*;
@@ -28,17 +27,10 @@ pub struct App {
impl App { impl App {
pub fn load() -> Result<Self> { pub fn load() -> Result<Self> {
let config = SshellConfig::load()?; let config = SshellConfig::load()?;
let should_pick_shells = !config let app = Self {
.connections
.values()
.any(|profile| matches!(profile.kind, ConnectionType::Shell { .. }));
let mut app = Self {
config, config,
session: Session::new(), session: Session::new(),
}; };
if should_pick_shells {
app.enter_shell_import();
}
Ok(app) Ok(app)
} }
@@ -97,14 +89,14 @@ impl App {
pub fn move_selection(&mut self, delta: isize) { pub fn move_selection(&mut self, delta: isize) {
let len = match self.session.mode { let len = match self.session.mode {
Mode::ImportSelector => self.session.import.candidates.len(), Mode::ImportSelector => {
Mode::ShellImport => self.session.shell_import.candidates.len(), self.session.import.shell_candidates.len() + self.session.import.candidates.len()
}
Mode::Credentials => self.config.credentials.entries.len(), Mode::Credentials => self.config.credentials.entries.len(),
_ => self.entries().len(), _ => self.entries().len(),
}; };
let selected = match self.session.mode { let selected = match self.session.mode {
Mode::ImportSelector => &mut self.session.import.cursor, Mode::ImportSelector => &mut self.session.import.cursor,
Mode::ShellImport => &mut self.session.shell_import.cursor,
Mode::Credentials => &mut self.session.credentials.selected, Mode::Credentials => &mut self.session.credentials.selected,
_ => &mut self.session.home.selected, _ => &mut self.session.home.selected,
}; };
+5 -1
View File
@@ -1,12 +1,16 @@
use crate::config::{ConnectionProfile, ConnectionType}; use crate::config::{ConnectionProfile, ConnectionType};
use std::fs; 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 { pub fn matches_search(name: &str, profile: &ConnectionProfile, query: &str) -> bool {
let query = query.trim().to_lowercase(); let query = query.trim().to_lowercase();
if query.is_empty() { if query.is_empty() {
return true; return true;
} }
if name.to_lowercase().contains(&query) { if display_name(name).to_lowercase().contains(&query) {
return true; return true;
} }
if profile 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)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum Mode { pub enum Mode {
Home, Home,
ActionMenu,
Search, Search,
QuickSelect, QuickSelect,
ShellImport,
Form, Form,
DeleteConfirm, DeleteConfirm,
ImportSelector, ImportSelector,
@@ -147,16 +147,23 @@ pub struct Toast {
pub born: std::time::Instant, pub born: std::time::Instant,
} }
// ── Action Menu ────────────────────────────────────────────────
#[derive(Debug, Clone, Default)]
pub struct ActionMenuSession {
pub cursor: usize,
}
// ── Session ────────────────────────────────────────────────── // ── Session ──────────────────────────────────────────────────
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct Session { pub struct Session {
pub mode: Mode, pub mode: Mode,
pub home: HomeSession, pub home: HomeSession,
pub action_menu: ActionMenuSession,
pub form: FormState, pub form: FormState,
pub credentials: CredentialSession, pub credentials: CredentialSession,
pub import: ImportSession, pub import: ImportSession,
pub shell_import: ShellImportSession,
pub toast: Option<Toast>, pub toast: Option<Toast>,
pub should_quit: bool, pub should_quit: bool,
pub settings: SettingsState, pub settings: SettingsState,
@@ -180,13 +187,8 @@ pub struct ImportSession {
pub cursor: usize, pub cursor: usize,
pub candidates: Vec<ImportCandidate>, pub candidates: Vec<ImportCandidate>,
pub selected: Vec<bool>, pub selected: Vec<bool>,
} pub shell_candidates: Vec<crate::config::ShellCandidate>,
pub shell_selected: Vec<bool>,
#[derive(Debug, Clone, Default)]
pub struct ShellImportSession {
pub cursor: usize,
pub candidates: Vec<crate::config::ShellCandidate>,
pub selected: Vec<bool>,
} }
impl Session { impl Session {
@@ -194,10 +196,10 @@ impl Session {
Self { Self {
mode: Mode::Home, mode: Mode::Home,
home: HomeSession::default(), home: HomeSession::default(),
action_menu: ActionMenuSession::default(),
form: FormState::blank(), form: FormState::blank(),
credentials: CredentialSession::default(), credentials: CredentialSession::default(),
import: ImportSession::default(), import: ImportSession::default(),
shell_import: ShellImportSession::default(),
toast: None, toast: None,
should_quit: false, should_quit: false,
settings: SettingsState::default(), settings: SettingsState::default(),
+19 -3
View File
@@ -149,6 +149,7 @@ impl SshellConfig {
let mut cfg: Self = let mut cfg: Self =
toml::from_str(&raw).with_context(|| format!("failed to parse {}", path.display()))?; toml::from_str(&raw).with_context(|| format!("failed to parse {}", path.display()))?;
cfg.migrate_path_to_embedded(); cfg.migrate_path_to_embedded();
cfg.migrate_shell_prefix();
Ok(cfg) Ok(cfg)
} }
@@ -209,7 +210,7 @@ impl SshellConfig {
} }
let conflict = self let conflict = self
.connections .connections
.contains_key(base_name) .contains_key(&format!("${base_name}"))
.then(|| ShellScanConflict { .then(|| ShellScanConflict {
name: base_name.to_string(), name: base_name.to_string(),
path: path.clone(), path: path.clone(),
@@ -250,7 +251,8 @@ impl SshellConfig {
} }
pub fn add_local_shell(&mut self, candidate: &ShellCandidate) -> Result<()> { 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); bail!("shell name conflict: {}", candidate.name);
} }
let command = candidate.path.to_string_lossy().to_string(); let command = candidate.path.to_string_lossy().to_string();
@@ -260,7 +262,7 @@ impl SshellConfig {
return Ok(()); return Ok(());
} }
self.connections.insert( self.connections.insert(
candidate.name.clone(), key,
ConnectionProfile { ConnectionProfile {
tags: Vec::new(), tags: Vec::new(),
local_tags: vec!["local".to_string(), "scanned".to_string()], local_tags: vec!["local".to_string(), "scanned".to_string()],
@@ -296,6 +298,20 @@ impl SshellConfig {
*path = None; *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)] #[cfg(unix)]
+3 -3
View File
@@ -12,8 +12,8 @@ use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration; use std::time::Duration;
use super::view::{ use super::view::{
CredFormView, CredListView, DeleteConfirmView, FormView, HomeListView, ImportView, ActionMenuView, CredFormView, CredListView, DeleteConfirmView, FormView, HomeListView,
QuickSelectView, SearchView, SettingsView, ShellImportView, View, ImportView, QuickSelectView, SearchView, SettingsView, View,
}; };
static TERMINAL_RESTORED: AtomicBool = AtomicBool::new(false); static TERMINAL_RESTORED: AtomicBool = AtomicBool::new(false);
@@ -71,12 +71,12 @@ fn handle_key(app: &mut App, key: KeyEvent) -> Result<()> {
} }
match app.session.mode { match app.session.mode {
Mode::Home => HomeListView.handle_key(app, key), Mode::Home => HomeListView.handle_key(app, key),
Mode::ActionMenu => ActionMenuView.handle_key(app, key),
Mode::Search => SearchView.handle_key(app, key), Mode::Search => SearchView.handle_key(app, key),
Mode::QuickSelect => QuickSelectView.handle_key(app, key), Mode::QuickSelect => QuickSelectView.handle_key(app, key),
Mode::DeleteConfirm => DeleteConfirmView.handle_key(app, key), Mode::DeleteConfirm => DeleteConfirmView.handle_key(app, key),
Mode::Form => FormView.handle_key(app, key), Mode::Form => FormView.handle_key(app, key),
Mode::ImportSelector => ImportView.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::Credentials => CredListView.handle_key(app, key),
Mode::CredForm => CredFormView.handle_key(app, key), Mode::CredForm => CredFormView.handle_key(app, key),
Mode::Settings => SettingsView.handle_key(app, key), Mode::Settings => SettingsView.handle_key(app, key),
@@ -1,9 +1,11 @@
use crate::app::App; use crate::app::App;
use crate::app::display_name;
use crate::ui::RED; use crate::ui::RED;
use crate::ui::component::dialog::draw_dialog; use crate::ui::component::dialog::draw_dialog;
pub fn draw_delete_confirm(frame: &mut ratatui::Frame<'_>, app: &App) { pub fn draw_delete_confirm(frame: &mut ratatui::Frame<'_>, app: &App) {
let name = app.selected_name().unwrap_or_default(); let name = app.selected_name().unwrap_or_default();
let name = display_name(&name);
draw_dialog( draw_dialog(
frame, frame,
46, 46,
+1 -1
View File
@@ -11,12 +11,12 @@ use ratatui::{
pub fn draw_header(frame: &mut ratatui::Frame<'_>, app: &App, area: Rect) { pub fn draw_header(frame: &mut ratatui::Frame<'_>, app: &App, area: Rect) {
let mode = match app.session.mode { let mode = match app.session.mode {
Mode::Home => "Home", Mode::Home => "Home",
Mode::ActionMenu => "Actions",
Mode::Search => "Search", Mode::Search => "Search",
Mode::QuickSelect => "Quick Select", Mode::QuickSelect => "Quick Select",
Mode::Form => "Editor", Mode::Form => "Editor",
Mode::DeleteConfirm => "Delete", Mode::DeleteConfirm => "Delete",
Mode::ImportSelector => "Import", Mode::ImportSelector => "Import",
Mode::ShellImport => "Shells",
Mode::Credentials => "Credentials", Mode::Credentials => "Credentials",
Mode::CredForm => "Cred Editor", Mode::CredForm => "Cred Editor",
Mode::Settings => "Settings", Mode::Settings => "Settings",
+10 -15
View File
@@ -22,10 +22,7 @@ fn home_hints() -> Vec<Hint> {
Hint { key: "a", desc: "add" }, Hint { key: "a", desc: "add" },
Hint { key: "e", desc: "edit" }, Hint { key: "e", desc: "edit" },
Hint { key: "d", desc: "delete" }, Hint { key: "d", desc: "delete" },
Hint { key: "r", desc: "scan" }, Hint { key: ":", desc: "actions" },
Hint { key: "p/P", desc: "push/pull" },
Hint { key: "c", desc: "creds" },
Hint { key: "s", desc: "settings" },
Hint { key: "q", desc: "quit" }, 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> { fn settings_hints() -> Vec<Hint> {
vec![ vec![
Hint { key: "type", desc: "edit" }, 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) { pub fn draw_help(frame: &mut ratatui::Frame<'_>, app: &App, area: Rect) {
let hints = match app.session.mode { let hints = match app.session.mode {
Mode::Home => home_hints(), Mode::Home => home_hints(),
Mode::ActionMenu => action_menu_hints(),
Mode::Search => search_hints(), Mode::Search => search_hints(),
Mode::QuickSelect => quick_select_hints(), Mode::QuickSelect => quick_select_hints(),
Mode::Form => form_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::Credentials => credentials_hints(),
Mode::CredForm => cred_form_hints(), Mode::CredForm => cred_form_hints(),
Mode::ImportSelector => import_hints(), Mode::ImportSelector => import_hints(),
Mode::ShellImport => shell_import_hints(),
Mode::Settings => settings_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; use view::View;
match app.session.mode { match app.session.mode {
Mode::Home => view::HomeListView.draw(frame, app, content), 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::Search => view::SearchView.draw(frame, app, content),
Mode::QuickSelect => view::QuickSelectView.draw(frame, app, content), Mode::QuickSelect => view::QuickSelectView.draw(frame, app, content),
Mode::DeleteConfirm => view::DeleteConfirmView.draw(frame, app, content), Mode::DeleteConfirm => view::DeleteConfirmView.draw(frame, app, content),
Mode::Form => view::FormView.draw(frame, app, content), Mode::Form => view::FormView.draw(frame, app, content),
Mode::ImportSelector => view::ImportView.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::Credentials => view::CredListView.draw(frame, app, content),
Mode::CredForm => view::CredFormView.draw(frame, app, content), Mode::CredForm => view::CredFormView.draw(frame, app, content),
Mode::Settings => view::SettingsView.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 { if app.session.mode == Mode::DeleteConfirm {
draw_delete_confirm(frame, app); draw_delete_confirm(frame, app);
} }
if app.session.mode == Mode::ActionMenu {
view::ActionMenuView.draw(frame, app, content);
}
if let Some(toast) = &app.session.toast { if let Some(toast) = &app.session.toast {
draw_toast(frame, toast.message.as_str(), toast.success); 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::{App, Mode};
use crate::app::display_name;
use crate::config::{ConnectionSource, ConnectionType, CredentialEntry}; use crate::config::{ConnectionSource, ConnectionType, CredentialEntry};
use crate::ui::component::{badge_span, draw_input, panel, tag_badge}; 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}; 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() { if entries.is_empty() {
let message = if app.session.home.search.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 { } else {
"\n No matching connections\n\n Press Esc to clear search" "\n No matching connections\n\n Press Esc to clear search"
}; };
@@ -259,7 +260,7 @@ fn connection_row(
}; };
Row::new([ 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( Cell::from(type_badge).style(
Style::default() Style::default()
.fg(crate::ui::BG) .fg(crate::ui::BG)
@@ -301,7 +302,7 @@ pub fn draw_detail_panel(frame: &mut Frame<'_>, app: &App, area: Rect) {
Span::raw(" "), Span::raw(" "),
badge_span(badge, badge_color), badge_span(badge, badge_color),
Span::raw(" "), 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![ lines.push(Line::from(vec![
Span::raw(" "), Span::raw(" "),
@@ -452,12 +453,7 @@ fn handle_home(app: &mut App, key: KeyEvent) -> Result<()> {
KeyCode::Char('a') => app.new_form(), KeyCode::Char('a') => app.new_form(),
KeyCode::Char('e') => app.edit_form(), KeyCode::Char('e') => app.edit_form(),
KeyCode::Char('d') => app.enter_delete_confirm_for_selected(), KeyCode::Char('d') => app.enter_delete_confirm_for_selected(),
KeyCode::Char('i') => app.enter_import_selector()?, KeyCode::Char(':') => app.enter_action_menu(),
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(),
_ => {} _ => {}
} }
Ok(()) Ok(())
+162 -180
View File
@@ -2,7 +2,7 @@ use crate::app::App;
use crate::ui::component::{panel, panel_with_subtitle}; use crate::ui::component::{panel, panel_with_subtitle};
use crate::ui::{BLUE, GREEN, MUTED, SELECTED_BG, TEXT}; use crate::ui::{BLUE, GREEN, MUTED, SELECTED_BG, TEXT};
use super::{View, scroll_rows}; use super::View;
use anyhow::Result; use anyhow::Result;
use crossterm::event::{KeyCode, KeyEvent}; use crossterm::event::{KeyCode, KeyEvent};
@@ -14,7 +14,6 @@ use ratatui::{
}; };
pub struct ImportView; pub struct ImportView;
pub struct ShellImportView;
impl View for ImportView { impl View for ImportView {
fn draw(&self, frame: &mut Frame<'_>, app: &App, area: Rect) { 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 ────────────────────────────────────────────────── // ── Rendering ──────────────────────────────────────────────────
fn draw_import(frame: &mut Frame<'_>, app: &App, area: Rect) { fn draw_import(frame: &mut Frame<'_>, app: &App, area: Rect) {
if app.session.import.candidates.is_empty() { let shell_len = app.session.import.shell_candidates.len();
Paragraph::new("\n No importable hosts found in ~/.ssh/config") 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) .fg(MUTED)
.alignment(Alignment::Center) .alignment(Alignment::Center)
.block(panel("SSH Config Import")) .block(panel("Import"))
.render(area, frame.buffer_mut()); .render(area, frame.buffer_mut());
return; return;
} }
let rows: Vec<Row<'_>> = app
.session let mut rows: Vec<Row<'_>> = Vec::new();
.import let mut entry_row: Vec<usize> = Vec::new(); // item_idx -> visual row
.candidates
.iter() if shell_len > 0 {
.enumerate() rows.push(section_row("Shell", shell_len));
.map(|(idx, item)| { }
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 selected_row = idx == app.session.import.cursor;
let checked = app let checked = app.session.import.shell_selected.get(idx).copied().unwrap_or(false);
.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 has_conflict = item.conflict.is_some(); let has_conflict = item.conflict.is_some();
let style = if selected_row { let style = if selected_row {
Style::default() Style::default().bg(SELECTED_BG).fg(TEXT).add_modifier(Modifier::BOLD)
.bg(SELECTED_BG)
.fg(TEXT)
.add_modifier(Modifier::BOLD)
} else if has_conflict { } else if has_conflict {
Style::default().fg(MUTED) Style::default().fg(MUTED)
} else { } else {
@@ -157,114 +67,186 @@ fn draw_shell_import(frame: &mut Frame<'_>, app: &App, area: Rect) {
let status = item let status = item
.conflict .conflict
.as_ref() .as_ref()
.map(|conflict| format!("conflict: {}", conflict.name)) .map(|c| format!("conflict: {}", c.name))
.unwrap_or_else(|| "ready".to_string()); .unwrap_or_else(|| "-".to_string());
Row::new([ rows.push(Row::new([
Cell::from(if checked { " [x]" } else { " [ ]" }).style(check_style), Cell::from(if checked { " [x]" } else { " [ ]" }).style(check_style),
Cell::from(item.name.clone()).style(style), Cell::from(item.name.clone()).style(style),
Cell::from(item.path.display().to_string()).style(style), Cell::from(item.path.display().to_string()).style(style),
Cell::from(status).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( let table = Table::new(
rows, rows,
[ [
Constraint::Length(5), Constraint::Length(14),
Constraint::Length(20), Constraint::Length(22),
Constraint::Percentage(50), Constraint::Percentage(40),
Constraint::Percentage(30), 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( .block(panel_with_subtitle(
"Detected Shells", "Import",
"Space toggle a all A none Enter enable Esc skip", "Space toggle a all A none Enter import Esc cancel",
)) ))
.column_spacing(2); .column_spacing(2);
frame.render_widget(table, area); 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 ─────────────────────────────────────────────── // ── Key handling ───────────────────────────────────────────────
fn handle_import(app: &mut App, key: KeyEvent) -> Result<()> { 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 { match key.code {
KeyCode::Esc => app.session.mode = crate::app::Mode::Home, KeyCode::Esc => app.session.mode = crate::app::Mode::Home,
KeyCode::Down | KeyCode::Char('j') => app.move_selection(1), KeyCode::Down | KeyCode::Char('j') if total > 0 => {
KeyCode::Up | KeyCode::Char('k') => app.move_selection(-1), 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(' ') => { KeyCode::Char(' ') => {
if let Some(v) = app if app.session.import.cursor < shell_len {
let can_toggle = app
.session .session
.import .import
.selected .shell_candidates
.get_mut(app.session.import.cursor) .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; *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 => { 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 .session
.import .import
.candidates .candidates
.iter() .iter()
.zip(&app.session.import.selected) .zip(&app.session.import.selected)
.filter_map(|(item, selected)| selected.then_some(item.clone())) .filter_map(|(item, &selected)| selected.then_some(item.clone()))
.collect(); .collect();
match crate::import::import_candidates(&mut app.config, &picked) { if !picked_ssh.is_empty() {
Ok(count) => app.toast(format!("imported {count} connections"), true), match crate::import::import_candidates(&mut app.config, &picked_ssh) {
Err(err) => app.toast(err.to_string(), false), Ok(count) => imported += count,
} Err(err) => {
app.session.mode = crate::app::Mode::Home; app.toast(err.to_string(), false);
} app.session.mode = crate::app::Mode::Home;
_ => {} return Ok(());
} }
Ok(()) }
} }
if imported > 0 {
fn handle_shell_import(app: &mut App, key: KeyEvent) -> Result<()> { app.config.save()?;
match key.code { app.toast(format!("imported {imported} items"), true);
KeyCode::Esc => app.session.mode = crate::app::Mode::Home, } else {
KeyCode::Down | KeyCode::Char('j') => app.move_selection(1), app.toast("nothing selected", false);
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),
} }
app.session.mode = crate::app::Mode::Home; app.session.mode = crate::app::Mode::Home;
} }
+3 -1
View File
@@ -1,3 +1,4 @@
mod action_menu;
mod cred_form; mod cred_form;
mod cred_list; mod cred_list;
mod delete_confirm_view; mod delete_confirm_view;
@@ -23,12 +24,13 @@ pub trait View {
fn handle_key(&self, app: &mut App, key: KeyEvent) -> Result<()>; fn handle_key(&self, app: &mut App, key: KeyEvent) -> Result<()>;
} }
pub use action_menu::ActionMenuView;
pub use cred_form::CredFormView; pub use cred_form::CredFormView;
pub use cred_list::CredListView; pub use cred_list::CredListView;
pub use delete_confirm_view::DeleteConfirmView; pub use delete_confirm_view::DeleteConfirmView;
pub use form::FormView; pub use form::FormView;
pub use home_list::HomeListView; pub use home_list::HomeListView;
pub use import::{ImportView, ShellImportView}; pub use import::ImportView;
pub use quick_select::QuickSelectView; pub use quick_select::QuickSelectView;
pub use search::SearchView; pub use search::SearchView;
pub use settings::SettingsView; pub use settings::SettingsView;