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);
|
||||
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
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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> {
|
||||
|
||||
+52
-36
@@ -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()?;
|
||||
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<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
@@ -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> {
|
||||
|
||||
+15
-5
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user