refactor: consolidate form/sync dispatch and trim unused helpers
This commit is contained in:
+1
-5
@@ -91,11 +91,7 @@ impl FormNav for CredFormState {
|
|||||||
|
|
||||||
impl TextEditing for CredFormState {
|
impl TextEditing for CredFormState {
|
||||||
fn active_text(&self) -> &str {
|
fn active_text(&self) -> &str {
|
||||||
match self.active {
|
self.field_value(self.active)
|
||||||
CredFormField::Name => &self.name,
|
|
||||||
CredFormField::Value => &self.value,
|
|
||||||
_ => "",
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn active_text_mut(&mut self) -> Option<&mut String> {
|
fn active_text_mut(&mut self) -> Option<&mut String> {
|
||||||
|
|||||||
+5
-37
@@ -220,19 +220,7 @@ impl FormNav for FormState {
|
|||||||
|
|
||||||
impl TextEditing for FormState {
|
impl TextEditing for FormState {
|
||||||
fn active_text(&self) -> &str {
|
fn active_text(&self) -> &str {
|
||||||
match self.active {
|
self.field_value(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,
|
|
||||||
_ => "",
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn active_text_mut(&mut self) -> Option<&mut String> {
|
fn active_text_mut(&mut self) -> Option<&mut String> {
|
||||||
@@ -296,14 +284,7 @@ impl App {
|
|||||||
self.config.deleted.insert(name.clone(), ts);
|
self.config.deleted.insert(name.clone(), ts);
|
||||||
|
|
||||||
if let Some(auth_ref) = profile.auth_ref() {
|
if let Some(auth_ref) = profile.auth_ref() {
|
||||||
let still_used = self
|
self.config.prune_credential_if_unused(auth_ref, None);
|
||||||
.config
|
|
||||||
.connections
|
|
||||||
.values()
|
|
||||||
.any(|p| p.auth_ref() == Some(auth_ref));
|
|
||||||
if !still_used {
|
|
||||||
self.config.credentials.entries.shift_remove(auth_ref);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
self.config.save()?;
|
self.config.save()?;
|
||||||
self.session.home.selected = self
|
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>) {
|
fn save_form_credential(&mut self, name: &str, auth_ref: &str, old_auth_ref: Option<String>) {
|
||||||
if self.session.form.is_shell {
|
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() {
|
} else if !self.session.form.secret.is_empty() {
|
||||||
let secret = resolve_secret(&self.session.form.secret);
|
let secret = resolve_secret(&self.session.form.secret);
|
||||||
let entry = match self.session.form.auth_kind {
|
let entry = match self.session.form.auth_kind {
|
||||||
@@ -448,21 +431,6 @@ impl App {
|
|||||||
.insert(auth_ref.to_string(), entry);
|
.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> {
|
fn parse_tags(raw: &str) -> Vec<String> {
|
||||||
|
|||||||
+1
-15
@@ -1,6 +1,5 @@
|
|||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
|
|
||||||
use crate::config::SyncBackend;
|
|
||||||
use super::{App, Mode};
|
use super::{App, Mode};
|
||||||
|
|
||||||
impl App {
|
impl App {
|
||||||
@@ -13,16 +12,6 @@ impl App {
|
|||||||
self.session.mode = Mode::ActionMenu;
|
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);
|
||||||
@@ -64,10 +53,7 @@ impl App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn sync_with_toast(&mut self) {
|
pub fn sync_with_toast(&mut self) {
|
||||||
let result = match self.config.settings.backend {
|
let result = crate::sync::run_sync(&mut self.config);
|
||||||
SyncBackend::Gist => crate::sync::gist::sync(&mut self.config),
|
|
||||||
SyncBackend::Webdav => crate::sync::webdav::sync(&mut self.config),
|
|
||||||
};
|
|
||||||
match result {
|
match result {
|
||||||
Ok(report) => self.toast(report.to_string(), true),
|
Ok(report) => self.toast(report.to_string(), true),
|
||||||
Err(err) => self.toast(err.to_string(), false),
|
Err(err) => self.toast(err.to_string(), false),
|
||||||
|
|||||||
+12
-22
@@ -1,6 +1,12 @@
|
|||||||
use super::{FormNav, TextEditing};
|
use super::{FormNav, TextEditing};
|
||||||
use crate::config::SyncBackend;
|
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)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum SettingsField {
|
pub enum SettingsField {
|
||||||
SyncPassword,
|
SyncPassword,
|
||||||
@@ -128,14 +134,7 @@ impl Default for SettingsState {
|
|||||||
|
|
||||||
impl TextEditing for SettingsState {
|
impl TextEditing for SettingsState {
|
||||||
fn active_text(&self) -> &str {
|
fn active_text(&self) -> &str {
|
||||||
match self.active {
|
self.field_text(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,
|
|
||||||
_ => "",
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn active_text_mut(&mut self) -> Option<&mut String> {
|
fn active_text_mut(&mut self) -> Option<&mut String> {
|
||||||
@@ -187,22 +186,13 @@ impl App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn save_settings(&mut self) -> Result<()> {
|
pub fn save_settings(&mut self) -> Result<()> {
|
||||||
let pw = self.session.settings.password.trim().to_string();
|
self.config.settings.sync_password = trimmed_opt(&self.session.settings.password);
|
||||||
self.config.settings.sync_password = if pw.is_empty() { None } else { Some(pw) };
|
|
||||||
self.config.settings.backend = self.session.settings.backend;
|
self.config.settings.backend = self.session.settings.backend;
|
||||||
self.config.settings.sync_on_start = self.session.settings.sync_on_start;
|
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 = trimmed_opt(&self.session.settings.gist_id);
|
||||||
self.config.settings.gist_id = if gist.is_empty() { None } else { Some(gist) };
|
self.config.settings.webdav_url = trimmed_opt(&self.session.settings.webdav_url);
|
||||||
let url = self.session.settings.webdav_url.trim().to_string();
|
self.config.settings.webdav_user = trimmed_opt(&self.session.settings.webdav_user);
|
||||||
self.config.settings.webdav_url = if url.is_empty() { None } else { Some(url) };
|
self.config.settings.webdav_password = trimmed_opt(&self.session.settings.webdav_password);
|
||||||
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.save()?;
|
self.config.save()?;
|
||||||
self.session.mode = Mode::Home;
|
self.session.mode = Mode::Home;
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
+3
-9
@@ -1,5 +1,5 @@
|
|||||||
use crate::config::{ConnectionType, CredentialEntry, SshellConfig, SyncBackend, config_path, find_binary};
|
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 crate::{connection, import, ui};
|
||||||
use anyhow::{Context, Result, bail};
|
use anyhow::{Context, Result, bail};
|
||||||
use clap::{Parser, Subcommand};
|
use clap::{Parser, Subcommand};
|
||||||
@@ -55,10 +55,7 @@ pub fn run() -> Result<()> {
|
|||||||
|
|
||||||
fn run_sync() -> Result<()> {
|
fn run_sync() -> Result<()> {
|
||||||
let mut cfg = SshellConfig::load()?;
|
let mut cfg = SshellConfig::load()?;
|
||||||
let report = match cfg.settings.backend {
|
let report = sync::run_sync(&mut cfg)?;
|
||||||
SyncBackend::Gist => gist::sync(&mut cfg)?,
|
|
||||||
SyncBackend::Webdav => webdav::sync(&mut cfg)?,
|
|
||||||
};
|
|
||||||
println!("{report}");
|
println!("{report}");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -143,13 +140,10 @@ fn check_connection(
|
|||||||
}
|
}
|
||||||
ConnectionType::Shell {
|
ConnectionType::Shell {
|
||||||
command,
|
command,
|
||||||
sync_args,
|
|
||||||
local_args,
|
|
||||||
..
|
..
|
||||||
} => {
|
} => {
|
||||||
println!("type: shell");
|
println!("type: shell");
|
||||||
let mut merged_args = sync_args.clone();
|
let merged_args = profile.merged_shell_args();
|
||||||
merged_args.extend(local_args.clone());
|
|
||||||
println!("command: {command} {}", merged_args.join(" "));
|
println!("command: {command} {}", merged_args.join(" "));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -199,6 +199,19 @@ impl SshellConfig {
|
|||||||
.unwrap_or(0)
|
.unwrap_or(0)
|
||||||
+ 1
|
+ 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 {
|
impl CredentialEntry {
|
||||||
@@ -235,6 +248,19 @@ impl ConnectionProfile {
|
|||||||
ConnectionType::Shell { sync, .. } => *sync,
|
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 {
|
pub fn expand_user_path(value: &str) -> PathBuf {
|
||||||
|
|||||||
+1
-4
@@ -49,12 +49,9 @@ pub fn connect(name: &str, cfg: &SshellConfig) -> Result<()> {
|
|||||||
} => connect_ssh(cfg, host, *port, user, auth_ref),
|
} => connect_ssh(cfg, host, *port, user, auth_ref),
|
||||||
ConnectionType::Shell {
|
ConnectionType::Shell {
|
||||||
command,
|
command,
|
||||||
sync_args,
|
|
||||||
local_args,
|
|
||||||
..
|
..
|
||||||
} => {
|
} => {
|
||||||
let mut merged_args = sync_args.clone();
|
let merged_args = profile.merged_shell_args();
|
||||||
merged_args.extend(local_args.clone());
|
|
||||||
exec_shell(command, &merged_args)
|
exec_shell(command, &merged_args)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-7
@@ -2,6 +2,8 @@ mod crypto;
|
|||||||
pub mod gist;
|
pub mod gist;
|
||||||
pub mod webdav;
|
pub mod webdav;
|
||||||
|
|
||||||
|
use crate::config::SyncBackend;
|
||||||
|
|
||||||
use crate::config::ConnectionType;
|
use crate::config::ConnectionType;
|
||||||
use crate::config::{ConnectionSource, CredentialStore, SshellConfig};
|
use crate::config::{ConnectionSource, CredentialStore, SshellConfig};
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
@@ -197,13 +199,7 @@ pub(crate) fn bidirectional_merge(
|
|||||||
if let Some(profile) = removed
|
if let Some(profile) = removed
|
||||||
&& let Some(auth_ref) = profile.auth_ref()
|
&& let Some(auth_ref) = profile.auth_ref()
|
||||||
{
|
{
|
||||||
let still_used = cfg
|
cfg.prune_credential_if_unused(auth_ref, None);
|
||||||
.connections
|
|
||||||
.values()
|
|
||||||
.any(|p| p.auth_ref() == Some(auth_ref));
|
|
||||||
if !still_used {
|
|
||||||
cfg.credentials.entries.shift_remove(auth_ref);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
report.deleted += 1;
|
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> {
|
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}"))
|
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),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -75,32 +75,41 @@ pub fn draw(frame: &mut Frame<'_>, app: &mut crate::app::App) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
use view::View;
|
use view::View;
|
||||||
let (title, hints): (&str, Vec<_>) = match app.session.mode {
|
let (title, hints): (&str, Vec<_>) = {
|
||||||
Mode::Home => (view::HomeListView.title(), view::HomeListView.hints()),
|
let v: &dyn View = match app.session.mode {
|
||||||
Mode::ActionMenu => (view::ActionMenuView.title(), view::ActionMenuView.hints()),
|
Mode::Home => &view::HomeListView,
|
||||||
Mode::Search => (view::SearchView.title(), view::SearchView.hints()),
|
Mode::ActionMenu => &view::ActionMenuView,
|
||||||
Mode::QuickSelect => (view::QuickSelectView.title(), view::QuickSelectView.hints()),
|
Mode::Search => &view::SearchView,
|
||||||
Mode::DeleteConfirm => (view::DeleteConfirmView.title(), view::DeleteConfirmView.hints()),
|
Mode::QuickSelect => &view::QuickSelectView,
|
||||||
Mode::Form => (view::FormView.title(), view::FormView.hints()),
|
Mode::DeleteConfirm => &view::DeleteConfirmView,
|
||||||
Mode::ImportSelector => (view::ImportView.title(), view::ImportView.hints()),
|
Mode::Form => &view::FormView,
|
||||||
Mode::Credentials => (view::CredListView.title(), view::CredListView.hints()),
|
Mode::ImportSelector => &view::ImportView,
|
||||||
Mode::CredForm => (view::CredFormView.title(), view::CredFormView.hints()),
|
Mode::Credentials => &view::CredListView,
|
||||||
Mode::Settings => (view::SettingsView.title(), view::SettingsView.hints()),
|
Mode::CredForm => &view::CredFormView,
|
||||||
|
Mode::Settings => &view::SettingsView,
|
||||||
|
};
|
||||||
|
(v.title(), v.hints())
|
||||||
};
|
};
|
||||||
|
|
||||||
draw_header(frame, app, title, shell[0]);
|
draw_header(frame, app, title, shell[0]);
|
||||||
|
|
||||||
match app.session.mode {
|
match app.session.mode {
|
||||||
Mode::Home => view::HomeListView.draw(frame, app, content),
|
Mode::Home | Mode::ActionMenu | Mode::DeleteConfirm => {
|
||||||
Mode::ActionMenu => view::HomeListView.draw(frame, app, content),
|
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),
|
let v: &dyn View = match app.session.mode {
|
||||||
Mode::Form => view::FormView.draw(frame, app, content),
|
Mode::Search => &view::SearchView,
|
||||||
Mode::ImportSelector => view::ImportView.draw(frame, app, content),
|
Mode::QuickSelect => &view::QuickSelectView,
|
||||||
Mode::Credentials => view::CredListView.draw(frame, app, content),
|
Mode::Form => &view::FormView,
|
||||||
Mode::CredForm => view::CredFormView.draw(frame, app, content),
|
Mode::ImportSelector => &view::ImportView,
|
||||||
Mode::Settings => view::SettingsView.draw(frame, app, content),
|
Mode::Credentials => &view::CredListView,
|
||||||
|
Mode::CredForm => &view::CredFormView,
|
||||||
|
Mode::Settings => &view::SettingsView,
|
||||||
|
_ => unreachable!(),
|
||||||
|
};
|
||||||
|
v.draw(frame, app, content);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
draw_help(frame, &hints, shell[2]);
|
draw_help(frame, &hints, shell[2]);
|
||||||
|
|||||||
+2
-5
@@ -1,5 +1,5 @@
|
|||||||
use crate::app::{App, Mode};
|
use crate::app::{App, Mode};
|
||||||
use crate::config::{ConnectionType, SyncBackend};
|
use crate::config::ConnectionType;
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use crossterm::{
|
use crossterm::{
|
||||||
cursor::{Hide, Show},
|
cursor::{Hide, Show},
|
||||||
@@ -26,10 +26,7 @@ pub fn run() -> Result<()> {
|
|||||||
let mut app = App::load()?;
|
let mut app = App::load()?;
|
||||||
|
|
||||||
if app.config.settings.sync_on_start {
|
if app.config.settings.sync_on_start {
|
||||||
let result = match app.config.settings.backend {
|
let result = crate::sync::run_sync(&mut app.config);
|
||||||
SyncBackend::Gist => crate::sync::gist::sync(&mut app.config),
|
|
||||||
SyncBackend::Webdav => crate::sync::webdav::sync(&mut app.config),
|
|
||||||
};
|
|
||||||
if let Err(err) = result {
|
if let Err(err) = result {
|
||||||
app.toast(err.to_string(), false);
|
app.toast(err.to_string(), false);
|
||||||
}
|
}
|
||||||
|
|||||||
+32
-5
@@ -7,12 +7,14 @@ mod import;
|
|||||||
mod settings;
|
mod settings;
|
||||||
|
|
||||||
use crate::app::{App, FormAction, FormNav};
|
use crate::app::{App, FormAction, FormNav};
|
||||||
|
use crate::ui::BLUE;
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
Frame,
|
Frame,
|
||||||
layout::Rect,
|
layout::Rect,
|
||||||
widgets::Row,
|
style::{Modifier, Style},
|
||||||
|
widgets::{Cell, Row},
|
||||||
};
|
};
|
||||||
|
|
||||||
/// A View represents a full screen that handles both rendering and key events.
|
/// 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.
|
/// Scroll a 1:1 row list so the selected index stays visible.
|
||||||
/// `area_height` includes the block borders and table header.
|
/// `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>> {
|
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 visible = area_height.saturating_sub(3) as usize; // 2 borders + 1 header
|
||||||
let total = rows.len();
|
let total = rows.len();
|
||||||
if visible == 0 || total <= visible {
|
if visible == 0 || total <= visible || entry_row.is_empty() {
|
||||||
return rows;
|
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
|
0
|
||||||
} else if selected + visible / 2 >= total {
|
} else if sel_row + visible / 2 >= total {
|
||||||
total.saturating_sub(visible)
|
total.saturating_sub(visible)
|
||||||
} else {
|
} else {
|
||||||
selected - visible / 2
|
sel_row - visible / 2
|
||||||
};
|
};
|
||||||
rows.into_iter().skip(scroll).take(visible).collect()
|
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,
|
_ => 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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ impl View for ActionMenuView {
|
|||||||
ListAction::Select => {
|
ListAction::Select => {
|
||||||
app.session.mode = Mode::Home;
|
app.session.mode = Mode::Home;
|
||||||
match app.session.action_menu.cursor {
|
match app.session.action_menu.cursor {
|
||||||
0 => app.enter_combined_import()?,
|
0 => app.enter_import_selector()?,
|
||||||
1 => app.sync_with_toast(),
|
1 => app.sync_with_toast(),
|
||||||
2 => app.enter_credentials(),
|
2 => app.enter_credentials(),
|
||||||
3 => app.enter_settings(),
|
3 => app.enter_settings(),
|
||||||
|
|||||||
@@ -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 crate::ui::{ACCENT, BLUE, GREEN, MUTED, PANEL_ALT, PURPLE, RED, SELECTED_BG, TEXT, YELLOW};
|
||||||
|
|
||||||
use super::View;
|
use super::View;
|
||||||
|
use super::{section_row, scroll_indexed_rows};
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
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
|
// Scroll to keep selected entry visible
|
||||||
let visible = area.height.saturating_sub(3) as usize; // 2 borders + 1 header
|
rows = scroll_indexed_rows(rows, &entry_row, app.session.home.selected, area.height);
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let title = if app.session.mode == Mode::QuickSelect {
|
let title = if app.session.mode == Mode::QuickSelect {
|
||||||
"Connections - Quick Select"
|
"Connections - Quick Select"
|
||||||
@@ -203,17 +190,6 @@ pub fn draw_connection_list(frame: &mut Frame<'_>, app: &App, area: Rect) {
|
|||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn connection_row(
|
fn connection_row(
|
||||||
app: &App,
|
app: &App,
|
||||||
idx: usize,
|
idx: usize,
|
||||||
@@ -252,11 +228,9 @@ fn connection_row(
|
|||||||
}
|
}
|
||||||
ConnectionType::Shell {
|
ConnectionType::Shell {
|
||||||
command,
|
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() {
|
if merged_args.is_empty() {
|
||||||
command.clone()
|
command.clone()
|
||||||
} else {
|
} else {
|
||||||
@@ -378,14 +352,12 @@ pub fn draw_detail_panel(frame: &mut Frame<'_>, app: &App, area: Rect) {
|
|||||||
shell_name,
|
shell_name,
|
||||||
auth_ref,
|
auth_ref,
|
||||||
command,
|
command,
|
||||||
sync_args,
|
|
||||||
local_args,
|
|
||||||
sync,
|
sync,
|
||||||
..
|
..
|
||||||
} => {
|
} => {
|
||||||
lines.push(detail_text("Shell", shell_name));
|
lines.push(detail_text("Shell", shell_name));
|
||||||
lines.push(detail_text("Command", command));
|
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() {
|
if !merged_args.is_empty() {
|
||||||
lines.push(detail_text("Args", &merged_args.join(" ")));
|
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> {
|
fn detail_line(label: &str, spans: Vec<Span<'static>>) -> Line<'static> {
|
||||||
let mut out = vec![Span::styled(
|
let mut out = vec![Span::styled(
|
||||||
format!(" {:<11}", label),
|
format!(" {:<11}", label),
|
||||||
|
|||||||
+2
-26
@@ -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 crate::ui::{BLUE, GREEN, MUTED, SELECTED_BG, TEXT};
|
||||||
|
|
||||||
use super::View;
|
use super::View;
|
||||||
|
use super::{section_row, scroll_indexed_rows};
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use crossterm::event::{KeyCode, KeyEvent};
|
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
|
// Scroll to keep selected entry visible
|
||||||
let visible = area.height.saturating_sub(3) as usize;
|
rows = scroll_indexed_rows(rows, &entry_row, app.session.import.cursor, area.height);
|
||||||
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,
|
||||||
@@ -172,17 +159,6 @@ fn draw_import(frame: &mut Frame<'_>, app: &App, area: Rect) {
|
|||||||
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<()> {
|
||||||
|
|||||||
Reference in New Issue
Block a user