refactor: enhance sync module with report tracking, payload parsing, and IndexMap support
This commit is contained in:
@@ -292,6 +292,9 @@ impl App {
|
|||||||
};
|
};
|
||||||
let removed = self.config.connections.shift_remove(&name);
|
let removed = self.config.connections.shift_remove(&name);
|
||||||
if let Some(profile) = removed {
|
if let Some(profile) = removed {
|
||||||
|
let ts = crate::config::now_epoch_secs();
|
||||||
|
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
|
let still_used = self
|
||||||
.config
|
.config
|
||||||
@@ -367,6 +370,7 @@ impl App {
|
|||||||
source,
|
source,
|
||||||
added_order,
|
added_order,
|
||||||
usage_count,
|
usage_count,
|
||||||
|
modified_at: crate::config::now_epoch_secs(),
|
||||||
kind: ConnectionType::Shell {
|
kind: ConnectionType::Shell {
|
||||||
shell_name: shell_name_for_command(&self.session.form.command),
|
shell_name: shell_name_for_command(&self.session.form.command),
|
||||||
auth_ref: None,
|
auth_ref: None,
|
||||||
@@ -389,6 +393,7 @@ impl App {
|
|||||||
source,
|
source,
|
||||||
added_order,
|
added_order,
|
||||||
usage_count,
|
usage_count,
|
||||||
|
modified_at: crate::config::now_epoch_secs(),
|
||||||
kind: ConnectionType::Ssh {
|
kind: ConnectionType::Ssh {
|
||||||
host,
|
host,
|
||||||
port,
|
port,
|
||||||
|
|||||||
+4
-15
@@ -63,24 +63,13 @@ impl App {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn push_sync_with_toast(&mut self) {
|
pub fn sync_with_toast(&mut self) {
|
||||||
let result = match self.config.settings.backend {
|
let result = match self.config.settings.backend {
|
||||||
SyncBackend::Gist => crate::sync::gist::push(&mut self.config).map(|id| format!("pushed ({id})")),
|
SyncBackend::Gist => crate::sync::gist::sync(&mut self.config),
|
||||||
SyncBackend::Webdav => crate::sync::webdav::push(&mut self.config).map(|_| "pushed".to_string()),
|
SyncBackend::Webdav => crate::sync::webdav::sync(&mut self.config),
|
||||||
};
|
};
|
||||||
match result {
|
match result {
|
||||||
Ok(msg) => self.toast(msg, true),
|
Ok(report) => self.toast(report.to_string(), true),
|
||||||
Err(err) => self.toast(err.to_string(), false),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn pull_sync_with_toast(&mut self) {
|
|
||||||
let result = match self.config.settings.backend {
|
|
||||||
SyncBackend::Gist => crate::sync::gist::pull_with_strategy(&mut self.config, crate::sync::PullStrategy::Merge),
|
|
||||||
SyncBackend::Webdav => crate::sync::webdav::pull_with_strategy(&mut self.config, crate::sync::PullStrategy::Merge),
|
|
||||||
};
|
|
||||||
match result {
|
|
||||||
Ok(count) => self.toast(format!("pulled {count} items"), true),
|
|
||||||
Err(err) => self.toast(err.to_string(), false),
|
Err(err) => self.toast(err.to_string(), false),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -203,6 +203,7 @@ mod tests {
|
|||||||
source: crate::config::ConnectionSource::Manual,
|
source: crate::config::ConnectionSource::Manual,
|
||||||
added_order: order,
|
added_order: order,
|
||||||
usage_count: usage,
|
usage_count: usage,
|
||||||
|
modified_at: 0,
|
||||||
kind: crate::config::ConnectionType::Ssh {
|
kind: crate::config::ConnectionType::Ssh {
|
||||||
host: "h".into(),
|
host: "h".into(),
|
||||||
port: 22,
|
port: 22,
|
||||||
@@ -224,6 +225,7 @@ mod tests {
|
|||||||
source: crate::config::ConnectionSource::Manual,
|
source: crate::config::ConnectionSource::Manual,
|
||||||
added_order: 1,
|
added_order: 1,
|
||||||
usage_count: 0,
|
usage_count: 0,
|
||||||
|
modified_at: 0,
|
||||||
kind: ConnectionType::Ssh {
|
kind: ConnectionType::Ssh {
|
||||||
host: "example.com".into(),
|
host: "example.com".into(),
|
||||||
port: 22,
|
port: 22,
|
||||||
@@ -245,6 +247,7 @@ mod tests {
|
|||||||
source: crate::config::ConnectionSource::Manual,
|
source: crate::config::ConnectionSource::Manual,
|
||||||
added_order: 1,
|
added_order: 1,
|
||||||
usage_count: 0,
|
usage_count: 0,
|
||||||
|
modified_at: 0,
|
||||||
kind: ConnectionType::Ssh {
|
kind: ConnectionType::Ssh {
|
||||||
host: "example.com".into(),
|
host: "example.com".into(),
|
||||||
port: 22,
|
port: 22,
|
||||||
@@ -265,6 +268,7 @@ mod tests {
|
|||||||
source: crate::config::ConnectionSource::Manual,
|
source: crate::config::ConnectionSource::Manual,
|
||||||
added_order: 1,
|
added_order: 1,
|
||||||
usage_count: 0,
|
usage_count: 0,
|
||||||
|
modified_at: 0,
|
||||||
kind: ConnectionType::Ssh {
|
kind: ConnectionType::Ssh {
|
||||||
host: "h".into(),
|
host: "h".into(),
|
||||||
port: 22,
|
port: 22,
|
||||||
|
|||||||
+8
-2
@@ -5,6 +5,7 @@ use crate::config::SyncBackend;
|
|||||||
pub enum SettingsField {
|
pub enum SettingsField {
|
||||||
SyncPassword,
|
SyncPassword,
|
||||||
Backend,
|
Backend,
|
||||||
|
SyncOnStart,
|
||||||
GistId,
|
GistId,
|
||||||
WebdavUrl,
|
WebdavUrl,
|
||||||
WebdavUser,
|
WebdavUser,
|
||||||
@@ -13,7 +14,7 @@ pub enum SettingsField {
|
|||||||
|
|
||||||
impl SettingsField {
|
impl SettingsField {
|
||||||
pub fn visible_fields(backend: SyncBackend) -> Vec<SettingsField> {
|
pub fn visible_fields(backend: SyncBackend) -> Vec<SettingsField> {
|
||||||
let mut fields = vec![SettingsField::SyncPassword, SettingsField::Backend];
|
let mut fields = vec![SettingsField::SyncPassword, SettingsField::Backend, SettingsField::SyncOnStart];
|
||||||
match backend {
|
match backend {
|
||||||
SyncBackend::Gist => fields.push(SettingsField::GistId),
|
SyncBackend::Gist => fields.push(SettingsField::GistId),
|
||||||
SyncBackend::Webdav => {
|
SyncBackend::Webdav => {
|
||||||
@@ -31,6 +32,7 @@ impl SettingsField {
|
|||||||
match self {
|
match self {
|
||||||
Self::SyncPassword => "Encrypt Pwd",
|
Self::SyncPassword => "Encrypt Pwd",
|
||||||
Self::Backend => "Backend",
|
Self::Backend => "Backend",
|
||||||
|
Self::SyncOnStart => "Auto Sync",
|
||||||
Self::GistId => "Gist ID",
|
Self::GistId => "Gist ID",
|
||||||
Self::WebdavUrl => "URL",
|
Self::WebdavUrl => "URL",
|
||||||
Self::WebdavUser => "Username",
|
Self::WebdavUser => "Username",
|
||||||
@@ -39,7 +41,7 @@ impl SettingsField {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn is_toggle(self) -> bool {
|
pub fn is_toggle(self) -> bool {
|
||||||
matches!(self, Self::Backend)
|
matches!(self, Self::Backend | Self::SyncOnStart)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn is_text(self) -> bool {
|
pub fn is_text(self) -> bool {
|
||||||
@@ -51,6 +53,7 @@ impl SettingsField {
|
|||||||
pub struct SettingsState {
|
pub struct SettingsState {
|
||||||
pub password: String,
|
pub password: String,
|
||||||
pub backend: SyncBackend,
|
pub backend: SyncBackend,
|
||||||
|
pub sync_on_start: bool,
|
||||||
pub gist_id: String,
|
pub gist_id: String,
|
||||||
pub webdav_url: String,
|
pub webdav_url: String,
|
||||||
pub webdav_user: String,
|
pub webdav_user: String,
|
||||||
@@ -112,6 +115,7 @@ impl Default for SettingsState {
|
|||||||
Self {
|
Self {
|
||||||
password: String::new(),
|
password: String::new(),
|
||||||
backend: SyncBackend::Gist,
|
backend: SyncBackend::Gist,
|
||||||
|
sync_on_start: false,
|
||||||
gist_id: String::new(),
|
gist_id: String::new(),
|
||||||
webdav_url: String::new(),
|
webdav_url: String::new(),
|
||||||
webdav_user: String::new(),
|
webdav_user: String::new(),
|
||||||
@@ -169,6 +173,7 @@ impl App {
|
|||||||
.clone()
|
.clone()
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
self.session.settings.backend = self.config.settings.backend;
|
self.session.settings.backend = self.config.settings.backend;
|
||||||
|
self.session.settings.sync_on_start = self.config.settings.sync_on_start;
|
||||||
self.session.settings.gist_id = self.config.settings.gist_id.clone().unwrap_or_default();
|
self.session.settings.gist_id = self.config.settings.gist_id.clone().unwrap_or_default();
|
||||||
self.session.settings.webdav_url =
|
self.session.settings.webdav_url =
|
||||||
self.config.settings.webdav_url.clone().unwrap_or_default();
|
self.config.settings.webdav_url.clone().unwrap_or_default();
|
||||||
@@ -185,6 +190,7 @@ impl App {
|
|||||||
let pw = self.session.settings.password.trim().to_string();
|
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 = 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;
|
||||||
let gist = self.session.settings.gist_id.trim().to_string();
|
let gist = self.session.settings.gist_id.trim().to_string();
|
||||||
self.config.settings.gist_id = if gist.is_empty() { None } else { Some(gist) };
|
self.config.settings.gist_id = if gist.is_empty() { None } else { Some(gist) };
|
||||||
let url = self.session.settings.webdav_url.trim().to_string();
|
let url = self.session.settings.webdav_url.trim().to_string();
|
||||||
|
|||||||
+8
-45
@@ -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::{self, gist, webdav};
|
use crate::sync::{gist, webdav};
|
||||||
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};
|
||||||
@@ -23,31 +23,12 @@ enum Command {
|
|||||||
name: String,
|
name: String,
|
||||||
},
|
},
|
||||||
Import,
|
Import,
|
||||||
Sync {
|
Sync,
|
||||||
#[command(subcommand)]
|
|
||||||
command: SyncCommand,
|
|
||||||
},
|
|
||||||
Doctor {
|
Doctor {
|
||||||
name: Option<String>,
|
name: Option<String>,
|
||||||
},
|
},
|
||||||
ConfigPath,
|
ConfigPath,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Subcommand)]
|
|
||||||
pub enum SyncCommand {
|
|
||||||
Push,
|
|
||||||
Pull {
|
|
||||||
#[arg(long, value_enum, default_value_t = PullStrategy::Merge)]
|
|
||||||
strategy: PullStrategy,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, clap::ValueEnum)]
|
|
||||||
pub enum PullStrategy {
|
|
||||||
Merge,
|
|
||||||
Overwrite,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn run() -> Result<()> {
|
pub fn run() -> Result<()> {
|
||||||
let cli = Cli::parse();
|
let cli = Cli::parse();
|
||||||
match cli.command.unwrap_or(Command::Tui) {
|
match cli.command.unwrap_or(Command::Tui) {
|
||||||
@@ -63,7 +44,7 @@ pub fn run() -> Result<()> {
|
|||||||
println!("imported {count} connections");
|
println!("imported {count} connections");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
Command::Sync { command } => run_sync(command),
|
Command::Sync => run_sync(),
|
||||||
Command::Doctor { name } => doctor(name),
|
Command::Doctor { name } => doctor(name),
|
||||||
Command::ConfigPath => {
|
Command::ConfigPath => {
|
||||||
println!("{}", config_path()?.display());
|
println!("{}", config_path()?.display());
|
||||||
@@ -72,31 +53,13 @@ pub fn run() -> Result<()> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn run_sync(command: SyncCommand) -> Result<()> {
|
fn run_sync() -> Result<()> {
|
||||||
let mut cfg = SshellConfig::load()?;
|
let mut cfg = SshellConfig::load()?;
|
||||||
let strat = |s: PullStrategy| match s {
|
let report = match cfg.settings.backend {
|
||||||
PullStrategy::Merge => sync::PullStrategy::Merge,
|
SyncBackend::Gist => gist::sync(&mut cfg)?,
|
||||||
PullStrategy::Overwrite => sync::PullStrategy::Overwrite,
|
SyncBackend::Webdav => webdav::sync(&mut cfg)?,
|
||||||
};
|
};
|
||||||
match command {
|
println!("{report}");
|
||||||
SyncCommand::Push => match cfg.settings.backend {
|
|
||||||
SyncBackend::Gist => {
|
|
||||||
let id = gist::push(&mut cfg)?;
|
|
||||||
println!("pushed ({id})");
|
|
||||||
}
|
|
||||||
SyncBackend::Webdav => {
|
|
||||||
webdav::push(&mut cfg)?;
|
|
||||||
println!("pushed");
|
|
||||||
}
|
|
||||||
},
|
|
||||||
SyncCommand::Pull { strategy } => {
|
|
||||||
let count = match cfg.settings.backend {
|
|
||||||
SyncBackend::Gist => gist::pull_with_strategy(&mut cfg, strat(strategy))?,
|
|
||||||
SyncBackend::Webdav => webdav::pull_with_strategy(&mut cfg, strat(strategy))?,
|
|
||||||
};
|
|
||||||
println!("pulled {count} items");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ pub struct SshellConfig {
|
|||||||
pub connections: IndexMap<String, ConnectionProfile>,
|
pub connections: IndexMap<String, ConnectionProfile>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub credentials: CredentialStore,
|
pub credentials: CredentialStore,
|
||||||
|
#[serde(default)]
|
||||||
|
pub deleted: IndexMap<String, u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
@@ -60,6 +62,8 @@ pub struct Settings {
|
|||||||
pub webdav_user: Option<String>,
|
pub webdav_user: Option<String>,
|
||||||
pub webdav_password: Option<String>,
|
pub webdav_password: Option<String>,
|
||||||
pub sync_password: Option<String>,
|
pub sync_password: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub sync_on_start: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -71,6 +75,7 @@ pub struct ConnectionProfile {
|
|||||||
pub source: ConnectionSource,
|
pub source: ConnectionSource,
|
||||||
pub added_order: u64,
|
pub added_order: u64,
|
||||||
pub usage_count: u64,
|
pub usage_count: u64,
|
||||||
|
pub modified_at: u64,
|
||||||
#[serde(flatten)]
|
#[serde(flatten)]
|
||||||
pub kind: ConnectionType,
|
pub kind: ConnectionType,
|
||||||
}
|
}
|
||||||
@@ -129,6 +134,7 @@ impl Default for SshellConfig {
|
|||||||
settings: Settings::default(),
|
settings: Settings::default(),
|
||||||
connections: IndexMap::new(),
|
connections: IndexMap::new(),
|
||||||
credentials: CredentialStore::default(),
|
credentials: CredentialStore::default(),
|
||||||
|
deleted: IndexMap::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -291,3 +297,10 @@ fn default_shell_sync() -> bool {
|
|||||||
fn default_ssh_sync() -> bool {
|
fn default_ssh_sync() -> bool {
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn now_epoch_secs() -> u64 {
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_secs()
|
||||||
|
}
|
||||||
|
|||||||
@@ -108,6 +108,7 @@ impl super::SshellConfig {
|
|||||||
source: ConnectionSource::Scanned,
|
source: ConnectionSource::Scanned,
|
||||||
added_order: self.next_added_order(),
|
added_order: self.next_added_order(),
|
||||||
usage_count: 0,
|
usage_count: 0,
|
||||||
|
modified_at: 0,
|
||||||
kind: ConnectionType::Shell {
|
kind: ConnectionType::Shell {
|
||||||
shell_name: candidate.name.clone(),
|
shell_name: candidate.name.clone(),
|
||||||
auth_ref: None,
|
auth_ref: None,
|
||||||
|
|||||||
@@ -116,6 +116,7 @@ pub fn import_candidates(cfg: &mut SshellConfig, candidates: &[ImportCandidate])
|
|||||||
source: ConnectionSource::Imported,
|
source: ConnectionSource::Imported,
|
||||||
added_order: cfg.next_added_order(),
|
added_order: cfg.next_added_order(),
|
||||||
usage_count: 0,
|
usage_count: 0,
|
||||||
|
modified_at: crate::config::now_epoch_secs(),
|
||||||
kind: ConnectionType::Ssh {
|
kind: ConnectionType::Ssh {
|
||||||
host: item.host.clone(),
|
host: item.host.clone(),
|
||||||
port: item.port,
|
port: item.port,
|
||||||
|
|||||||
+245
-61
@@ -5,15 +5,242 @@ pub mod webdav;
|
|||||||
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};
|
||||||
|
use indexmap::IndexMap;
|
||||||
|
|
||||||
pub(crate) const GIST_TOKEN_REF: &str = "__gist_token";
|
pub(crate) const GIST_TOKEN_REF: &str = "__gist_token";
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy)]
|
// ── Sync report ──────────────────────────────────────────────────
|
||||||
pub enum PullStrategy {
|
|
||||||
Merge,
|
#[derive(Debug, Clone, Default)]
|
||||||
Overwrite,
|
pub struct SyncReport {
|
||||||
|
pub pulled: usize,
|
||||||
|
pub updated: usize,
|
||||||
|
pub pushed: usize,
|
||||||
|
pub deleted: usize,
|
||||||
|
pub skipped: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for SyncReport {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
let mut parts = Vec::new();
|
||||||
|
if self.pulled > 0 {
|
||||||
|
parts.push(format!("{} pulled", self.pulled));
|
||||||
|
}
|
||||||
|
if self.updated > 0 {
|
||||||
|
parts.push(format!("{} updated", self.updated));
|
||||||
|
}
|
||||||
|
if self.pushed > 0 {
|
||||||
|
parts.push(format!("{} pushed", self.pushed));
|
||||||
|
}
|
||||||
|
if self.deleted > 0 {
|
||||||
|
parts.push(format!("{} deleted", self.deleted));
|
||||||
|
}
|
||||||
|
if self.skipped > 0 {
|
||||||
|
parts.push(format!("{} skipped", self.skipped));
|
||||||
|
}
|
||||||
|
if parts.is_empty() {
|
||||||
|
write!(f, "already up to date")
|
||||||
|
} else {
|
||||||
|
write!(f, "sync: {}", parts.join(", "))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Remote payload ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
struct RemotePayload {
|
||||||
|
connections: IndexMap<String, crate::config::ConnectionProfile>,
|
||||||
|
credentials: CredentialStore,
|
||||||
|
deleted: IndexMap<String, u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_remote_payload(
|
||||||
|
remote: toml::Value,
|
||||||
|
sync_password: Option<&str>,
|
||||||
|
) -> Result<RemotePayload> {
|
||||||
|
let mut connections = IndexMap::new();
|
||||||
|
if let Some(conns) = remote.get("connections").and_then(|v| v.as_table()) {
|
||||||
|
for (name, profile_val) in conns {
|
||||||
|
if let Ok(profile) = profile_val
|
||||||
|
.clone()
|
||||||
|
.try_into::<crate::config::ConnectionProfile>()
|
||||||
|
{
|
||||||
|
connections.insert(name.clone(), profile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let credentials =
|
||||||
|
if let Some(enc) = remote.get("credentials_encrypted").and_then(|v| v.as_str()) {
|
||||||
|
let pw = sync_password
|
||||||
|
.context("sync_password not set; needed to decrypt remote credentials")?;
|
||||||
|
crypto::decrypt_credentials(enc, pw)?
|
||||||
|
} else if let Some(creds_val) = remote.get("credentials") {
|
||||||
|
creds_val
|
||||||
|
.clone()
|
||||||
|
.try_into::<CredentialStore>()
|
||||||
|
.unwrap_or_default()
|
||||||
|
} else {
|
||||||
|
CredentialStore::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut deleted = IndexMap::new();
|
||||||
|
if let Some(del) = remote.get("deleted").and_then(|v| v.as_table()) {
|
||||||
|
for (name, ts_val) in del {
|
||||||
|
if let Some(ts) = ts_val.as_integer() {
|
||||||
|
deleted.insert(name.clone(), ts as u64);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(RemotePayload {
|
||||||
|
connections,
|
||||||
|
credentials,
|
||||||
|
deleted,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Bidirectional merge ──────────────────────────────────────────
|
||||||
|
|
||||||
|
pub(crate) fn bidirectional_merge(
|
||||||
|
cfg: &mut SshellConfig,
|
||||||
|
remote: toml::Value,
|
||||||
|
) -> Result<SyncReport> {
|
||||||
|
let mut report = SyncReport::default();
|
||||||
|
let remote_payload = parse_remote_payload(remote, cfg.settings.sync_password.as_deref())?;
|
||||||
|
|
||||||
|
// 1. Merge remote connections into local
|
||||||
|
for (name, remote_profile) in &remote_payload.connections {
|
||||||
|
match cfg.connections.get_mut(name) {
|
||||||
|
None => {
|
||||||
|
// Only remote has it → pull
|
||||||
|
let mut p = remote_profile.clone();
|
||||||
|
if localize_shell_profile(cfg, name, &mut p) {
|
||||||
|
cfg.connections.insert(name.clone(), p);
|
||||||
|
report.pulled += 1;
|
||||||
|
} else {
|
||||||
|
report.skipped += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(local_profile)
|
||||||
|
if remote_profile.modified_at > local_profile.modified_at =>
|
||||||
|
{
|
||||||
|
// Remote is newer → update local (preserve local-only fields)
|
||||||
|
let mut p = remote_profile.clone();
|
||||||
|
p.local_tags = local_profile.local_tags.clone();
|
||||||
|
p.usage_count = local_profile.usage_count;
|
||||||
|
p.added_order = local_profile.added_order;
|
||||||
|
p.source = local_profile.source;
|
||||||
|
// Preserve local_args for shell connections
|
||||||
|
if let (
|
||||||
|
ConnectionType::Shell { local_args, .. },
|
||||||
|
ConnectionType::Shell {
|
||||||
|
local_args: remote_local_args,
|
||||||
|
..
|
||||||
|
},
|
||||||
|
) = (&local_profile.kind, &p.kind)
|
||||||
|
{
|
||||||
|
// Keep existing local_args from the remote profile's
|
||||||
|
// localization step
|
||||||
|
let _ = (local_args, remote_local_args);
|
||||||
|
}
|
||||||
|
if let ConnectionType::Shell { .. } = &p.kind {
|
||||||
|
// Re-localize the shell profile for this machine
|
||||||
|
let preserved = (
|
||||||
|
p.local_tags.clone(),
|
||||||
|
p.usage_count,
|
||||||
|
p.added_order,
|
||||||
|
p.source,
|
||||||
|
);
|
||||||
|
if localize_shell_profile(cfg, name, &mut p) {
|
||||||
|
p.local_tags = preserved.0;
|
||||||
|
p.usage_count = preserved.1;
|
||||||
|
p.added_order = preserved.2;
|
||||||
|
p.source = preserved.3;
|
||||||
|
*cfg.connections.get_mut(name).unwrap() = p;
|
||||||
|
report.updated += 1;
|
||||||
|
} else {
|
||||||
|
report.skipped += 1;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
*cfg.connections.get_mut(name).unwrap() = p;
|
||||||
|
report.updated += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
// Local is newer or equal → nothing to do
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Count connections to push (local has newer or remote doesn't have)
|
||||||
|
for (name, local_profile) in &cfg.connections {
|
||||||
|
if !local_profile.sync() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match remote_payload.connections.get(name) {
|
||||||
|
None => report.pushed += 1,
|
||||||
|
Some(remote_p) if local_profile.modified_at > remote_p.modified_at => {
|
||||||
|
report.pushed += 1
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Process remote tombstones
|
||||||
|
for (name, tombstone_ts) in &remote_payload.deleted {
|
||||||
|
if let Some(local_profile) = cfg.connections.get(name)
|
||||||
|
&& *tombstone_ts > local_profile.modified_at
|
||||||
|
{
|
||||||
|
// Remote deletion is newer → delete locally
|
||||||
|
let removed = cfg.connections.shift_remove(name);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
report.deleted += 1;
|
||||||
|
}
|
||||||
|
// else: local is newer or not present → local edit wins over deletion
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Merge credentials: bring in remote credentials for pulled/updated connections
|
||||||
|
for (name, remote_profile) in &remote_payload.connections {
|
||||||
|
let local_is_newer = cfg
|
||||||
|
.connections
|
||||||
|
.get(name)
|
||||||
|
.is_some_and(|lp| lp.modified_at >= remote_profile.modified_at);
|
||||||
|
|
||||||
|
if !local_is_newer {
|
||||||
|
// We accepted the remote version of this connection → bring its credential too
|
||||||
|
if let Some(auth_ref) = remote_profile.auth_ref()
|
||||||
|
&& let Some(credential) = remote_payload.credentials.entries.get(auth_ref)
|
||||||
|
{
|
||||||
|
cfg.credentials
|
||||||
|
.entries
|
||||||
|
.insert(auth_ref.to_string(), credential.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Prune local tombstones that both sides agree are gone
|
||||||
|
cfg.deleted.retain(|name, _| {
|
||||||
|
// Keep tombstone if remote still has this connection (need to propagate deletion)
|
||||||
|
remote_payload.connections.contains_key(name)
|
||||||
|
});
|
||||||
|
|
||||||
|
// NOTE: caller is responsible for cfg.save() after successful upload
|
||||||
|
Ok(report)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
pub(crate) fn localize_shell_profile(
|
pub(crate) fn localize_shell_profile(
|
||||||
cfg: &SshellConfig,
|
cfg: &SshellConfig,
|
||||||
name: &str,
|
name: &str,
|
||||||
@@ -129,66 +356,23 @@ pub(crate) fn build_sync_payload(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Include tombstones
|
||||||
|
if !payload.deleted.is_empty() {
|
||||||
|
let mut del_map = toml::map::Map::new();
|
||||||
|
for (name, ts) in &payload.deleted {
|
||||||
|
del_map.insert(name.clone(), toml::Value::Integer(*ts as i64));
|
||||||
|
}
|
||||||
|
table.insert("deleted".to_string(), toml::Value::Table(del_map));
|
||||||
|
}
|
||||||
|
|
||||||
Ok(toml::Value::Table(table))
|
Ok(toml::Value::Table(table))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn merge_remote(
|
pub(crate) fn count_synced(cfg: &SshellConfig) -> usize {
|
||||||
cfg: &mut SshellConfig,
|
cfg.connections
|
||||||
remote: toml::Value,
|
.iter()
|
||||||
strategy: PullStrategy,
|
.filter(|(_, p)| p.sync())
|
||||||
) -> Result<usize> {
|
.count()
|
||||||
let mut count = 0;
|
|
||||||
|
|
||||||
if let Some(conns) = remote.get("connections").and_then(|v| v.as_table()) {
|
|
||||||
for (name, profile_val) in conns {
|
|
||||||
let should_insert = match strategy {
|
|
||||||
PullStrategy::Merge => !cfg.connections.contains_key(name),
|
|
||||||
PullStrategy::Overwrite => true,
|
|
||||||
};
|
|
||||||
if should_insert
|
|
||||||
&& let Ok(mut profile) = profile_val
|
|
||||||
.clone()
|
|
||||||
.try_into::<crate::config::ConnectionProfile>()
|
|
||||||
&& localize_shell_profile(cfg, name, &mut profile)
|
|
||||||
{
|
|
||||||
cfg.connections.insert(name.clone(), profile);
|
|
||||||
count += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let remote_credentials =
|
|
||||||
if let Some(enc) = remote.get("credentials_encrypted").and_then(|v| v.as_str()) {
|
|
||||||
let sync_password = cfg
|
|
||||||
.settings
|
|
||||||
.sync_password
|
|
||||||
.as_deref()
|
|
||||||
.context("sync_password not set; needed to decrypt remote credentials")?;
|
|
||||||
Some(crypto::decrypt_credentials(enc, sync_password)?)
|
|
||||||
} else if let Some(creds_val) = remote.get("credentials") {
|
|
||||||
creds_val.clone().try_into::<CredentialStore>().ok()
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Some(remote_creds) = remote_credentials {
|
|
||||||
for (name, credential) in remote_creds.entries {
|
|
||||||
if name == GIST_TOKEN_REF {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let should_insert = match strategy {
|
|
||||||
PullStrategy::Merge => !cfg.credentials.entries.contains_key(&name),
|
|
||||||
PullStrategy::Overwrite => true,
|
|
||||||
};
|
|
||||||
if should_insert {
|
|
||||||
cfg.credentials.entries.insert(name, credential);
|
|
||||||
count += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg.save()?;
|
|
||||||
Ok(count)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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> {
|
||||||
|
|||||||
+51
-35
@@ -1,14 +1,52 @@
|
|||||||
use crate::config::{CredentialEntry, SshellConfig};
|
use crate::config::{CredentialEntry, SshellConfig};
|
||||||
use super::{GIST_TOKEN_REF, PullStrategy, build_sync_payload, merge_remote};
|
use super::{GIST_TOKEN_REF, SyncReport, build_sync_payload, bidirectional_merge, count_synced};
|
||||||
use anyhow::{Context, Result, bail};
|
use anyhow::{Context, Result, bail};
|
||||||
use reqwest::blocking::Client;
|
use reqwest::blocking::Client;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
const FILE_NAME: &str = "sshell-config.toml";
|
const FILE_NAME: &str = "sshell-config.toml";
|
||||||
|
|
||||||
pub fn push(cfg: &mut SshellConfig) -> Result<String> {
|
pub fn sync(cfg: &mut SshellConfig) -> Result<SyncReport> {
|
||||||
let token = gist_token(cfg)?;
|
let token = gist_token(cfg)?;
|
||||||
|
|
||||||
|
// Step 1: Download remote if gist_id exists
|
||||||
|
let remote_payload = if let Some(id) = &cfg.settings.gist_id {
|
||||||
|
let client = Client::new();
|
||||||
|
let response = client
|
||||||
|
.get(format!("https://api.github.com/gists/{id}"))
|
||||||
|
.bearer_auth(&token)
|
||||||
|
.header("User-Agent", "sshell")
|
||||||
|
.send()?;
|
||||||
|
if response.status().is_success() {
|
||||||
|
let value: serde_json::Value = response.json()?;
|
||||||
|
if let Some(content) = value["files"][FILE_NAME]["content"].as_str() {
|
||||||
|
Some(
|
||||||
|
toml::from_str(content).with_context(|| "failed to parse remote config")?,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
// Step 2: Snapshot before merge so we can rollback on upload failure
|
||||||
|
let snapshot = cfg.clone();
|
||||||
|
|
||||||
|
// Step 3: Bidirectional merge (modifies cfg in memory only)
|
||||||
|
let report = if let Some(remote) = remote_payload {
|
||||||
|
bidirectional_merge(cfg, remote)?
|
||||||
|
} else {
|
||||||
|
SyncReport {
|
||||||
|
pushed: count_synced(cfg),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Step 4: Upload merged payload
|
||||||
let payload = build_sync_payload(cfg, cfg.settings.sync_password.as_deref())?;
|
let payload = build_sync_payload(cfg, cfg.settings.sync_password.as_deref())?;
|
||||||
let content = toml::to_string_pretty(&payload)?;
|
let content = toml::to_string_pretty(&payload)?;
|
||||||
let body = json!({
|
let body = json!({
|
||||||
@@ -35,43 +73,21 @@ pub fn push(cfg: &mut SshellConfig) -> Result<String> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if !response.status().is_success() {
|
if !response.status().is_success() {
|
||||||
bail!("sync push failed: {}", response.status());
|
// Rollback in-memory state
|
||||||
|
*cfg = snapshot;
|
||||||
|
bail!("sync upload failed: {}", response.status());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Save gist_id if this was a first-time creation
|
||||||
|
if cfg.settings.gist_id.is_none() {
|
||||||
let value: serde_json::Value = response.json()?;
|
let value: serde_json::Value = response.json()?;
|
||||||
let id = value["id"]
|
if let Some(id) = value["id"].as_str() {
|
||||||
.as_str()
|
cfg.settings.gist_id = Some(id.to_string());
|
||||||
.context("sync response did not include id")?
|
}
|
||||||
.to_string();
|
}
|
||||||
cfg.settings.gist_id = Some(id.clone());
|
|
||||||
cfg.save()?;
|
cfg.save()?;
|
||||||
Ok(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn pull_with_strategy(cfg: &mut SshellConfig, strategy: PullStrategy) -> Result<usize> {
|
Ok(report)
|
||||||
let token = gist_token(cfg)?;
|
|
||||||
let id = cfg
|
|
||||||
.settings
|
|
||||||
.gist_id
|
|
||||||
.clone()
|
|
||||||
.context("gist id not configured")?;
|
|
||||||
let client = Client::new();
|
|
||||||
let response = client
|
|
||||||
.get(format!("https://api.github.com/gists/{id}"))
|
|
||||||
.bearer_auth(token)
|
|
||||||
.header("User-Agent", "sshell")
|
|
||||||
.send()?;
|
|
||||||
if !response.status().is_success() {
|
|
||||||
bail!("sync pull failed: {}", response.status());
|
|
||||||
}
|
|
||||||
let value: serde_json::Value = response.json()?;
|
|
||||||
let content = value["files"][FILE_NAME]["content"]
|
|
||||||
.as_str()
|
|
||||||
.context("remote file not found")?;
|
|
||||||
|
|
||||||
let remote: toml::Value =
|
|
||||||
toml::from_str(content).with_context(|| "failed to parse remote config")?;
|
|
||||||
|
|
||||||
merge_remote(cfg, remote, strategy)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn gist_token(cfg: &SshellConfig) -> Result<String> {
|
fn gist_token(cfg: &SshellConfig) -> Result<String> {
|
||||||
|
|||||||
+43
-44
@@ -1,75 +1,74 @@
|
|||||||
use crate::config::SshellConfig;
|
use crate::config::SshellConfig;
|
||||||
use super::{PullStrategy, build_sync_payload, merge_remote};
|
use super::{SyncReport, bidirectional_merge, build_sync_payload, count_synced};
|
||||||
use anyhow::{Context, Result, bail};
|
use anyhow::{Context, Result, bail};
|
||||||
use reqwest::blocking::Client;
|
use reqwest::blocking::Client;
|
||||||
use reqwest::header::{ACCEPT, CONTENT_TYPE};
|
use reqwest::header::{ACCEPT, CONTENT_TYPE};
|
||||||
|
|
||||||
const FILE_NAME: &str = "sshell-config.toml";
|
const FILE_NAME: &str = "sshell-config.toml";
|
||||||
|
|
||||||
pub fn push(cfg: &mut SshellConfig) -> Result<String> {
|
pub fn sync(cfg: &mut SshellConfig) -> Result<SyncReport> {
|
||||||
let url = webdav_file_url(cfg)?;
|
let url = webdav_file_url(cfg)?;
|
||||||
let user = cfg
|
let user = cfg
|
||||||
.settings
|
.settings
|
||||||
.webdav_user
|
.webdav_user
|
||||||
.as_deref()
|
.clone()
|
||||||
.context("webdav_user not set")?;
|
.context("webdav_user not set")?;
|
||||||
let password = cfg
|
let password = cfg
|
||||||
.settings
|
.settings
|
||||||
.webdav_password
|
.webdav_password
|
||||||
.as_deref()
|
.clone()
|
||||||
.context("webdav_password not set")?;
|
.context("webdav_password not set")?;
|
||||||
|
let client = Client::new();
|
||||||
|
|
||||||
|
// Step 1: Download remote
|
||||||
|
let remote_payload = {
|
||||||
|
let response = client
|
||||||
|
.get(&url)
|
||||||
|
.basic_auth(&user, Some(&password))
|
||||||
|
.header(ACCEPT, "*/*")
|
||||||
|
.send()?;
|
||||||
|
if response.status().is_success() {
|
||||||
|
let content = response.text()?;
|
||||||
|
Some(
|
||||||
|
toml::from_str(&content)
|
||||||
|
.with_context(|| "failed to parse remote config")?,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Step 2: Snapshot before merge so we can rollback on upload failure
|
||||||
|
let snapshot = cfg.clone();
|
||||||
|
|
||||||
|
// Step 3: Bidirectional merge (modifies cfg in memory only)
|
||||||
|
let report = if let Some(remote) = remote_payload {
|
||||||
|
bidirectional_merge(cfg, remote)?
|
||||||
|
} else {
|
||||||
|
SyncReport {
|
||||||
|
pushed: count_synced(cfg),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Step 4: Upload merged payload
|
||||||
let payload = build_sync_payload(cfg, cfg.settings.sync_password.as_deref())?;
|
let payload = build_sync_payload(cfg, cfg.settings.sync_password.as_deref())?;
|
||||||
let content = toml::to_string_pretty(&payload)?;
|
let content = toml::to_string_pretty(&payload)?;
|
||||||
|
|
||||||
let client = Client::new();
|
|
||||||
let response = client
|
let response = client
|
||||||
.put(&url)
|
.put(&url)
|
||||||
.basic_auth(user, Some(password))
|
.basic_auth(&user, Some(&password))
|
||||||
.header(CONTENT_TYPE, "text/plain")
|
.header(CONTENT_TYPE, "text/plain")
|
||||||
.header(ACCEPT, "*/*")
|
.header(ACCEPT, "*/*")
|
||||||
.body(content)
|
.body(content)
|
||||||
.send()?;
|
.send()?;
|
||||||
|
|
||||||
if !response.status().is_success() {
|
if !response.status().is_success() {
|
||||||
bail!("sync push failed: {}", response.status());
|
// Rollback in-memory state
|
||||||
|
*cfg = snapshot;
|
||||||
|
bail!("sync upload failed: {}", response.status());
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(url)
|
cfg.save()?;
|
||||||
}
|
Ok(report)
|
||||||
|
|
||||||
pub fn pull_with_strategy(cfg: &mut SshellConfig, strategy: PullStrategy) -> Result<usize> {
|
|
||||||
let url = webdav_file_url(cfg)?;
|
|
||||||
let user = cfg
|
|
||||||
.settings
|
|
||||||
.webdav_user
|
|
||||||
.as_deref()
|
|
||||||
.context("webdav_user not set")?;
|
|
||||||
let password = cfg
|
|
||||||
.settings
|
|
||||||
.webdav_password
|
|
||||||
.as_deref()
|
|
||||||
.context("webdav_password not set")?;
|
|
||||||
|
|
||||||
let client = Client::new();
|
|
||||||
let response = client
|
|
||||||
.get(&url)
|
|
||||||
.basic_auth(user, Some(password))
|
|
||||||
.header(ACCEPT, "*/*")
|
|
||||||
.send()?;
|
|
||||||
|
|
||||||
if response.status() == reqwest::StatusCode::NOT_FOUND {
|
|
||||||
bail!("sync pull failed: remote file not found");
|
|
||||||
}
|
|
||||||
if !response.status().is_success() {
|
|
||||||
bail!("sync pull failed: {}", response.status());
|
|
||||||
}
|
|
||||||
|
|
||||||
let content = response.text()?;
|
|
||||||
let remote: toml::Value =
|
|
||||||
toml::from_str(&content).with_context(|| "failed to parse remote config")?;
|
|
||||||
|
|
||||||
merge_remote(cfg, remote, strategy)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn webdav_file_url(cfg: &SshellConfig) -> Result<String> {
|
fn webdav_file_url(cfg: &SshellConfig) -> Result<String> {
|
||||||
|
|||||||
+14
-4
@@ -1,5 +1,5 @@
|
|||||||
use crate::app::{App, Mode};
|
use crate::app::{App, Mode};
|
||||||
use crate::config::ConnectionType;
|
use crate::config::{ConnectionType, SyncBackend};
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use crossterm::{
|
use crossterm::{
|
||||||
cursor::{Hide, Show},
|
cursor::{Hide, Show},
|
||||||
@@ -25,6 +25,16 @@ pub fn run() -> Result<()> {
|
|||||||
let mut terminal = Terminal::new(backend)?;
|
let mut terminal = Terminal::new(backend)?;
|
||||||
let mut app = App::load()?;
|
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),
|
||||||
|
};
|
||||||
|
if let Err(err) = result {
|
||||||
|
app.toast(err.to_string(), false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
spawn_latency_probes(&app);
|
spawn_latency_probes(&app);
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
@@ -116,12 +126,12 @@ fn spawn_latency_probes(app: &App) {
|
|||||||
// Re-check under lock to avoid duplicate spawns
|
// Re-check under lock to avoid duplicate spawns
|
||||||
{
|
{
|
||||||
let cache = app.session.latency.lock().unwrap();
|
let cache = app.session.latency.lock().unwrap();
|
||||||
if let Some(entry) = cache.get(&key) {
|
if let Some(entry) = cache.get(&key)
|
||||||
if now.duration_since(entry.checked_at) < stale_duration {
|
&& now.duration_since(entry.checked_at) < stale_duration
|
||||||
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
// Mark as "in-flight" by inserting a fresh entry
|
// Mark as "in-flight" by inserting a fresh entry
|
||||||
{
|
{
|
||||||
let mut cache = app.session.latency.lock().unwrap();
|
let mut cache = app.session.latency.lock().unwrap();
|
||||||
|
|||||||
@@ -16,8 +16,7 @@ use ratatui::{
|
|||||||
|
|
||||||
const ACTIONS: &[(&str, &str)] = &[
|
const ACTIONS: &[(&str, &str)] = &[
|
||||||
("Import", "scan shells & import SSH config"),
|
("Import", "scan shells & import SSH config"),
|
||||||
("Push Sync", "upload to cloud"),
|
("Sync", "bidirectional cloud sync"),
|
||||||
("Pull Sync", "download from cloud"),
|
|
||||||
("Credentials", "manage passwords & keys"),
|
("Credentials", "manage passwords & keys"),
|
||||||
("Settings", "preferences & sync config"),
|
("Settings", "preferences & sync config"),
|
||||||
];
|
];
|
||||||
@@ -95,10 +94,9 @@ impl View for ActionMenuView {
|
|||||||
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_combined_import()?,
|
||||||
1 => app.push_sync_with_toast(),
|
1 => app.sync_with_toast(),
|
||||||
2 => app.pull_sync_with_toast(),
|
2 => app.enter_credentials(),
|
||||||
3 => app.enter_credentials(),
|
3 => app.enter_settings(),
|
||||||
4 => app.enter_settings(),
|
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use crate::app::{App, FormAction, Mode, SettingsField, SettingsState, char_len};
|
use crate::app::{App, FormAction, Mode, SettingsField, SettingsState, char_len};
|
||||||
use crate::config::SyncBackend;
|
use crate::config::SyncBackend;
|
||||||
use crate::ui::component::{FormRow, badge_span};
|
use crate::ui::component::{FormRow, badge_span};
|
||||||
use crate::ui::{ACCENT, ORANGE};
|
use crate::ui::{ACCENT, GREEN, MUTED, ORANGE};
|
||||||
|
|
||||||
use super::{View, handle_form_nav};
|
use super::{View, handle_form_nav};
|
||||||
|
|
||||||
@@ -41,6 +41,11 @@ impl View for SettingsView {
|
|||||||
SyncBackend::Gist => badge_span("Gist", ACCENT),
|
SyncBackend::Gist => badge_span("Gist", ACCENT),
|
||||||
SyncBackend::Webdav => badge_span("WebDAV", ORANGE),
|
SyncBackend::Webdav => badge_span("WebDAV", ORANGE),
|
||||||
},
|
},
|
||||||
|
SettingsField::SyncOnStart => if settings.sync_on_start {
|
||||||
|
badge_span("on", GREEN)
|
||||||
|
} else {
|
||||||
|
badge_span("off", MUTED)
|
||||||
|
},
|
||||||
_ => unreachable!(),
|
_ => unreachable!(),
|
||||||
};
|
};
|
||||||
rows.push(FormRow::Toggle {
|
rows.push(FormRow::Toggle {
|
||||||
@@ -121,6 +126,9 @@ fn settings_toggle(settings: &mut SettingsState) {
|
|||||||
};
|
};
|
||||||
settings.ensure_active_visible();
|
settings.ensure_active_visible();
|
||||||
}
|
}
|
||||||
|
SettingsField::SyncOnStart => {
|
||||||
|
settings.sync_on_start = !settings.sync_on_start;
|
||||||
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user