From 8e6d7321223549d8aa265566a4c079b400fe5a13 Mon Sep 17 00:00:00 2001 From: rain-bus Date: Fri, 5 Jun 2026 17:15:18 +0800 Subject: [PATCH] refactor: enhance sync module with report tracking, payload parsing, and IndexMap support --- src/app/form.rs | 5 + src/app/home_ops.rs | 19 +-- src/app/profile_ext.rs | 4 + src/app/settings.rs | 10 +- src/cli.rs | 53 +------ src/config.rs | 13 ++ src/config/config_shell.rs | 1 + src/import.rs | 1 + src/sync.rs | 306 +++++++++++++++++++++++++++++-------- src/sync/gist.rs | 88 ++++++----- src/sync/webdav.rs | 87 ++++++----- src/ui/app.rs | 20 ++- src/ui/view/action_menu.rs | 10 +- src/ui/view/settings.rs | 10 +- 14 files changed, 412 insertions(+), 215 deletions(-) diff --git a/src/app/form.rs b/src/app/form.rs index cee814a..c538dd4 100644 --- a/src/app/form.rs +++ b/src/app/form.rs @@ -292,6 +292,9 @@ impl App { }; let removed = self.config.connections.shift_remove(&name); 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() { let still_used = self .config @@ -367,6 +370,7 @@ impl App { source, added_order, usage_count, + modified_at: crate::config::now_epoch_secs(), kind: ConnectionType::Shell { shell_name: shell_name_for_command(&self.session.form.command), auth_ref: None, @@ -389,6 +393,7 @@ impl App { source, added_order, usage_count, + modified_at: crate::config::now_epoch_secs(), kind: ConnectionType::Ssh { host, port, diff --git a/src/app/home_ops.rs b/src/app/home_ops.rs index 854a523..3f73f0d 100644 --- a/src/app/home_ops.rs +++ b/src/app/home_ops.rs @@ -63,24 +63,13 @@ impl App { Ok(()) } - pub fn push_sync_with_toast(&mut self) { + pub fn sync_with_toast(&mut self) { let result = match self.config.settings.backend { - SyncBackend::Gist => crate::sync::gist::push(&mut self.config).map(|id| format!("pushed ({id})")), - SyncBackend::Webdav => crate::sync::webdav::push(&mut self.config).map(|_| "pushed".to_string()), + SyncBackend::Gist => crate::sync::gist::sync(&mut self.config), + SyncBackend::Webdav => crate::sync::webdav::sync(&mut self.config), }; match result { - Ok(msg) => self.toast(msg, 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), + Ok(report) => self.toast(report.to_string(), true), Err(err) => self.toast(err.to_string(), false), } } diff --git a/src/app/profile_ext.rs b/src/app/profile_ext.rs index bc9824e..c7b6940 100644 --- a/src/app/profile_ext.rs +++ b/src/app/profile_ext.rs @@ -203,6 +203,7 @@ mod tests { source: crate::config::ConnectionSource::Manual, added_order: order, usage_count: usage, + modified_at: 0, kind: crate::config::ConnectionType::Ssh { host: "h".into(), port: 22, @@ -224,6 +225,7 @@ mod tests { source: crate::config::ConnectionSource::Manual, added_order: 1, usage_count: 0, + modified_at: 0, kind: ConnectionType::Ssh { host: "example.com".into(), port: 22, @@ -245,6 +247,7 @@ mod tests { source: crate::config::ConnectionSource::Manual, added_order: 1, usage_count: 0, + modified_at: 0, kind: ConnectionType::Ssh { host: "example.com".into(), port: 22, @@ -265,6 +268,7 @@ mod tests { source: crate::config::ConnectionSource::Manual, added_order: 1, usage_count: 0, + modified_at: 0, kind: ConnectionType::Ssh { host: "h".into(), port: 22, diff --git a/src/app/settings.rs b/src/app/settings.rs index e675f28..6964e94 100644 --- a/src/app/settings.rs +++ b/src/app/settings.rs @@ -5,6 +5,7 @@ use crate::config::SyncBackend; pub enum SettingsField { SyncPassword, Backend, + SyncOnStart, GistId, WebdavUrl, WebdavUser, @@ -13,7 +14,7 @@ pub enum SettingsField { impl SettingsField { pub fn visible_fields(backend: SyncBackend) -> Vec { - let mut fields = vec![SettingsField::SyncPassword, SettingsField::Backend]; + let mut fields = vec![SettingsField::SyncPassword, SettingsField::Backend, SettingsField::SyncOnStart]; match backend { SyncBackend::Gist => fields.push(SettingsField::GistId), SyncBackend::Webdav => { @@ -31,6 +32,7 @@ impl SettingsField { match self { Self::SyncPassword => "Encrypt Pwd", Self::Backend => "Backend", + Self::SyncOnStart => "Auto Sync", Self::GistId => "Gist ID", Self::WebdavUrl => "URL", Self::WebdavUser => "Username", @@ -39,7 +41,7 @@ impl SettingsField { } pub fn is_toggle(self) -> bool { - matches!(self, Self::Backend) + matches!(self, Self::Backend | Self::SyncOnStart) } pub fn is_text(self) -> bool { @@ -51,6 +53,7 @@ impl SettingsField { pub struct SettingsState { pub password: String, pub backend: SyncBackend, + pub sync_on_start: bool, pub gist_id: String, pub webdav_url: String, pub webdav_user: String, @@ -112,6 +115,7 @@ impl Default for SettingsState { Self { password: String::new(), backend: SyncBackend::Gist, + sync_on_start: false, gist_id: String::new(), webdav_url: String::new(), webdav_user: String::new(), @@ -169,6 +173,7 @@ impl App { .clone() .unwrap_or_default(); 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.webdav_url = self.config.settings.webdav_url.clone().unwrap_or_default(); @@ -185,6 +190,7 @@ impl App { 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.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(); diff --git a/src/cli.rs b/src/cli.rs index 4fb6312..5273b01 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,5 +1,5 @@ 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 anyhow::{Context, Result, bail}; use clap::{Parser, Subcommand}; @@ -23,31 +23,12 @@ enum Command { name: String, }, Import, - Sync { - #[command(subcommand)] - command: SyncCommand, - }, + Sync, Doctor { name: Option, }, 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<()> { let cli = Cli::parse(); match cli.command.unwrap_or(Command::Tui) { @@ -63,7 +44,7 @@ pub fn run() -> Result<()> { println!("imported {count} connections"); Ok(()) } - Command::Sync { command } => run_sync(command), + Command::Sync => run_sync(), Command::Doctor { name } => doctor(name), Command::ConfigPath => { 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 strat = |s: PullStrategy| match s { - PullStrategy::Merge => sync::PullStrategy::Merge, - PullStrategy::Overwrite => sync::PullStrategy::Overwrite, + let report = match cfg.settings.backend { + SyncBackend::Gist => gist::sync(&mut cfg)?, + SyncBackend::Webdav => webdav::sync(&mut cfg)?, }; - match command { - 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"); - } - } + println!("{report}"); Ok(()) } diff --git a/src/config.rs b/src/config.rs index b367c41..1164cd4 100644 --- a/src/config.rs +++ b/src/config.rs @@ -18,6 +18,8 @@ pub struct SshellConfig { pub connections: IndexMap, #[serde(default)] pub credentials: CredentialStore, + #[serde(default)] + pub deleted: IndexMap, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] @@ -60,6 +62,8 @@ pub struct Settings { pub webdav_user: Option, pub webdav_password: Option, pub sync_password: Option, + #[serde(default)] + pub sync_on_start: bool, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -71,6 +75,7 @@ pub struct ConnectionProfile { pub source: ConnectionSource, pub added_order: u64, pub usage_count: u64, + pub modified_at: u64, #[serde(flatten)] pub kind: ConnectionType, } @@ -129,6 +134,7 @@ impl Default for SshellConfig { settings: Settings::default(), connections: IndexMap::new(), credentials: CredentialStore::default(), + deleted: IndexMap::new(), } } } @@ -291,3 +297,10 @@ fn default_shell_sync() -> bool { fn default_ssh_sync() -> bool { true } + +pub fn now_epoch_secs() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} diff --git a/src/config/config_shell.rs b/src/config/config_shell.rs index cca1d2c..d439c17 100644 --- a/src/config/config_shell.rs +++ b/src/config/config_shell.rs @@ -108,6 +108,7 @@ impl super::SshellConfig { source: ConnectionSource::Scanned, added_order: self.next_added_order(), usage_count: 0, + modified_at: 0, kind: ConnectionType::Shell { shell_name: candidate.name.clone(), auth_ref: None, diff --git a/src/import.rs b/src/import.rs index 13af997..3df16ea 100644 --- a/src/import.rs +++ b/src/import.rs @@ -116,6 +116,7 @@ pub fn import_candidates(cfg: &mut SshellConfig, candidates: &[ImportCandidate]) source: ConnectionSource::Imported, added_order: cfg.next_added_order(), usage_count: 0, + modified_at: crate::config::now_epoch_secs(), kind: ConnectionType::Ssh { host: item.host.clone(), port: item.port, diff --git a/src/sync.rs b/src/sync.rs index ca71cda..72da688 100644 --- a/src/sync.rs +++ b/src/sync.rs @@ -5,15 +5,242 @@ pub mod webdav; use crate::config::ConnectionType; use crate::config::{ConnectionSource, CredentialStore, SshellConfig}; use anyhow::{Context, Result}; +use indexmap::IndexMap; pub(crate) const GIST_TOKEN_REF: &str = "__gist_token"; -#[derive(Debug, Clone, Copy)] -pub enum PullStrategy { - Merge, - Overwrite, +// ── Sync report ────────────────────────────────────────────────── + +#[derive(Debug, Clone, Default)] +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, + credentials: CredentialStore, + deleted: IndexMap, +} + +fn parse_remote_payload( + remote: toml::Value, + sync_password: Option<&str>, +) -> Result { + 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::() + { + 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::() + .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 { + 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( cfg: &SshellConfig, 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)) } -pub(crate) fn merge_remote( - cfg: &mut SshellConfig, - remote: toml::Value, - strategy: PullStrategy, -) -> Result { - 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::() - && 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::().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 count_synced(cfg: &SshellConfig) -> usize { + cfg.connections + .iter() + .filter(|(_, p)| p.sync()) + .count() } pub(crate) fn to_toml_value(val: &T) -> Result { diff --git a/src/sync/gist.rs b/src/sync/gist.rs index 742d44c..bb27847 100644 --- a/src/sync/gist.rs +++ b/src/sync/gist.rs @@ -1,14 +1,52 @@ 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 reqwest::blocking::Client; use serde_json::json; const FILE_NAME: &str = "sshell-config.toml"; -pub fn push(cfg: &mut SshellConfig) -> Result { +pub fn sync(cfg: &mut SshellConfig) -> Result { 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 content = toml::to_string_pretty(&payload)?; let body = json!({ @@ -35,43 +73,21 @@ pub fn push(cfg: &mut SshellConfig) -> Result { }; 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()?; + if let Some(id) = value["id"].as_str() { + cfg.settings.gist_id = Some(id.to_string()); + } } - let value: serde_json::Value = response.json()?; - let id = value["id"] - .as_str() - .context("sync response did not include id")? - .to_string(); - cfg.settings.gist_id = Some(id.clone()); cfg.save()?; - Ok(id) -} -pub fn pull_with_strategy(cfg: &mut SshellConfig, strategy: PullStrategy) -> Result { - 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) + Ok(report) } fn gist_token(cfg: &SshellConfig) -> Result { diff --git a/src/sync/webdav.rs b/src/sync/webdav.rs index 12948c3..ba0fe8f 100644 --- a/src/sync/webdav.rs +++ b/src/sync/webdav.rs @@ -1,75 +1,74 @@ 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 reqwest::blocking::Client; use reqwest::header::{ACCEPT, CONTENT_TYPE}; const FILE_NAME: &str = "sshell-config.toml"; -pub fn push(cfg: &mut SshellConfig) -> Result { +pub fn sync(cfg: &mut SshellConfig) -> Result { let url = webdav_file_url(cfg)?; let user = cfg .settings .webdav_user - .as_deref() + .clone() .context("webdav_user not set")?; let password = cfg .settings .webdav_password - .as_deref() + .clone() .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 content = toml::to_string_pretty(&payload)?; - - let client = Client::new(); let response = client .put(&url) - .basic_auth(user, Some(password)) + .basic_auth(&user, Some(&password)) .header(CONTENT_TYPE, "text/plain") .header(ACCEPT, "*/*") .body(content) .send()?; - 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) -} - -pub fn pull_with_strategy(cfg: &mut SshellConfig, strategy: PullStrategy) -> Result { - 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) + cfg.save()?; + Ok(report) } fn webdav_file_url(cfg: &SshellConfig) -> Result { diff --git a/src/ui/app.rs b/src/ui/app.rs index 82c5b6a..97e2ee3 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -1,5 +1,5 @@ use crate::app::{App, Mode}; -use crate::config::ConnectionType; +use crate::config::{ConnectionType, SyncBackend}; use anyhow::Result; use crossterm::{ cursor::{Hide, Show}, @@ -25,6 +25,16 @@ pub fn run() -> Result<()> { let mut terminal = Terminal::new(backend)?; 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); loop { @@ -116,10 +126,10 @@ fn spawn_latency_probes(app: &App) { // Re-check under lock to avoid duplicate spawns { let cache = app.session.latency.lock().unwrap(); - if let Some(entry) = cache.get(&key) { - if now.duration_since(entry.checked_at) < stale_duration { - continue; - } + if let Some(entry) = cache.get(&key) + && now.duration_since(entry.checked_at) < stale_duration + { + continue; } } // Mark as "in-flight" by inserting a fresh entry diff --git a/src/ui/view/action_menu.rs b/src/ui/view/action_menu.rs index f0a5006..f12986e 100644 --- a/src/ui/view/action_menu.rs +++ b/src/ui/view/action_menu.rs @@ -16,8 +16,7 @@ use ratatui::{ const ACTIONS: &[(&str, &str)] = &[ ("Import", "scan shells & import SSH config"), - ("Push Sync", "upload to cloud"), - ("Pull Sync", "download from cloud"), + ("Sync", "bidirectional cloud sync"), ("Credentials", "manage passwords & keys"), ("Settings", "preferences & sync config"), ]; @@ -95,10 +94,9 @@ impl View for ActionMenuView { app.session.mode = Mode::Home; match app.session.action_menu.cursor { 0 => app.enter_combined_import()?, - 1 => app.push_sync_with_toast(), - 2 => app.pull_sync_with_toast(), - 3 => app.enter_credentials(), - 4 => app.enter_settings(), + 1 => app.sync_with_toast(), + 2 => app.enter_credentials(), + 3 => app.enter_settings(), _ => {} } } diff --git a/src/ui/view/settings.rs b/src/ui/view/settings.rs index ca449ba..f84e7ea 100644 --- a/src/ui/view/settings.rs +++ b/src/ui/view/settings.rs @@ -1,7 +1,7 @@ use crate::app::{App, FormAction, Mode, SettingsField, SettingsState, char_len}; use crate::config::SyncBackend; 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}; @@ -41,6 +41,11 @@ impl View for SettingsView { SyncBackend::Gist => badge_span("Gist", ACCENT), SyncBackend::Webdav => badge_span("WebDAV", ORANGE), }, + SettingsField::SyncOnStart => if settings.sync_on_start { + badge_span("on", GREEN) + } else { + badge_span("off", MUTED) + }, _ => unreachable!(), }; rows.push(FormRow::Toggle { @@ -121,6 +126,9 @@ fn settings_toggle(settings: &mut SettingsState) { }; settings.ensure_active_visible(); } + SettingsField::SyncOnStart => { + settings.sync_on_start = !settings.sync_on_start; + } _ => {} } }