refactor: consolidate form/sync dispatch and trim unused helpers

This commit is contained in:
2026-06-05 19:17:36 +08:00
parent 8e6d732122
commit c78f1b7c08
14 changed files with 131 additions and 195 deletions
+1 -5
View File
@@ -91,11 +91,7 @@ impl FormNav for CredFormState {
impl TextEditing for CredFormState {
fn active_text(&self) -> &str {
match self.active {
CredFormField::Name => &self.name,
CredFormField::Value => &self.value,
_ => "",
}
self.field_value(self.active)
}
fn active_text_mut(&mut self) -> Option<&mut String> {
+5 -37
View File
@@ -220,19 +220,7 @@ impl FormNav for FormState {
impl TextEditing for FormState {
fn active_text(&self) -> &str {
match self.active {
FormField::Name => &self.name,
FormField::Host => &self.host,
FormField::Port => &self.port,
FormField::User => &self.user,
FormField::CredId => &self.auth_ref,
FormField::Secret => &self.secret,
FormField::Command => &self.command,
FormField::SyncArgs => &self.sync_args,
FormField::LocalArgs => &self.local_args,
FormField::Tags => &self.tags,
_ => "",
}
self.field_value(self.active)
}
fn active_text_mut(&mut self) -> Option<&mut String> {
@@ -296,14 +284,7 @@ impl App {
self.config.deleted.insert(name.clone(), ts);
if let Some(auth_ref) = profile.auth_ref() {
let still_used = self
.config
.connections
.values()
.any(|p| p.auth_ref() == Some(auth_ref));
if !still_used {
self.config.credentials.entries.shift_remove(auth_ref);
}
self.config.prune_credential_if_unused(auth_ref, None);
}
self.config.save()?;
self.session.home.selected = self
@@ -435,7 +416,9 @@ impl App {
fn save_form_credential(&mut self, name: &str, auth_ref: &str, old_auth_ref: Option<String>) {
if self.session.form.is_shell {
self.remove_unused_old_credential(name, old_auth_ref);
if let Some(old) = old_auth_ref {
self.config.prune_credential_if_unused(&old, Some(name));
}
} else if !self.session.form.secret.is_empty() {
let secret = resolve_secret(&self.session.form.secret);
let entry = match self.session.form.auth_kind {
@@ -448,21 +431,6 @@ impl App {
.insert(auth_ref.to_string(), entry);
}
}
fn remove_unused_old_credential(&mut self, editing_name: &str, old_auth_ref: Option<String>) {
let Some(old_auth_ref) = old_auth_ref else {
return;
};
let still_used = self
.config
.connections
.iter()
.filter(|(conn_name, _)| conn_name.as_str() != editing_name)
.any(|(_, profile)| profile.auth_ref() == Some(old_auth_ref.as_str()));
if !still_used {
self.config.credentials.entries.shift_remove(&old_auth_ref);
}
}
}
fn parse_tags(raw: &str) -> Vec<String> {
+1 -15
View File
@@ -1,6 +1,5 @@
use anyhow::Result;
use crate::config::SyncBackend;
use super::{App, Mode};
impl App {
@@ -13,16 +12,6 @@ impl App {
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);
@@ -64,10 +53,7 @@ impl App {
}
pub fn sync_with_toast(&mut self) {
let result = match self.config.settings.backend {
SyncBackend::Gist => crate::sync::gist::sync(&mut self.config),
SyncBackend::Webdav => crate::sync::webdav::sync(&mut self.config),
};
let result = crate::sync::run_sync(&mut self.config);
match result {
Ok(report) => self.toast(report.to_string(), true),
Err(err) => self.toast(err.to_string(), false),
+12 -22
View File
@@ -1,6 +1,12 @@
use super::{FormNav, TextEditing};
use crate::config::SyncBackend;
/// Trim whitespace and return None if the result is empty.
fn trimmed_opt(s: &str) -> Option<String> {
let s = s.trim().to_string();
if s.is_empty() { None } else { Some(s) }
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SettingsField {
SyncPassword,
@@ -128,14 +134,7 @@ impl Default for SettingsState {
impl TextEditing for SettingsState {
fn active_text(&self) -> &str {
match self.active {
SettingsField::SyncPassword => &self.password,
SettingsField::GistId => &self.gist_id,
SettingsField::WebdavUrl => &self.webdav_url,
SettingsField::WebdavUser => &self.webdav_user,
SettingsField::WebdavPassword => &self.webdav_password,
_ => "",
}
self.field_text(self.active)
}
fn active_text_mut(&mut self) -> Option<&mut String> {
@@ -187,22 +186,13 @@ impl App {
}
pub fn save_settings(&mut self) -> Result<()> {
let pw = self.session.settings.password.trim().to_string();
self.config.settings.sync_password = if pw.is_empty() { None } else { Some(pw) };
self.config.settings.sync_password = trimmed_opt(&self.session.settings.password);
self.config.settings.backend = self.session.settings.backend;
self.config.settings.sync_on_start = self.session.settings.sync_on_start;
let gist = self.session.settings.gist_id.trim().to_string();
self.config.settings.gist_id = if gist.is_empty() { None } else { Some(gist) };
let url = self.session.settings.webdav_url.trim().to_string();
self.config.settings.webdav_url = if url.is_empty() { None } else { Some(url) };
let user = self.session.settings.webdav_user.trim().to_string();
self.config.settings.webdav_user = if user.is_empty() { None } else { Some(user) };
let wd_pw = self.session.settings.webdav_password.trim().to_string();
self.config.settings.webdav_password = if wd_pw.is_empty() {
None
} else {
Some(wd_pw)
};
self.config.settings.gist_id = trimmed_opt(&self.session.settings.gist_id);
self.config.settings.webdav_url = trimmed_opt(&self.session.settings.webdav_url);
self.config.settings.webdav_user = trimmed_opt(&self.session.settings.webdav_user);
self.config.settings.webdav_password = trimmed_opt(&self.session.settings.webdav_password);
self.config.save()?;
self.session.mode = Mode::Home;
Ok(())
+3 -9
View File
@@ -1,5 +1,5 @@
use crate::config::{ConnectionType, CredentialEntry, SshellConfig, SyncBackend, config_path, find_binary};
use crate::sync::{gist, webdav};
use crate::sync;
use crate::{connection, import, ui};
use anyhow::{Context, Result, bail};
use clap::{Parser, Subcommand};
@@ -55,10 +55,7 @@ pub fn run() -> Result<()> {
fn run_sync() -> Result<()> {
let mut cfg = SshellConfig::load()?;
let report = match cfg.settings.backend {
SyncBackend::Gist => gist::sync(&mut cfg)?,
SyncBackend::Webdav => webdav::sync(&mut cfg)?,
};
let report = sync::run_sync(&mut cfg)?;
println!("{report}");
Ok(())
}
@@ -143,13 +140,10 @@ fn check_connection(
}
ConnectionType::Shell {
command,
sync_args,
local_args,
..
} => {
println!("type: shell");
let mut merged_args = sync_args.clone();
merged_args.extend(local_args.clone());
let merged_args = profile.merged_shell_args();
println!("command: {command} {}", merged_args.join(" "));
}
}
+26
View File
@@ -199,6 +199,19 @@ impl SshellConfig {
.unwrap_or(0)
+ 1
}
/// Remove a credential if it is no longer referenced by any connection.
/// `exclude` is an optional connection name to skip during the check (e.g. the one being renamed).
pub fn prune_credential_if_unused(&mut self, auth_ref: &str, exclude: Option<&str>) {
let still_used = self
.connections
.iter()
.filter(|(name, _)| Some(name.as_str()) != exclude)
.any(|(_, profile)| profile.auth_ref() == Some(auth_ref));
if !still_used {
self.credentials.entries.shift_remove(auth_ref);
}
}
}
impl CredentialEntry {
@@ -235,6 +248,19 @@ impl ConnectionProfile {
ConnectionType::Shell { sync, .. } => *sync,
}
}
/// For Shell connections, returns the merged sync_args + local_args.
/// Returns an empty vec for SSH connections.
pub fn merged_shell_args(&self) -> Vec<String> {
match &self.kind {
ConnectionType::Shell { sync_args, local_args, .. } => {
let mut out = sync_args.clone();
out.extend(local_args.iter().cloned());
out
}
ConnectionType::Ssh { .. } => Vec::new(),
}
}
}
pub fn expand_user_path(value: &str) -> PathBuf {
+1 -4
View File
@@ -49,12 +49,9 @@ pub fn connect(name: &str, cfg: &SshellConfig) -> Result<()> {
} => connect_ssh(cfg, host, *port, user, auth_ref),
ConnectionType::Shell {
command,
sync_args,
local_args,
..
} => {
let mut merged_args = sync_args.clone();
merged_args.extend(local_args.clone());
let merged_args = profile.merged_shell_args();
exec_shell(command, &merged_args)
}
}
+11 -7
View File
@@ -2,6 +2,8 @@ mod crypto;
pub mod gist;
pub mod webdav;
use crate::config::SyncBackend;
use crate::config::ConnectionType;
use crate::config::{ConnectionSource, CredentialStore, SshellConfig};
use anyhow::{Context, Result};
@@ -197,13 +199,7 @@ pub(crate) fn bidirectional_merge(
if let Some(profile) = removed
&& let Some(auth_ref) = profile.auth_ref()
{
let still_used = cfg
.connections
.values()
.any(|p| p.auth_ref() == Some(auth_ref));
if !still_used {
cfg.credentials.entries.shift_remove(auth_ref);
}
cfg.prune_credential_if_unused(auth_ref, None);
}
report.deleted += 1;
}
@@ -378,3 +374,11 @@ pub(crate) fn count_synced(cfg: &SshellConfig) -> usize {
pub(crate) fn to_toml_value<T: serde::Serialize>(val: &T) -> Result<toml::Value> {
toml::Value::try_from(val).map_err(|e| anyhow::anyhow!("toml conversion failed: {e}"))
}
/// Run sync using the configured backend.
pub fn run_sync(cfg: &mut crate::config::SshellConfig) -> Result<SyncReport> {
match cfg.settings.backend {
SyncBackend::Gist => gist::sync(cfg),
SyncBackend::Webdav => webdav::sync(cfg),
}
}
+30 -21
View File
@@ -75,32 +75,41 @@ pub fn draw(frame: &mut Frame<'_>, app: &mut crate::app::App) {
});
use view::View;
let (title, hints): (&str, Vec<_>) = match app.session.mode {
Mode::Home => (view::HomeListView.title(), view::HomeListView.hints()),
Mode::ActionMenu => (view::ActionMenuView.title(), view::ActionMenuView.hints()),
Mode::Search => (view::SearchView.title(), view::SearchView.hints()),
Mode::QuickSelect => (view::QuickSelectView.title(), view::QuickSelectView.hints()),
Mode::DeleteConfirm => (view::DeleteConfirmView.title(), view::DeleteConfirmView.hints()),
Mode::Form => (view::FormView.title(), view::FormView.hints()),
Mode::ImportSelector => (view::ImportView.title(), view::ImportView.hints()),
Mode::Credentials => (view::CredListView.title(), view::CredListView.hints()),
Mode::CredForm => (view::CredFormView.title(), view::CredFormView.hints()),
Mode::Settings => (view::SettingsView.title(), view::SettingsView.hints()),
let (title, hints): (&str, Vec<_>) = {
let v: &dyn View = match app.session.mode {
Mode::Home => &view::HomeListView,
Mode::ActionMenu => &view::ActionMenuView,
Mode::Search => &view::SearchView,
Mode::QuickSelect => &view::QuickSelectView,
Mode::DeleteConfirm => &view::DeleteConfirmView,
Mode::Form => &view::FormView,
Mode::ImportSelector => &view::ImportView,
Mode::Credentials => &view::CredListView,
Mode::CredForm => &view::CredFormView,
Mode::Settings => &view::SettingsView,
};
(v.title(), v.hints())
};
draw_header(frame, app, title, shell[0]);
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::Credentials => view::CredListView.draw(frame, app, content),
Mode::CredForm => view::CredFormView.draw(frame, app, content),
Mode::Settings => view::SettingsView.draw(frame, app, content),
Mode::Home | Mode::ActionMenu | Mode::DeleteConfirm => {
view::HomeListView.draw(frame, app, content)
}
_ => {
let v: &dyn View = match app.session.mode {
Mode::Search => &view::SearchView,
Mode::QuickSelect => &view::QuickSelectView,
Mode::Form => &view::FormView,
Mode::ImportSelector => &view::ImportView,
Mode::Credentials => &view::CredListView,
Mode::CredForm => &view::CredFormView,
Mode::Settings => &view::SettingsView,
_ => unreachable!(),
};
v.draw(frame, app, content);
}
}
draw_help(frame, &hints, shell[2]);
+2 -5
View File
@@ -1,5 +1,5 @@
use crate::app::{App, Mode};
use crate::config::{ConnectionType, SyncBackend};
use crate::config::ConnectionType;
use anyhow::Result;
use crossterm::{
cursor::{Hide, Show},
@@ -26,10 +26,7 @@ pub fn run() -> Result<()> {
let mut app = App::load()?;
if app.config.settings.sync_on_start {
let result = match app.config.settings.backend {
SyncBackend::Gist => crate::sync::gist::sync(&mut app.config),
SyncBackend::Webdav => crate::sync::webdav::sync(&mut app.config),
};
let result = crate::sync::run_sync(&mut app.config);
if let Err(err) = result {
app.toast(err.to_string(), false);
}
+32 -5
View File
@@ -7,12 +7,14 @@ mod import;
mod settings;
use crate::app::{App, FormAction, FormNav};
use crate::ui::BLUE;
use anyhow::Result;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::{
Frame,
layout::Rect,
widgets::Row,
style::{Modifier, Style},
widgets::{Cell, Row},
};
/// A View represents a full screen that handles both rendering and key events.
@@ -38,17 +40,30 @@ pub use settings::SettingsView;
/// Scroll a 1:1 row list so the selected index stays visible.
/// `area_height` includes the block borders and table header.
pub fn scroll_rows<'a>(rows: Vec<Row<'a>>, selected: usize, area_height: u16) -> Vec<Row<'a>> {
scroll_indexed_rows(rows, &[selected], 0, area_height)
}
/// Scroll a row list that mixes data rows with non-data rows (e.g. section headers).
/// `entry_row` maps each data index to its visual row index in `rows`.
/// `selected` is the currently selected data index.
pub fn scroll_indexed_rows<'a>(
rows: Vec<Row<'a>>,
entry_row: &[usize],
selected: usize,
area_height: u16,
) -> Vec<Row<'a>> {
let visible = area_height.saturating_sub(3) as usize; // 2 borders + 1 header
let total = rows.len();
if visible == 0 || total <= visible {
if visible == 0 || total <= visible || entry_row.is_empty() {
return rows;
}
let scroll = if selected < visible / 2 {
let sel_row = entry_row[selected.min(entry_row.len() - 1)];
let scroll = if sel_row < visible / 2 {
0
} else if selected + visible / 2 >= total {
} else if sel_row + visible / 2 >= total {
total.saturating_sub(visible)
} else {
selected - visible / 2
sel_row - visible / 2
};
rows.into_iter().skip(scroll).take(visible).collect()
}
@@ -79,3 +94,15 @@ pub fn handle_form_nav<F: FormNav>(form: &mut F, key: KeyEvent) -> Option<FormAc
_ => None,
}
}
/// A section header row with a label and count, used in table views.
pub 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)
}
+1 -1
View File
@@ -93,7 +93,7 @@ impl View for ActionMenuView {
ListAction::Select => {
app.session.mode = Mode::Home;
match app.session.action_menu.cursor {
0 => app.enter_combined_import()?,
0 => app.enter_import_selector()?,
1 => app.sync_with_toast(),
2 => app.enter_credentials(),
3 => app.enter_settings(),
+4 -38
View File
@@ -6,6 +6,7 @@ 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, YELLOW};
use super::View;
use super::{section_row, scroll_indexed_rows};
use anyhow::Result;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
@@ -162,21 +163,7 @@ pub fn draw_connection_list(frame: &mut Frame<'_>, app: &App, area: Rect) {
}
// Scroll to keep selected entry visible
let visible = area.height.saturating_sub(3) as usize; // 2 borders + 1 header
if visible > 0 && !entry_row.is_empty() {
let sel_row = entry_row[app.session.home.selected.min(entry_row.len() - 1)];
let total = rows.len();
if total > visible {
let scroll = if sel_row < visible / 2 {
0
} else if sel_row + visible / 2 >= total {
total.saturating_sub(visible)
} else {
sel_row - visible / 2
};
rows = rows.into_iter().skip(scroll).take(visible).collect();
}
}
rows = scroll_indexed_rows(rows, &entry_row, app.session.home.selected, area.height);
let title = if app.session.mode == Mode::QuickSelect {
"Connections - Quick Select"
@@ -203,17 +190,6 @@ pub fn draw_connection_list(frame: &mut Frame<'_>, app: &App, area: Rect) {
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)
}
fn connection_row(
app: &App,
idx: usize,
@@ -252,11 +228,9 @@ fn connection_row(
}
ConnectionType::Shell {
command,
sync_args,
local_args,
..
} => {
let merged_args = shell_args(sync_args, local_args);
let merged_args = profile.merged_shell_args();
if merged_args.is_empty() {
command.clone()
} else {
@@ -378,14 +352,12 @@ pub fn draw_detail_panel(frame: &mut Frame<'_>, app: &App, area: Rect) {
shell_name,
auth_ref,
command,
sync_args,
local_args,
sync,
..
} => {
lines.push(detail_text("Shell", shell_name));
lines.push(detail_text("Command", command));
let merged_args = shell_args(sync_args, local_args);
let merged_args = profile.merged_shell_args();
if !merged_args.is_empty() {
lines.push(detail_text("Args", &merged_args.join(" ")));
}
@@ -437,12 +409,6 @@ fn detail_text(label: &str, value: &str) -> Line<'static> {
])
}
fn shell_args(sync_args: &[String], local_args: &[String]) -> Vec<String> {
let mut out = sync_args.to_vec();
out.extend(local_args.iter().cloned());
out
}
fn detail_line(label: &str, spans: Vec<Span<'static>>) -> Line<'static> {
let mut out = vec![Span::styled(
format!(" {:<11}", label),
+2 -26
View File
@@ -3,6 +3,7 @@ use crate::ui::component::{ListAction, handle_list_nav, panel, panel_with_subtit
use crate::ui::{BLUE, GREEN, MUTED, SELECTED_BG, TEXT};
use super::View;
use super::{section_row, scroll_indexed_rows};
use anyhow::Result;
use crossterm::event::{KeyCode, KeyEvent};
@@ -134,21 +135,7 @@ fn draw_import(frame: &mut Frame<'_>, app: &App, area: Rect) {
}
// 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();
}
}
rows = scroll_indexed_rows(rows, &entry_row, app.session.import.cursor, area.height);
let table = Table::new(
rows,
@@ -172,17 +159,6 @@ fn draw_import(frame: &mut Frame<'_>, app: &App, area: Rect) {
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<()> {