refactor: replace keybinds with action menu, unify import view, add $ prefix for shells
This commit is contained in:
+1
-1
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user