refactor: enhance sync module with report tracking, payload parsing, and IndexMap support

This commit is contained in:
2026-06-05 17:15:18 +08:00
parent d88f0843b5
commit 8e6d732122
14 changed files with 412 additions and 215 deletions
+5
View File
@@ -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
View File
@@ -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),
} }
} }
+4
View File
@@ -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
View File
@@ -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
View File
@@ -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(())
} }
+13
View File
@@ -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()
}
+1
View File
@@ -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,
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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();
+4 -6
View File
@@ -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(),
_ => {} _ => {}
} }
} }
+9 -1
View File
@@ -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;
}
_ => {} _ => {}
} }
} }