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);
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,
+4 -15
View File
@@ -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),
}
}
+4
View File
@@ -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,
+8 -2
View File
@@ -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<SettingsField> {
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();
+8 -45
View File
@@ -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<String>,
},
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(())
}
+13
View File
@@ -18,6 +18,8 @@ pub struct SshellConfig {
pub connections: IndexMap<String, ConnectionProfile>,
#[serde(default)]
pub credentials: CredentialStore,
#[serde(default)]
pub deleted: IndexMap<String, u64>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
@@ -60,6 +62,8 @@ pub struct Settings {
pub webdav_user: Option<String>,
pub webdav_password: Option<String>,
pub sync_password: Option<String>,
#[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()
}
+1
View File
@@ -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,
+1
View File
@@ -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,
+245 -61
View File
@@ -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<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(
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<usize> {
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 count_synced(cfg: &SshellConfig) -> usize {
cfg.connections
.iter()
.filter(|(_, p)| p.sync())
.count()
}
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 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<String> {
pub fn sync(cfg: &mut SshellConfig) -> Result<SyncReport> {
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<String> {
};
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 id = value["id"]
.as_str()
.context("sync response did not include id")?
.to_string();
cfg.settings.gist_id = Some(id.clone());
if let Some(id) = value["id"].as_str() {
cfg.settings.gist_id = Some(id.to_string());
}
}
cfg.save()?;
Ok(id)
}
pub fn pull_with_strategy(cfg: &mut SshellConfig, strategy: PullStrategy) -> Result<usize> {
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<String> {
+43 -44
View File
@@ -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<String> {
pub fn sync(cfg: &mut SshellConfig) -> Result<SyncReport> {
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<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)
cfg.save()?;
Ok(report)
}
fn webdav_file_url(cfg: &SshellConfig) -> Result<String> {
+14 -4
View File
@@ -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,12 +126,12 @@ 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 {
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
{
let mut cache = app.session.latency.lock().unwrap();
+4 -6
View File
@@ -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(),
_ => {}
}
}
+9 -1
View File
@@ -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;
}
_ => {}
}
}