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
+182 -200
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,245 +25,228 @@ impl View for ImportView {
}
}
impl View for ShellImportView {
fn draw(&self, frame: &mut Frame<'_>, app: &App, area: Rect) {
draw_shell_import(frame, app, area);
}
fn handle_key(&self, app: &mut App, key: KeyEvent) -> Result<()> {
handle_shell_import(app, key)
}
}
// ── Rendering ──────────────────────────────────────────────────
fn draw_import(frame: &mut Frame<'_>, app: &App, area: Rect) {
if app.session.import.candidates.is_empty() {
Paragraph::new("\n No importable hosts found in ~/.ssh/config")
let shell_len = app.session.import.shell_candidates.len();
let ssh_len = app.session.import.candidates.len();
let total = shell_len + ssh_len;
if total == 0 {
Paragraph::new("\n No importable hosts or shells found")
.fg(MUTED)
.alignment(Alignment::Center)
.block(panel("SSH Config Import"))
.block(panel("Import"))
.render(area, frame.buffer_mut());
return;
}
let rows: Vec<Row<'_>> = app
.session
.import
.candidates
.iter()
.enumerate()
.map(|(idx, item)| {
let selected_row = idx == app.session.import.cursor;
let checked = app
.session
.import
.selected
.get(idx)
.copied()
.unwrap_or(false);
let style = if selected_row {
Style::default()
.bg(SELECTED_BG)
.fg(TEXT)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(TEXT)
};
let check_style = if checked {
Style::default().fg(GREEN).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(MUTED)
};
Row::new([
Cell::from(if checked { " [x]" } else { " [ ]" }).style(check_style),
Cell::from(item.name.clone()).style(style),
Cell::from(format!("{}@{}:{}", item.user, item.host, item.port)).style(style),
Cell::from(
item.identity_file
.as_ref()
.map(|p| p.display().to_string())
.unwrap_or_else(|| "-".to_string()),
)
.style(style),
])
})
.collect();
let rows = scroll_rows(rows, app.session.import.cursor, area.height);
let mut rows: Vec<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.shell_selected.get(idx).copied().unwrap_or(false);
let has_conflict = item.conflict.is_some();
let style = if selected_row {
Style::default().bg(SELECTED_BG).fg(TEXT).add_modifier(Modifier::BOLD)
} else if has_conflict {
Style::default().fg(MUTED)
} else {
Style::default().fg(TEXT)
};
let check_style = if checked {
Style::default().fg(GREEN).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(MUTED)
};
let status = item
.conflict
.as_ref()
.map(|c| format!("conflict: {}", c.name))
.unwrap_or_else(|| "-".to_string());
rows.push(Row::new([
Cell::from(if checked { " [x]" } else { " [ ]" }).style(check_style),
Cell::from(item.name.clone()).style(style),
Cell::from(item.path.display().to_string()).style(style),
Cell::from(status).style(style),
]));
}
if shell_len > 0 && ssh_len > 0 {
rows.push(Row::new(["", "", "", ""]).height(1));
}
if ssh_len > 0 {
rows.push(section_row("SSH", ssh_len));
}
for (idx, item) in app.session.import.candidates.iter().enumerate() {
entry_row.push(rows.len());
let selected_row = (shell_len + idx) == app.session.import.cursor;
let checked = app.session.import.selected.get(idx).copied().unwrap_or(false);
let style = if selected_row {
Style::default().bg(SELECTED_BG).fg(TEXT).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(TEXT)
};
let check_style = if checked {
Style::default().fg(GREEN).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(MUTED)
};
rows.push(Row::new([
Cell::from(if checked { " [x]" } else { " [ ]" }).style(check_style),
Cell::from(item.name.clone()).style(style),
Cell::from(format!("{}@{}:{}", item.user, item.host, item.port)).style(style),
Cell::from(
item.identity_file
.as_ref()
.map(|p| p.display().to_string())
.unwrap_or_else(|| "-".to_string()),
)
.style(style),
]));
}
// Scroll to keep selected entry visible
let visible = area.height.saturating_sub(3) as usize;
if visible > 0 && !entry_row.is_empty() {
let sel_row = entry_row[app.session.import.cursor.min(entry_row.len() - 1)];
let total_rows = rows.len();
if total_rows > visible {
let scroll = if sel_row < visible / 2 {
0
} else if sel_row + visible / 2 >= total_rows {
total_rows.saturating_sub(visible)
} else {
sel_row - visible / 2
};
rows = rows.into_iter().skip(scroll).take(visible).collect();
}
}
let table = Table::new(
rows,
[
Constraint::Length(5),
Constraint::Length(24),
Constraint::Percentage(35),
Constraint::Percentage(45),
Constraint::Length(14),
Constraint::Length(22),
Constraint::Percentage(40),
Constraint::Percentage(30),
],
)
.header(
Row::new([" Use", "Name", "Target", "Identity File"])
.style(Style::default().fg(BLUE).bold()),
Row::new([" Use", "Name", "Target", "Identity / Status"])
.style(Style::default().fg(BLUE).add_modifier(Modifier::BOLD)),
)
.block(panel_with_subtitle(
"SSH Config Import",
"Import",
"Space toggle a all A none Enter import Esc cancel",
))
.column_spacing(2);
frame.render_widget(table, area);
}
fn draw_shell_import(frame: &mut Frame<'_>, app: &App, area: Rect) {
if app.session.shell_import.candidates.is_empty() {
Paragraph::new("\n No new local shells found\n\n Press Esc to return home")
.fg(MUTED)
.alignment(Alignment::Center)
.block(panel("Detected Shells"))
.render(area, frame.buffer_mut());
return;
}
let rows: Vec<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 style = if selected_row {
Style::default()
.bg(SELECTED_BG)
.fg(TEXT)
.add_modifier(Modifier::BOLD)
} else if has_conflict {
Style::default().fg(MUTED)
} else {
Style::default().fg(TEXT)
};
let check_style = if checked {
Style::default().fg(GREEN).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(MUTED)
};
let status = item
.conflict
.as_ref()
.map(|conflict| format!("conflict: {}", conflict.name))
.unwrap_or_else(|| "ready".to_string());
Row::new([
Cell::from(if checked { " [x]" } else { " [ ]" }).style(check_style),
Cell::from(item.name.clone()).style(style),
Cell::from(item.path.display().to_string()).style(style),
Cell::from(status).style(style),
])
})
.collect();
let rows = scroll_rows(rows, app.session.shell_import.cursor, area.height);
let table = Table::new(
rows,
[
Constraint::Length(5),
Constraint::Length(20),
Constraint::Percentage(50),
Constraint::Percentage(30),
],
)
.header(Row::new([" Use", "Name", "Path", "Status"]).style(Style::default().fg(BLUE).bold()))
.block(panel_with_subtitle(
"Detected Shells",
"Space toggle a all A none Enter enable Esc skip",
))
.column_spacing(2);
frame.render_widget(table, area);
fn section_row(label: &str, count: usize) -> Row<'static> {
Row::new([
Cell::from(format!(" {label} ({count})"))
.style(Style::default().fg(BLUE).add_modifier(Modifier::BOLD)),
Cell::from(""),
Cell::from(""),
Cell::from(""),
])
.height(1)
}
// ── Key handling ───────────────────────────────────────────────
fn handle_import(app: &mut App, key: KeyEvent) -> Result<()> {
let shell_len = app.session.import.shell_candidates.len();
let total = shell_len + app.session.import.candidates.len();
match key.code {
KeyCode::Esc => app.session.mode = crate::app::Mode::Home,
KeyCode::Down | KeyCode::Char('j') => app.move_selection(1),
KeyCode::Up | KeyCode::Char('k') => app.move_selection(-1),
KeyCode::Down | KeyCode::Char('j') if total > 0 => {
app.session.import.cursor = (app.session.import.cursor + 1) % total;
}
KeyCode::Up | KeyCode::Char('k') if total > 0 => {
app.session.import.cursor = (app.session.import.cursor + total - 1) % total;
}
KeyCode::Char(' ') => {
if let Some(v) = app
.session
.import
.selected
.get_mut(app.session.import.cursor)
{
*v = !*v;
if app.session.import.cursor < shell_len {
let can_toggle = app
.session
.import
.shell_candidates
.get(app.session.import.cursor)
.is_some_and(|c| c.conflict.is_none());
if can_toggle
&& let Some(v) = app.session.import.shell_selected.get_mut(app.session.import.cursor)
{
*v = !*v;
}
} else {
let ssh_idx = app.session.import.cursor - shell_len;
if let Some(v) = app.session.import.selected.get_mut(ssh_idx) {
*v = !*v;
}
}
}
KeyCode::Char('a') => app.session.import.selected.fill(true),
KeyCode::Char('A') => app.session.import.selected.fill(false),
KeyCode::Char('a') => {
app.session
.import
.shell_selected
.iter_mut()
.zip(&app.session.import.shell_candidates)
.for_each(|(sel, c)| *sel = c.conflict.is_none());
app.session.import.selected.fill(true);
}
KeyCode::Char('A') => {
app.session.import.shell_selected.fill(false);
app.session.import.selected.fill(false);
}
KeyCode::Enter => {
let picked: Vec<_> = app
let mut imported = 0;
let picked_shells: Vec<_> = app
.session
.import
.shell_candidates
.iter()
.zip(&app.session.import.shell_selected)
.filter_map(|(item, &selected)| selected.then_some(item.clone()))
.collect();
for candidate in &picked_shells {
if candidate.conflict.is_none() {
app.config.add_local_shell(candidate)?;
imported += 1;
}
}
let picked_ssh: Vec<_> = app
.session
.import
.candidates
.iter()
.zip(&app.session.import.selected)
.filter_map(|(item, selected)| selected.then_some(item.clone()))
.filter_map(|(item, &selected)| selected.then_some(item.clone()))
.collect();
match crate::import::import_candidates(&mut app.config, &picked) {
Ok(count) => app.toast(format!("imported {count} connections"), true),
Err(err) => app.toast(err.to_string(), false),
}
app.session.mode = crate::app::Mode::Home;
}
_ => {}
}
Ok(())
}
fn handle_shell_import(app: &mut App, key: KeyEvent) -> Result<()> {
match key.code {
KeyCode::Esc => app.session.mode = crate::app::Mode::Home,
KeyCode::Down | KeyCode::Char('j') => app.move_selection(1),
KeyCode::Up | KeyCode::Char('k') => app.move_selection(-1),
KeyCode::Char(' ') => {
let can_select = app
.session
.shell_import
.candidates
.get(app.session.shell_import.cursor)
.is_some_and(|candidate| candidate.conflict.is_none());
if can_select
&& let Some(v) = app
.session
.shell_import
.selected
.get_mut(app.session.shell_import.cursor)
{
*v = !*v;
}
}
KeyCode::Char('a') => {
for (selected, candidate) in app
.session
.shell_import
.selected
.iter_mut()
.zip(&app.session.shell_import.candidates)
{
*selected = candidate.conflict.is_none();
}
}
KeyCode::Char('A') => app.session.shell_import.selected.fill(false),
KeyCode::Enter => {
match app.import_selected_shells() {
Ok(count) => app.toast(format!("enabled {count} shells"), true),
Err(err) => app.toast(err.to_string(), false),
if !picked_ssh.is_empty() {
match crate::import::import_candidates(&mut app.config, &picked_ssh) {
Ok(count) => imported += count,
Err(err) => {
app.toast(err.to_string(), false);
app.session.mode = crate::app::Mode::Home;
return Ok(());
}
}
}
if imported > 0 {
app.config.save()?;
app.toast(format!("imported {imported} items"), true);
} else {
app.toast("nothing selected", false);
}
app.session.mode = crate::app::Mode::Home;
}