Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c78f1b7c08 | |||
| 8e6d732122 | |||
| d88f0843b5 | |||
| 24af4d0f95 |
Generated
-12
@@ -836,15 +836,6 @@ version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
||||
|
||||
[[package]]
|
||||
name = "hmac"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
|
||||
dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "http"
|
||||
version = "1.4.0"
|
||||
@@ -2299,14 +2290,11 @@ dependencies = [
|
||||
"clap",
|
||||
"crossterm",
|
||||
"dirs",
|
||||
"hex",
|
||||
"hmac",
|
||||
"indexmap",
|
||||
"ratatui",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"tempfile",
|
||||
"toml",
|
||||
"whoami",
|
||||
|
||||
@@ -11,14 +11,11 @@ base64 = "0.22"
|
||||
clap = { version = "4.5", features = ["derive"] }
|
||||
crossterm = "0.29"
|
||||
dirs = "6"
|
||||
hex = "0.4"
|
||||
hmac = "0.12"
|
||||
indexmap = { version = "2", features = ["serde"] }
|
||||
ratatui = { version = "0.30", features = ["crossterm_0_29"] }
|
||||
reqwest = { version = "0.13.3", default-features = false, features = ["blocking", "json", "rustls"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
sha2 = "0.10"
|
||||
tempfile = "3.27"
|
||||
toml = "0.9"
|
||||
whoami = "1.6"
|
||||
|
||||
@@ -9,7 +9,7 @@ A personal SSH and shell connection manager with a terminal user interface (TUI)
|
||||
- **Import from `~/.ssh/config`** — One command to import all existing hosts
|
||||
- **Local shell scan** — Auto-detects available shells from `/etc/shells`
|
||||
- **Quick select** — Press `Ctrl+Q` then a number key (1–9) to connect instantly
|
||||
- **Encrypted cloud sync** — Push/pull config via GitHub Gist, WebDAV, or S3 with AES-256-GCM encryption
|
||||
- **Encrypted cloud sync** — Push/pull config via GitHub Gist or WebDAV with AES-256-GCM encryption
|
||||
- **CLI subcommands** — Connect, import, sync, and diagnostics without launching the TUI
|
||||
|
||||
## Installation
|
||||
|
||||
+1
-5
@@ -91,11 +91,7 @@ impl FormNav for CredFormState {
|
||||
|
||||
impl TextEditing for CredFormState {
|
||||
fn active_text(&self) -> &str {
|
||||
match self.active {
|
||||
CredFormField::Name => &self.name,
|
||||
CredFormField::Value => &self.value,
|
||||
_ => "",
|
||||
}
|
||||
self.field_value(self.active)
|
||||
}
|
||||
|
||||
fn active_text_mut(&mut self) -> Option<&mut String> {
|
||||
|
||||
+10
-37
@@ -220,19 +220,7 @@ impl FormNav for FormState {
|
||||
|
||||
impl TextEditing for FormState {
|
||||
fn active_text(&self) -> &str {
|
||||
match self.active {
|
||||
FormField::Name => &self.name,
|
||||
FormField::Host => &self.host,
|
||||
FormField::Port => &self.port,
|
||||
FormField::User => &self.user,
|
||||
FormField::CredId => &self.auth_ref,
|
||||
FormField::Secret => &self.secret,
|
||||
FormField::Command => &self.command,
|
||||
FormField::SyncArgs => &self.sync_args,
|
||||
FormField::LocalArgs => &self.local_args,
|
||||
FormField::Tags => &self.tags,
|
||||
_ => "",
|
||||
}
|
||||
self.field_value(self.active)
|
||||
}
|
||||
|
||||
fn active_text_mut(&mut self) -> Option<&mut String> {
|
||||
@@ -292,15 +280,11 @@ 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
|
||||
.connections
|
||||
.values()
|
||||
.any(|p| p.auth_ref() == Some(auth_ref));
|
||||
if !still_used {
|
||||
self.config.credentials.entries.shift_remove(auth_ref);
|
||||
}
|
||||
self.config.prune_credential_if_unused(auth_ref, None);
|
||||
}
|
||||
self.config.save()?;
|
||||
self.session.home.selected = self
|
||||
@@ -367,6 +351,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 +374,7 @@ impl App {
|
||||
source,
|
||||
added_order,
|
||||
usage_count,
|
||||
modified_at: crate::config::now_epoch_secs(),
|
||||
kind: ConnectionType::Ssh {
|
||||
host,
|
||||
port,
|
||||
@@ -430,7 +416,9 @@ impl App {
|
||||
|
||||
fn save_form_credential(&mut self, name: &str, auth_ref: &str, old_auth_ref: Option<String>) {
|
||||
if self.session.form.is_shell {
|
||||
self.remove_unused_old_credential(name, old_auth_ref);
|
||||
if let Some(old) = old_auth_ref {
|
||||
self.config.prune_credential_if_unused(&old, Some(name));
|
||||
}
|
||||
} else if !self.session.form.secret.is_empty() {
|
||||
let secret = resolve_secret(&self.session.form.secret);
|
||||
let entry = match self.session.form.auth_kind {
|
||||
@@ -443,21 +431,6 @@ impl App {
|
||||
.insert(auth_ref.to_string(), entry);
|
||||
}
|
||||
}
|
||||
|
||||
fn remove_unused_old_credential(&mut self, editing_name: &str, old_auth_ref: Option<String>) {
|
||||
let Some(old_auth_ref) = old_auth_ref else {
|
||||
return;
|
||||
};
|
||||
let still_used = self
|
||||
.config
|
||||
.connections
|
||||
.iter()
|
||||
.filter(|(conn_name, _)| conn_name.as_str() != editing_name)
|
||||
.any(|(_, profile)| profile.auth_ref() == Some(old_auth_ref.as_str()));
|
||||
if !still_used {
|
||||
self.config.credentials.entries.shift_remove(&old_auth_ref);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_tags(raw: &str) -> Vec<String> {
|
||||
|
||||
+3
-30
@@ -1,6 +1,5 @@
|
||||
use anyhow::Result;
|
||||
|
||||
use crate::config::SyncBackend;
|
||||
use super::{App, Mode};
|
||||
|
||||
impl App {
|
||||
@@ -13,16 +12,6 @@ impl App {
|
||||
self.session.mode = Mode::ActionMenu;
|
||||
}
|
||||
|
||||
pub fn enter_combined_import(&mut self) -> Result<()> {
|
||||
self.session.import.candidates = crate::import::load_candidates(&self.config)?;
|
||||
self.session.import.selected = vec![false; self.session.import.candidates.len()];
|
||||
self.session.import.shell_candidates = self.config.local_shell_candidates();
|
||||
self.session.import.shell_selected = vec![false; self.session.import.shell_candidates.len()];
|
||||
self.session.import.cursor = 0;
|
||||
self.session.mode = Mode::ImportSelector;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn enter_quick_select(&mut self) {
|
||||
if self.entries().is_empty() {
|
||||
self.toast("no connections available", false);
|
||||
@@ -63,26 +52,10 @@ impl App {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn push_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::S3 => crate::sync::s3::push(&mut self.config).map(|_| "pushed".to_string()),
|
||||
};
|
||||
pub fn sync_with_toast(&mut self) {
|
||||
let result = crate::sync::run_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),
|
||||
SyncBackend::S3 => crate::sync::s3::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,
|
||||
|
||||
+21
-88
@@ -1,24 +1,26 @@
|
||||
use super::{FormNav, TextEditing};
|
||||
use crate::config::SyncBackend;
|
||||
|
||||
/// Trim whitespace and return None if the result is empty.
|
||||
fn trimmed_opt(s: &str) -> Option<String> {
|
||||
let s = s.trim().to_string();
|
||||
if s.is_empty() { None } else { Some(s) }
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SettingsField {
|
||||
SyncPassword,
|
||||
Backend,
|
||||
SyncOnStart,
|
||||
GistId,
|
||||
WebdavUrl,
|
||||
WebdavUser,
|
||||
WebdavPassword,
|
||||
S3Endpoint,
|
||||
S3Bucket,
|
||||
S3AccessKey,
|
||||
S3SecretKey,
|
||||
SyncUsage,
|
||||
}
|
||||
|
||||
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 => {
|
||||
@@ -28,16 +30,7 @@ impl SettingsField {
|
||||
SettingsField::WebdavPassword,
|
||||
]);
|
||||
}
|
||||
SyncBackend::S3 => {
|
||||
fields.extend_from_slice(&[
|
||||
SettingsField::S3Endpoint,
|
||||
SettingsField::S3Bucket,
|
||||
SettingsField::S3AccessKey,
|
||||
SettingsField::S3SecretKey,
|
||||
]);
|
||||
}
|
||||
}
|
||||
fields.push(SettingsField::SyncUsage);
|
||||
fields
|
||||
}
|
||||
|
||||
@@ -45,20 +38,16 @@ 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",
|
||||
Self::WebdavPassword => "Password",
|
||||
Self::S3Endpoint => "Endpoint",
|
||||
Self::S3Bucket => "Bucket",
|
||||
Self::S3AccessKey => "Access Key",
|
||||
Self::S3SecretKey => "Secret Key",
|
||||
Self::SyncUsage => "Sync Usage",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_toggle(self) -> bool {
|
||||
matches!(self, Self::Backend | Self::SyncUsage)
|
||||
matches!(self, Self::Backend | Self::SyncOnStart)
|
||||
}
|
||||
|
||||
pub fn is_text(self) -> bool {
|
||||
@@ -70,15 +59,11 @@ 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,
|
||||
pub webdav_password: String,
|
||||
pub s3_endpoint: String,
|
||||
pub s3_bucket: String,
|
||||
pub s3_access_key: String,
|
||||
pub s3_secret_key: String,
|
||||
pub sync_usage: bool,
|
||||
pub active: SettingsField,
|
||||
pub cursor: usize,
|
||||
}
|
||||
@@ -91,10 +76,6 @@ impl SettingsState {
|
||||
SettingsField::WebdavUrl => &self.webdav_url,
|
||||
SettingsField::WebdavUser => &self.webdav_user,
|
||||
SettingsField::WebdavPassword => &self.webdav_password,
|
||||
SettingsField::S3Endpoint => &self.s3_endpoint,
|
||||
SettingsField::S3Bucket => &self.s3_bucket,
|
||||
SettingsField::S3AccessKey => &self.s3_access_key,
|
||||
SettingsField::S3SecretKey => &self.s3_secret_key,
|
||||
_ => "",
|
||||
}
|
||||
}
|
||||
@@ -140,15 +121,11 @@ 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(),
|
||||
webdav_password: String::new(),
|
||||
s3_endpoint: String::new(),
|
||||
s3_bucket: String::new(),
|
||||
s3_access_key: String::new(),
|
||||
s3_secret_key: String::new(),
|
||||
sync_usage: false,
|
||||
active: SettingsField::SyncPassword,
|
||||
cursor: 0,
|
||||
}
|
||||
@@ -157,18 +134,7 @@ impl Default for SettingsState {
|
||||
|
||||
impl TextEditing for SettingsState {
|
||||
fn active_text(&self) -> &str {
|
||||
match self.active {
|
||||
SettingsField::SyncPassword => &self.password,
|
||||
SettingsField::GistId => &self.gist_id,
|
||||
SettingsField::WebdavUrl => &self.webdav_url,
|
||||
SettingsField::WebdavUser => &self.webdav_user,
|
||||
SettingsField::WebdavPassword => &self.webdav_password,
|
||||
SettingsField::S3Endpoint => &self.s3_endpoint,
|
||||
SettingsField::S3Bucket => &self.s3_bucket,
|
||||
SettingsField::S3AccessKey => &self.s3_access_key,
|
||||
SettingsField::S3SecretKey => &self.s3_secret_key,
|
||||
_ => "",
|
||||
}
|
||||
self.field_text(self.active)
|
||||
}
|
||||
|
||||
fn active_text_mut(&mut self) -> Option<&mut String> {
|
||||
@@ -178,10 +144,6 @@ impl TextEditing for SettingsState {
|
||||
SettingsField::WebdavUrl => Some(&mut self.webdav_url),
|
||||
SettingsField::WebdavUser => Some(&mut self.webdav_user),
|
||||
SettingsField::WebdavPassword => Some(&mut self.webdav_password),
|
||||
SettingsField::S3Endpoint => Some(&mut self.s3_endpoint),
|
||||
SettingsField::S3Bucket => Some(&mut self.s3_bucket),
|
||||
SettingsField::S3AccessKey => Some(&mut self.s3_access_key),
|
||||
SettingsField::S3SecretKey => Some(&mut self.s3_secret_key),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -210,6 +172,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();
|
||||
@@ -217,49 +180,19 @@ impl App {
|
||||
self.config.settings.webdav_user.clone().unwrap_or_default();
|
||||
self.session.settings.webdav_password =
|
||||
self.config.settings.webdav_password.clone().unwrap_or_default();
|
||||
self.session.settings.s3_endpoint =
|
||||
self.config.settings.s3_endpoint.clone().unwrap_or_default();
|
||||
self.session.settings.s3_bucket =
|
||||
self.config.settings.s3_bucket.clone().unwrap_or_default();
|
||||
self.session.settings.s3_access_key =
|
||||
self.config.settings.s3_access_key.clone().unwrap_or_default();
|
||||
self.session.settings.s3_secret_key =
|
||||
self.config.settings.s3_secret_key.clone().unwrap_or_default();
|
||||
self.session.settings.sync_usage = self.config.settings.sync_usage_count;
|
||||
self.session.settings.active = SettingsField::SyncPassword;
|
||||
self.session.settings.cursor = char_len(self.session.settings.active_text());
|
||||
self.session.mode = Mode::Settings;
|
||||
}
|
||||
|
||||
pub fn save_settings(&mut self) -> Result<()> {
|
||||
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 = trimmed_opt(&self.session.settings.password);
|
||||
self.config.settings.backend = self.session.settings.backend;
|
||||
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();
|
||||
self.config.settings.webdav_url = if url.is_empty() { None } else { Some(url) };
|
||||
let user = self.session.settings.webdav_user.trim().to_string();
|
||||
self.config.settings.webdav_user = if user.is_empty() { None } else { Some(user) };
|
||||
let wd_pw = self.session.settings.webdav_password.trim().to_string();
|
||||
self.config.settings.webdav_password = if wd_pw.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(wd_pw)
|
||||
};
|
||||
let s3_ep = self.session.settings.s3_endpoint.trim().to_string();
|
||||
self.config.settings.s3_endpoint = if s3_ep.is_empty() { None } else { Some(s3_ep) };
|
||||
let s3_bk = self.session.settings.s3_bucket.trim().to_string();
|
||||
self.config.settings.s3_bucket = if s3_bk.is_empty() { None } else { Some(s3_bk) };
|
||||
let s3_ak = self.session.settings.s3_access_key.trim().to_string();
|
||||
self.config.settings.s3_access_key = if s3_ak.is_empty() { None } else { Some(s3_ak) };
|
||||
let s3_sk = self.session.settings.s3_secret_key.trim().to_string();
|
||||
self.config.settings.s3_secret_key = if s3_sk.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(s3_sk)
|
||||
};
|
||||
self.config.settings.sync_usage_count = self.session.settings.sync_usage;
|
||||
self.config.settings.sync_on_start = self.session.settings.sync_on_start;
|
||||
self.config.settings.gist_id = trimmed_opt(&self.session.settings.gist_id);
|
||||
self.config.settings.webdav_url = trimmed_opt(&self.session.settings.webdav_url);
|
||||
self.config.settings.webdav_user = trimmed_opt(&self.session.settings.webdav_user);
|
||||
self.config.settings.webdav_password = trimmed_opt(&self.session.settings.webdav_password);
|
||||
self.config.save()?;
|
||||
self.session.mode = Mode::Home;
|
||||
Ok(())
|
||||
|
||||
+7
-56
@@ -1,5 +1,5 @@
|
||||
use crate::config::{ConnectionType, CredentialEntry, SshellConfig, SyncBackend, config_path, find_binary};
|
||||
use crate::sync::{self, gist, s3, webdav};
|
||||
use crate::sync;
|
||||
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,36 +53,10 @@ 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,
|
||||
};
|
||||
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");
|
||||
}
|
||||
SyncBackend::S3 => {
|
||||
s3::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))?,
|
||||
SyncBackend::S3 => s3::pull_with_strategy(&mut cfg, strat(strategy))?,
|
||||
};
|
||||
println!("pulled {count} items");
|
||||
}
|
||||
}
|
||||
let report = sync::run_sync(&mut cfg)?;
|
||||
println!("{report}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -124,7 +79,6 @@ fn doctor(name: Option<String>) -> Result<()> {
|
||||
match cfg.settings.backend {
|
||||
SyncBackend::Gist => "gist",
|
||||
SyncBackend::Webdav => "webdav",
|
||||
SyncBackend::S3 => "s3",
|
||||
}
|
||||
);
|
||||
println!(
|
||||
@@ -186,13 +140,10 @@ fn check_connection(
|
||||
}
|
||||
ConnectionType::Shell {
|
||||
command,
|
||||
sync_args,
|
||||
local_args,
|
||||
..
|
||||
} => {
|
||||
println!("type: shell");
|
||||
let mut merged_args = sync_args.clone();
|
||||
merged_args.extend(local_args.clone());
|
||||
let merged_args = profile.merged_shell_args();
|
||||
println!("command: {command} {}", merged_args.join(" "));
|
||||
}
|
||||
}
|
||||
|
||||
+39
-7
@@ -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)]
|
||||
@@ -49,7 +51,6 @@ pub enum SyncBackend {
|
||||
#[default]
|
||||
Gist,
|
||||
Webdav,
|
||||
S3,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
@@ -60,13 +61,9 @@ pub struct Settings {
|
||||
pub webdav_url: Option<String>,
|
||||
pub webdav_user: Option<String>,
|
||||
pub webdav_password: Option<String>,
|
||||
pub s3_endpoint: Option<String>,
|
||||
pub s3_bucket: Option<String>,
|
||||
pub s3_access_key: Option<String>,
|
||||
pub s3_secret_key: Option<String>,
|
||||
#[serde(default)]
|
||||
pub sync_usage_count: bool,
|
||||
pub sync_password: Option<String>,
|
||||
#[serde(default)]
|
||||
pub sync_on_start: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -78,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,
|
||||
}
|
||||
@@ -136,6 +134,7 @@ impl Default for SshellConfig {
|
||||
settings: Settings::default(),
|
||||
connections: IndexMap::new(),
|
||||
credentials: CredentialStore::default(),
|
||||
deleted: IndexMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -200,6 +199,19 @@ impl SshellConfig {
|
||||
.unwrap_or(0)
|
||||
+ 1
|
||||
}
|
||||
|
||||
/// Remove a credential if it is no longer referenced by any connection.
|
||||
/// `exclude` is an optional connection name to skip during the check (e.g. the one being renamed).
|
||||
pub fn prune_credential_if_unused(&mut self, auth_ref: &str, exclude: Option<&str>) {
|
||||
let still_used = self
|
||||
.connections
|
||||
.iter()
|
||||
.filter(|(name, _)| Some(name.as_str()) != exclude)
|
||||
.any(|(_, profile)| profile.auth_ref() == Some(auth_ref));
|
||||
if !still_used {
|
||||
self.credentials.entries.shift_remove(auth_ref);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CredentialEntry {
|
||||
@@ -236,6 +248,19 @@ impl ConnectionProfile {
|
||||
ConnectionType::Shell { sync, .. } => *sync,
|
||||
}
|
||||
}
|
||||
|
||||
/// For Shell connections, returns the merged sync_args + local_args.
|
||||
/// Returns an empty vec for SSH connections.
|
||||
pub fn merged_shell_args(&self) -> Vec<String> {
|
||||
match &self.kind {
|
||||
ConnectionType::Shell { sync_args, local_args, .. } => {
|
||||
let mut out = sync_args.clone();
|
||||
out.extend(local_args.iter().cloned());
|
||||
out
|
||||
}
|
||||
ConnectionType::Ssh { .. } => Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn expand_user_path(value: &str) -> PathBuf {
|
||||
@@ -298,3 +323,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,
|
||||
|
||||
+34
-6
@@ -1,9 +1,38 @@
|
||||
use crate::config::{ConnectionType, CredentialEntry, SshellConfig};
|
||||
use crate::config::{ConnectionType, CredentialEntry, SshellConfig, find_binary};
|
||||
use anyhow::{Context, Result, bail};
|
||||
use std::io::Write;
|
||||
use std::process::Command;
|
||||
use tempfile::NamedTempFile;
|
||||
|
||||
fn require_binary(name: &str) -> Result<()> {
|
||||
if find_binary(name).is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
let hint = match name {
|
||||
"ssh" => "\n\
|
||||
sshell requires `ssh` to connect via SSH.\n\
|
||||
\n\
|
||||
Install it with:\n\
|
||||
macOS: pre-installed (or: xcode-select --install)\n\
|
||||
Debian: sudo apt install openssh-client\n\
|
||||
Arch: sudo pacman -S openssh\n\
|
||||
Fedora: sudo dnf install openssh-clients\n\
|
||||
Windows: Settings → Apps → Optional Features → OpenSSH Client"
|
||||
,
|
||||
"sshpass" => "\n\
|
||||
Password-based SSH login requires `sshpass`.\n\
|
||||
Consider switching to private-key auth instead, or install it:\n\
|
||||
macOS: brew install hudochenkov/sshpass/sshpass\n\
|
||||
Debian: sudo apt install sshpass\n\
|
||||
Arch: sudo pacman -S sshpass\n\
|
||||
Fedora: sudo dnf install sshpass\n\
|
||||
Windows: not available — use private-key auth"
|
||||
,
|
||||
_ => "",
|
||||
};
|
||||
bail!("command not found: `{name}`{hint}");
|
||||
}
|
||||
|
||||
pub fn connect(name: &str, cfg: &SshellConfig) -> Result<()> {
|
||||
let profile = cfg
|
||||
.connections
|
||||
@@ -20,12 +49,9 @@ pub fn connect(name: &str, cfg: &SshellConfig) -> Result<()> {
|
||||
} => connect_ssh(cfg, host, *port, user, auth_ref),
|
||||
ConnectionType::Shell {
|
||||
command,
|
||||
sync_args,
|
||||
local_args,
|
||||
..
|
||||
} => {
|
||||
let mut merged_args = sync_args.clone();
|
||||
merged_args.extend(local_args.clone());
|
||||
let merged_args = profile.merged_shell_args();
|
||||
exec_shell(command, &merged_args)
|
||||
}
|
||||
}
|
||||
@@ -41,8 +67,10 @@ fn connect_ssh(
|
||||
if auth_ref.is_empty() {
|
||||
bail!("this connection has no credential; edit it and set password or private key");
|
||||
}
|
||||
require_binary("ssh")?;
|
||||
match cfg.credential(auth_ref) {
|
||||
Some(CredentialEntry::Password { value, .. }) => {
|
||||
require_binary("sshpass")?;
|
||||
let args = vec![
|
||||
"ssh".to_string(),
|
||||
"-o".to_string(),
|
||||
@@ -87,7 +115,7 @@ fn run_sshpass(args: &[String], password: &str) -> Result<()> {
|
||||
.args(args)
|
||||
.env("SSHPASS", password)
|
||||
.status()
|
||||
.context("failed to run sshpass — is it installed?")?;
|
||||
.context("failed to run sshpass")?;
|
||||
std::process::exit(status.code().unwrap_or(1));
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
+250
-70
@@ -1,20 +1,242 @@
|
||||
mod crypto;
|
||||
pub mod gist;
|
||||
pub mod s3;
|
||||
pub mod webdav;
|
||||
|
||||
use crate::config::SyncBackend;
|
||||
|
||||
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()
|
||||
{
|
||||
cfg.prune_credential_if_unused(auth_ref, None);
|
||||
}
|
||||
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,
|
||||
@@ -60,9 +282,6 @@ pub(crate) fn build_sync_payload(
|
||||
sync_password: Option<&str>,
|
||||
) -> Result<toml::Value> {
|
||||
let mut payload = cfg.clone();
|
||||
payload.settings.sync_password = None;
|
||||
payload.settings.webdav_password = None;
|
||||
payload.settings.s3_secret_key = None;
|
||||
|
||||
let synced_refs: Vec<String> = payload
|
||||
.connections
|
||||
@@ -90,14 +309,10 @@ pub(crate) fn build_sync_payload(
|
||||
"version".to_string(),
|
||||
toml::Value::Integer(payload.version as i64),
|
||||
);
|
||||
table.insert("settings".to_string(), to_toml_value(&payload.settings)?);
|
||||
|
||||
let mut conns = toml::map::Map::new();
|
||||
for (name, profile) in &mut payload.connections {
|
||||
profile.local_tags.clear();
|
||||
if !payload.settings.sync_usage_count {
|
||||
profile.usage_count = 0;
|
||||
}
|
||||
profile.usage_count = 0;
|
||||
match &mut profile.kind {
|
||||
ConnectionType::Shell {
|
||||
auth_ref,
|
||||
@@ -137,68 +352,33 @@ 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> {
|
||||
toml::Value::try_from(val).map_err(|e| anyhow::anyhow!("toml conversion failed: {e}"))
|
||||
}
|
||||
|
||||
/// Run sync using the configured backend.
|
||||
pub fn run_sync(cfg: &mut crate::config::SshellConfig) -> Result<SyncReport> {
|
||||
match cfg.settings.backend {
|
||||
SyncBackend::Gist => gist::sync(cfg),
|
||||
SyncBackend::Webdav => webdav::sync(cfg),
|
||||
}
|
||||
}
|
||||
|
||||
+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> {
|
||||
|
||||
-313
@@ -1,313 +0,0 @@
|
||||
use crate::config::SshellConfig;
|
||||
use super::{PullStrategy, build_sync_payload, merge_remote};
|
||||
use anyhow::{Context, Result, bail};
|
||||
use hmac::{Hmac, Mac};
|
||||
use reqwest::blocking::Client;
|
||||
use reqwest::header::{CONTENT_TYPE, HOST};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
const FILE_NAME: &str = "sshell-config.toml";
|
||||
const SERVICE: &str = "s3";
|
||||
|
||||
pub fn push(cfg: &mut SshellConfig) -> Result<String> {
|
||||
let endpoint = cfg.settings.s3_endpoint.as_deref().context("s3_endpoint not set")?;
|
||||
let bucket = cfg.settings.s3_bucket.as_deref().context("s3_bucket not set")?;
|
||||
let access_key = cfg.settings.s3_access_key.as_deref().context("s3_access_key not set")?;
|
||||
let secret_key = cfg.settings.s3_secret_key.as_deref().context("s3_secret_key not set")?;
|
||||
let payload = build_sync_payload(cfg, cfg.settings.sync_password.as_deref())?;
|
||||
let body = toml::to_string_pretty(&payload)?;
|
||||
let body_bytes = body.as_bytes();
|
||||
|
||||
let path = format!("/{bucket}/{FILE_NAME}");
|
||||
let host = endpoint_host(endpoint);
|
||||
|
||||
let now = chrono_now();
|
||||
let region = region_from_endpoint(endpoint);
|
||||
|
||||
let payload_hash = hex_hash(body_bytes);
|
||||
let (auth_header, amz_date) = sign(
|
||||
access_key,
|
||||
secret_key,
|
||||
®ion,
|
||||
&SigningRequest {
|
||||
method: "PUT",
|
||||
host: &host,
|
||||
path: &path,
|
||||
query: &[],
|
||||
payload_hash: &payload_hash,
|
||||
timestamp: &now,
|
||||
},
|
||||
);
|
||||
|
||||
let url = format!("https://{host}{path}");
|
||||
|
||||
let client = Client::new();
|
||||
let response = client
|
||||
.put(&url)
|
||||
.header(HOST, host.clone())
|
||||
.header(CONTENT_TYPE, "application/octet-stream")
|
||||
.header("x-amz-content-sha256", &payload_hash)
|
||||
.header("x-amz-date", &amz_date)
|
||||
.header("Authorization", &auth_header)
|
||||
.body(body)
|
||||
.send()?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let body = response.text().unwrap_or_default();
|
||||
bail!("sync push failed: {status} {body}");
|
||||
}
|
||||
|
||||
Ok(url)
|
||||
}
|
||||
|
||||
pub fn pull_with_strategy(cfg: &mut SshellConfig, strategy: PullStrategy) -> Result<usize> {
|
||||
let endpoint = cfg.settings.s3_endpoint.as_deref().context("s3_endpoint not set")?;
|
||||
let bucket = cfg.settings.s3_bucket.as_deref().context("s3_bucket not set")?;
|
||||
let access_key = cfg.settings.s3_access_key.as_deref().context("s3_access_key not set")?;
|
||||
let secret_key = cfg.settings.s3_secret_key.as_deref().context("s3_secret_key not set")?;
|
||||
|
||||
let path = format!("/{bucket}/{FILE_NAME}");
|
||||
let host = endpoint_host(endpoint);
|
||||
|
||||
let now = chrono_now();
|
||||
let region = region_from_endpoint(endpoint);
|
||||
|
||||
let payload_hash = hex_hash(b"");
|
||||
let (auth_header, amz_date) = sign(
|
||||
access_key,
|
||||
secret_key,
|
||||
®ion,
|
||||
&SigningRequest {
|
||||
method: "GET",
|
||||
host: &host,
|
||||
path: &path,
|
||||
query: &[],
|
||||
payload_hash: &payload_hash,
|
||||
timestamp: &now,
|
||||
},
|
||||
);
|
||||
|
||||
let url = format!("https://{host}{path}");
|
||||
|
||||
let client = Client::new();
|
||||
let response = client
|
||||
.get(&url)
|
||||
.header(HOST, host.clone())
|
||||
.header("x-amz-content-sha256", &payload_hash)
|
||||
.header("x-amz-date", &amz_date)
|
||||
.header("Authorization", &auth_header)
|
||||
.send()?;
|
||||
|
||||
if response.status() == reqwest::StatusCode::NOT_FOUND {
|
||||
bail!("sync pull failed: remote file not found");
|
||||
}
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let body = response.text().unwrap_or_default();
|
||||
bail!("sync pull failed: {status} {body}");
|
||||
}
|
||||
|
||||
let content = response.text()?;
|
||||
let remote: toml::Value =
|
||||
toml::from_str(&content).with_context(|| "failed to parse remote config")?;
|
||||
|
||||
merge_remote(cfg, remote, strategy)
|
||||
}
|
||||
|
||||
// ── AWS Signature V4 ───────────────────────────────────────────
|
||||
|
||||
struct SigningRequest<'a> {
|
||||
method: &'a str,
|
||||
host: &'a str,
|
||||
path: &'a str,
|
||||
query: &'a [(&'a str, &'a str)],
|
||||
payload_hash: &'a str,
|
||||
timestamp: &'a str,
|
||||
}
|
||||
|
||||
fn sign(
|
||||
access_key: &str,
|
||||
secret_key: &str,
|
||||
region: &str,
|
||||
req: &SigningRequest<'_>,
|
||||
) -> (String, String) {
|
||||
let date = &req.timestamp[..8];
|
||||
let amz_date = req.timestamp.to_string();
|
||||
|
||||
let content_type_val = if req.method == "PUT" {
|
||||
"application/octet-stream"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
|
||||
// Canonical headers (sorted by key)
|
||||
let mut headers: Vec<(&str, String)> = vec![
|
||||
("content-type", content_type_val.to_string()),
|
||||
("host", req.host.to_string()),
|
||||
("x-amz-content-sha256", req.payload_hash.to_string()),
|
||||
("x-amz-date", amz_date.clone()),
|
||||
];
|
||||
headers.sort_by_key(|(k, _)| *k);
|
||||
|
||||
let signed_headers: String = headers.iter().map(|(k, _)| *k).collect::<Vec<_>>().join(";");
|
||||
let canonical_headers: String = headers
|
||||
.iter()
|
||||
.map(|(k, v)| format!("{k}:{v}\n"))
|
||||
.collect();
|
||||
|
||||
let canonical_querystring = req.query
|
||||
.iter()
|
||||
.map(|(k, v)| format!("{}={}", url_encode(k), url_encode(v)))
|
||||
.collect::<Vec<_>>()
|
||||
.join("&");
|
||||
|
||||
let canonical_request = format!(
|
||||
"{method}\n{path}\n{qs}\n{headers}\n{signed}\n{hash}",
|
||||
method = req.method,
|
||||
path = req.path,
|
||||
qs = canonical_querystring,
|
||||
headers = canonical_headers,
|
||||
signed = signed_headers,
|
||||
hash = req.payload_hash,
|
||||
);
|
||||
|
||||
let credential_scope = format!("{date}/{region}/{SERVICE}/aws4_request");
|
||||
let string_to_sign = format!(
|
||||
"AWS4-HMAC-SHA256\n{timestamp}\n{scope}\n{hash}",
|
||||
timestamp = req.timestamp,
|
||||
scope = credential_scope,
|
||||
hash = hex_hash(canonical_request.as_bytes()),
|
||||
);
|
||||
|
||||
let signing_key = derive_signing_key(secret_key, date, region);
|
||||
let signature = hex_hmac(&signing_key, string_to_sign.as_bytes());
|
||||
|
||||
let auth = format!(
|
||||
"AWS4-HMAC-SHA256 Credential={access_key}/{credential_scope}, SignedHeaders={signed_headers}, Signature={signature}"
|
||||
);
|
||||
|
||||
(auth, amz_date)
|
||||
}
|
||||
|
||||
fn derive_signing_key(secret_key: &str, date: &str, region: &str) -> Vec<u8> {
|
||||
let k_date = hmac_bytes(format!("AWS4{secret_key}").as_bytes(), date.as_bytes());
|
||||
let k_region = hmac_bytes(&k_date, region.as_bytes());
|
||||
let k_service = hmac_bytes(&k_region, SERVICE.as_bytes());
|
||||
hmac_bytes(&k_service, b"aws4_request")
|
||||
}
|
||||
|
||||
fn hmac_bytes(key: &[u8], data: &[u8]) -> Vec<u8> {
|
||||
let mut mac = HmacSha256::new_from_slice(key).expect("HMAC key len");
|
||||
mac.update(data);
|
||||
mac.finalize().into_bytes().to_vec()
|
||||
}
|
||||
|
||||
fn hex_hmac(key: &[u8], data: &[u8]) -> String {
|
||||
hex::encode(hmac_bytes(key, data))
|
||||
}
|
||||
|
||||
fn hex_hash(data: &[u8]) -> String {
|
||||
hex::encode(Sha256::digest(data))
|
||||
}
|
||||
|
||||
fn url_encode(s: &str) -> String {
|
||||
let mut out = String::new();
|
||||
for b in s.bytes() {
|
||||
match b {
|
||||
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'_' | b'-' | b'~' | b'.' => {
|
||||
out.push(b as char)
|
||||
}
|
||||
_ => out.push_str(&format!("%{b:02X}")),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn endpoint_host(endpoint: &str) -> String {
|
||||
let s = endpoint
|
||||
.strip_prefix("https://")
|
||||
.or_else(|| endpoint.strip_prefix("http://"))
|
||||
.unwrap_or(endpoint);
|
||||
s.trim_end_matches('/').to_string()
|
||||
}
|
||||
|
||||
fn region_from_endpoint(endpoint: &str) -> String {
|
||||
if endpoint.contains("r2.cloudflarestorage.com") {
|
||||
return "auto".to_string();
|
||||
}
|
||||
let host = endpoint_host(endpoint);
|
||||
let parts: Vec<&str> = host.split('.').collect();
|
||||
for (i, part) in parts.iter().enumerate() {
|
||||
if *part == "s3" && i + 1 < parts.len() {
|
||||
return parts[i + 1].to_string();
|
||||
}
|
||||
}
|
||||
"us-east-1".to_string()
|
||||
}
|
||||
|
||||
fn chrono_now() -> String {
|
||||
let now = std::time::SystemTime::now();
|
||||
let duration = now
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default();
|
||||
let secs = duration.as_secs();
|
||||
// Simple UTC time formatting without chrono dependency
|
||||
let days = secs / 86400;
|
||||
let time_of_day = secs % 86400;
|
||||
let hours = time_of_day / 3600;
|
||||
let minutes = (time_of_day % 3600) / 60;
|
||||
let seconds = time_of_day % 60;
|
||||
|
||||
// Calculate year/month/day from days since epoch
|
||||
let (year, month, day) = days_to_date(days);
|
||||
|
||||
format!(
|
||||
"{year:04}{month:02}{day:02}T{hours:02}{minutes:02}{seconds:02}Z"
|
||||
)
|
||||
}
|
||||
|
||||
fn days_to_date(mut days: u64) -> (u64, u64, u64) {
|
||||
let mut year = 1970u64;
|
||||
loop {
|
||||
let days_in_year = if is_leap(year) { 366 } else { 365 };
|
||||
if days < days_in_year {
|
||||
break;
|
||||
}
|
||||
days -= days_in_year;
|
||||
year += 1;
|
||||
}
|
||||
let leap = is_leap(year);
|
||||
let month_days = [
|
||||
31,
|
||||
if leap { 29 } else { 28 },
|
||||
31,
|
||||
30,
|
||||
31,
|
||||
30,
|
||||
31,
|
||||
31,
|
||||
30,
|
||||
31,
|
||||
30,
|
||||
31,
|
||||
];
|
||||
let mut month = 0u64;
|
||||
for (i, &md) in month_days.iter().enumerate() {
|
||||
if days < md {
|
||||
month = i as u64 + 1;
|
||||
break;
|
||||
}
|
||||
days -= md;
|
||||
}
|
||||
if month == 0 {
|
||||
month = 12;
|
||||
}
|
||||
(year, month, days + 1)
|
||||
}
|
||||
|
||||
fn is_leap(year: u64) -> bool {
|
||||
(year.is_multiple_of(4) && !year.is_multiple_of(100)) || year.is_multiple_of(400)
|
||||
}
|
||||
+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> {
|
||||
|
||||
@@ -75,32 +75,41 @@ pub fn draw(frame: &mut Frame<'_>, app: &mut crate::app::App) {
|
||||
});
|
||||
|
||||
use view::View;
|
||||
let (title, hints): (&str, Vec<_>) = match app.session.mode {
|
||||
Mode::Home => (view::HomeListView.title(), view::HomeListView.hints()),
|
||||
Mode::ActionMenu => (view::ActionMenuView.title(), view::ActionMenuView.hints()),
|
||||
Mode::Search => (view::SearchView.title(), view::SearchView.hints()),
|
||||
Mode::QuickSelect => (view::QuickSelectView.title(), view::QuickSelectView.hints()),
|
||||
Mode::DeleteConfirm => (view::DeleteConfirmView.title(), view::DeleteConfirmView.hints()),
|
||||
Mode::Form => (view::FormView.title(), view::FormView.hints()),
|
||||
Mode::ImportSelector => (view::ImportView.title(), view::ImportView.hints()),
|
||||
Mode::Credentials => (view::CredListView.title(), view::CredListView.hints()),
|
||||
Mode::CredForm => (view::CredFormView.title(), view::CredFormView.hints()),
|
||||
Mode::Settings => (view::SettingsView.title(), view::SettingsView.hints()),
|
||||
let (title, hints): (&str, Vec<_>) = {
|
||||
let v: &dyn View = match app.session.mode {
|
||||
Mode::Home => &view::HomeListView,
|
||||
Mode::ActionMenu => &view::ActionMenuView,
|
||||
Mode::Search => &view::SearchView,
|
||||
Mode::QuickSelect => &view::QuickSelectView,
|
||||
Mode::DeleteConfirm => &view::DeleteConfirmView,
|
||||
Mode::Form => &view::FormView,
|
||||
Mode::ImportSelector => &view::ImportView,
|
||||
Mode::Credentials => &view::CredListView,
|
||||
Mode::CredForm => &view::CredFormView,
|
||||
Mode::Settings => &view::SettingsView,
|
||||
};
|
||||
(v.title(), v.hints())
|
||||
};
|
||||
|
||||
draw_header(frame, app, title, shell[0]);
|
||||
|
||||
match app.session.mode {
|
||||
Mode::Home => view::HomeListView.draw(frame, app, content),
|
||||
Mode::ActionMenu => view::HomeListView.draw(frame, app, content),
|
||||
Mode::Search => view::SearchView.draw(frame, app, content),
|
||||
Mode::QuickSelect => view::QuickSelectView.draw(frame, app, content),
|
||||
Mode::DeleteConfirm => view::DeleteConfirmView.draw(frame, app, content),
|
||||
Mode::Form => view::FormView.draw(frame, app, content),
|
||||
Mode::ImportSelector => view::ImportView.draw(frame, app, content),
|
||||
Mode::Credentials => view::CredListView.draw(frame, app, content),
|
||||
Mode::CredForm => view::CredFormView.draw(frame, app, content),
|
||||
Mode::Settings => view::SettingsView.draw(frame, app, content),
|
||||
Mode::Home | Mode::ActionMenu | Mode::DeleteConfirm => {
|
||||
view::HomeListView.draw(frame, app, content)
|
||||
}
|
||||
_ => {
|
||||
let v: &dyn View = match app.session.mode {
|
||||
Mode::Search => &view::SearchView,
|
||||
Mode::QuickSelect => &view::QuickSelectView,
|
||||
Mode::Form => &view::FormView,
|
||||
Mode::ImportSelector => &view::ImportView,
|
||||
Mode::Credentials => &view::CredListView,
|
||||
Mode::CredForm => &view::CredFormView,
|
||||
Mode::Settings => &view::SettingsView,
|
||||
_ => unreachable!(),
|
||||
};
|
||||
v.draw(frame, app, content);
|
||||
}
|
||||
}
|
||||
|
||||
draw_help(frame, &hints, shell[2]);
|
||||
|
||||
+11
-4
@@ -25,6 +25,13 @@ pub fn run() -> Result<()> {
|
||||
let mut terminal = Terminal::new(backend)?;
|
||||
let mut app = App::load()?;
|
||||
|
||||
if app.config.settings.sync_on_start {
|
||||
let result = crate::sync::run_sync(&mut app.config);
|
||||
if let Err(err) = result {
|
||||
app.toast(err.to_string(), false);
|
||||
}
|
||||
}
|
||||
|
||||
spawn_latency_probes(&app);
|
||||
|
||||
loop {
|
||||
@@ -116,10 +123,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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use crate::app::App;
|
||||
use crate::config::SyncBackend;
|
||||
use crate::ui::{ACCENT, BG, BLUE, DIM_BORDER, GREEN, MUTED, ORANGE, PANEL, PURPLE, TEXT};
|
||||
use crate::ui::{ACCENT, BG, BLUE, DIM_BORDER, GREEN, MUTED, ORANGE, PANEL, TEXT};
|
||||
use ratatui::{
|
||||
layout::{Alignment, Rect},
|
||||
style::{Color, Style, Stylize},
|
||||
@@ -31,13 +31,6 @@ pub fn draw_header(frame: &mut ratatui::Frame<'_>, app: &App, title: &str, area:
|
||||
("webdav not set", MUTED)
|
||||
}
|
||||
}
|
||||
SyncBackend::S3 => {
|
||||
if app.config.settings.s3_endpoint.is_some() {
|
||||
("s3 ready", PURPLE)
|
||||
} else {
|
||||
("s3 not set", MUTED)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let mut spans: Vec<Span<'static>> = vec![
|
||||
|
||||
+32
-5
@@ -7,12 +7,14 @@ mod import;
|
||||
mod settings;
|
||||
|
||||
use crate::app::{App, FormAction, FormNav};
|
||||
use crate::ui::BLUE;
|
||||
use anyhow::Result;
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
use ratatui::{
|
||||
Frame,
|
||||
layout::Rect,
|
||||
widgets::Row,
|
||||
style::{Modifier, Style},
|
||||
widgets::{Cell, Row},
|
||||
};
|
||||
|
||||
/// A View represents a full screen that handles both rendering and key events.
|
||||
@@ -38,17 +40,30 @@ pub use settings::SettingsView;
|
||||
/// Scroll a 1:1 row list so the selected index stays visible.
|
||||
/// `area_height` includes the block borders and table header.
|
||||
pub fn scroll_rows<'a>(rows: Vec<Row<'a>>, selected: usize, area_height: u16) -> Vec<Row<'a>> {
|
||||
scroll_indexed_rows(rows, &[selected], 0, area_height)
|
||||
}
|
||||
|
||||
/// Scroll a row list that mixes data rows with non-data rows (e.g. section headers).
|
||||
/// `entry_row` maps each data index to its visual row index in `rows`.
|
||||
/// `selected` is the currently selected data index.
|
||||
pub fn scroll_indexed_rows<'a>(
|
||||
rows: Vec<Row<'a>>,
|
||||
entry_row: &[usize],
|
||||
selected: usize,
|
||||
area_height: u16,
|
||||
) -> Vec<Row<'a>> {
|
||||
let visible = area_height.saturating_sub(3) as usize; // 2 borders + 1 header
|
||||
let total = rows.len();
|
||||
if visible == 0 || total <= visible {
|
||||
if visible == 0 || total <= visible || entry_row.is_empty() {
|
||||
return rows;
|
||||
}
|
||||
let scroll = if selected < visible / 2 {
|
||||
let sel_row = entry_row[selected.min(entry_row.len() - 1)];
|
||||
let scroll = if sel_row < visible / 2 {
|
||||
0
|
||||
} else if selected + visible / 2 >= total {
|
||||
} else if sel_row + visible / 2 >= total {
|
||||
total.saturating_sub(visible)
|
||||
} else {
|
||||
selected - visible / 2
|
||||
sel_row - visible / 2
|
||||
};
|
||||
rows.into_iter().skip(scroll).take(visible).collect()
|
||||
}
|
||||
@@ -79,3 +94,15 @@ pub fn handle_form_nav<F: FormNav>(form: &mut F, key: KeyEvent) -> Option<FormAc
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A section header row with a label and count, used in table views.
|
||||
pub fn section_row(label: &str, count: usize) -> Row<'static> {
|
||||
Row::new([
|
||||
Cell::from(format!(" {label} ({count})"))
|
||||
.style(Style::default().fg(BLUE).add_modifier(Modifier::BOLD)),
|
||||
Cell::from(""),
|
||||
Cell::from(""),
|
||||
Cell::from(""),
|
||||
])
|
||||
.height(1)
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
];
|
||||
@@ -94,11 +93,10 @@ impl View for ActionMenuView {
|
||||
ListAction::Select => {
|
||||
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(),
|
||||
0 => app.enter_import_selector()?,
|
||||
1 => app.sync_with_toast(),
|
||||
2 => app.enter_credentials(),
|
||||
3 => app.enter_settings(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ use crate::ui::component::{badge_span, draw_input, panel, tag_badge};
|
||||
use crate::ui::{ACCENT, BLUE, GREEN, MUTED, PANEL_ALT, PURPLE, RED, SELECTED_BG, TEXT, YELLOW};
|
||||
|
||||
use super::View;
|
||||
use super::{section_row, scroll_indexed_rows};
|
||||
|
||||
use anyhow::Result;
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
@@ -162,21 +163,7 @@ pub fn draw_connection_list(frame: &mut Frame<'_>, app: &App, area: Rect) {
|
||||
}
|
||||
|
||||
// Scroll to keep selected entry visible
|
||||
let visible = area.height.saturating_sub(3) as usize; // 2 borders + 1 header
|
||||
if visible > 0 && !entry_row.is_empty() {
|
||||
let sel_row = entry_row[app.session.home.selected.min(entry_row.len() - 1)];
|
||||
let total = rows.len();
|
||||
if total > visible {
|
||||
let scroll = if sel_row < visible / 2 {
|
||||
0
|
||||
} else if sel_row + visible / 2 >= total {
|
||||
total.saturating_sub(visible)
|
||||
} else {
|
||||
sel_row - visible / 2
|
||||
};
|
||||
rows = rows.into_iter().skip(scroll).take(visible).collect();
|
||||
}
|
||||
}
|
||||
rows = scroll_indexed_rows(rows, &entry_row, app.session.home.selected, area.height);
|
||||
|
||||
let title = if app.session.mode == Mode::QuickSelect {
|
||||
"Connections - Quick Select"
|
||||
@@ -203,17 +190,6 @@ pub fn draw_connection_list(frame: &mut Frame<'_>, app: &App, area: Rect) {
|
||||
frame.render_widget(table, area);
|
||||
}
|
||||
|
||||
fn section_row(label: &str, count: usize) -> Row<'static> {
|
||||
Row::new([
|
||||
Cell::from(format!(" {label} ({count})"))
|
||||
.style(Style::default().fg(BLUE).add_modifier(Modifier::BOLD)),
|
||||
Cell::from(""),
|
||||
Cell::from(""),
|
||||
Cell::from(""),
|
||||
])
|
||||
.height(1)
|
||||
}
|
||||
|
||||
fn connection_row(
|
||||
app: &App,
|
||||
idx: usize,
|
||||
@@ -252,11 +228,9 @@ fn connection_row(
|
||||
}
|
||||
ConnectionType::Shell {
|
||||
command,
|
||||
sync_args,
|
||||
local_args,
|
||||
..
|
||||
} => {
|
||||
let merged_args = shell_args(sync_args, local_args);
|
||||
let merged_args = profile.merged_shell_args();
|
||||
if merged_args.is_empty() {
|
||||
command.clone()
|
||||
} else {
|
||||
@@ -378,14 +352,12 @@ pub fn draw_detail_panel(frame: &mut Frame<'_>, app: &App, area: Rect) {
|
||||
shell_name,
|
||||
auth_ref,
|
||||
command,
|
||||
sync_args,
|
||||
local_args,
|
||||
sync,
|
||||
..
|
||||
} => {
|
||||
lines.push(detail_text("Shell", shell_name));
|
||||
lines.push(detail_text("Command", command));
|
||||
let merged_args = shell_args(sync_args, local_args);
|
||||
let merged_args = profile.merged_shell_args();
|
||||
if !merged_args.is_empty() {
|
||||
lines.push(detail_text("Args", &merged_args.join(" ")));
|
||||
}
|
||||
@@ -437,12 +409,6 @@ fn detail_text(label: &str, value: &str) -> Line<'static> {
|
||||
])
|
||||
}
|
||||
|
||||
fn shell_args(sync_args: &[String], local_args: &[String]) -> Vec<String> {
|
||||
let mut out = sync_args.to_vec();
|
||||
out.extend(local_args.iter().cloned());
|
||||
out
|
||||
}
|
||||
|
||||
fn detail_line(label: &str, spans: Vec<Span<'static>>) -> Line<'static> {
|
||||
let mut out = vec![Span::styled(
|
||||
format!(" {:<11}", label),
|
||||
|
||||
+2
-26
@@ -3,6 +3,7 @@ use crate::ui::component::{ListAction, handle_list_nav, panel, panel_with_subtit
|
||||
use crate::ui::{BLUE, GREEN, MUTED, SELECTED_BG, TEXT};
|
||||
|
||||
use super::View;
|
||||
use super::{section_row, scroll_indexed_rows};
|
||||
|
||||
use anyhow::Result;
|
||||
use crossterm::event::{KeyCode, KeyEvent};
|
||||
@@ -134,21 +135,7 @@ fn draw_import(frame: &mut Frame<'_>, app: &App, area: Rect) {
|
||||
}
|
||||
|
||||
// Scroll to keep selected entry visible
|
||||
let visible = area.height.saturating_sub(3) as usize;
|
||||
if visible > 0 && !entry_row.is_empty() {
|
||||
let sel_row = entry_row[app.session.import.cursor.min(entry_row.len() - 1)];
|
||||
let total_rows = rows.len();
|
||||
if total_rows > visible {
|
||||
let scroll = if sel_row < visible / 2 {
|
||||
0
|
||||
} else if sel_row + visible / 2 >= total_rows {
|
||||
total_rows.saturating_sub(visible)
|
||||
} else {
|
||||
sel_row - visible / 2
|
||||
};
|
||||
rows = rows.into_iter().skip(scroll).take(visible).collect();
|
||||
}
|
||||
}
|
||||
rows = scroll_indexed_rows(rows, &entry_row, app.session.import.cursor, area.height);
|
||||
|
||||
let table = Table::new(
|
||||
rows,
|
||||
@@ -172,17 +159,6 @@ fn draw_import(frame: &mut Frame<'_>, app: &App, area: Rect) {
|
||||
frame.render_widget(table, area);
|
||||
}
|
||||
|
||||
fn section_row(label: &str, count: usize) -> Row<'static> {
|
||||
Row::new([
|
||||
Cell::from(format!(" {label} ({count})"))
|
||||
.style(Style::default().fg(BLUE).add_modifier(Modifier::BOLD)),
|
||||
Cell::from(""),
|
||||
Cell::from(""),
|
||||
Cell::from(""),
|
||||
])
|
||||
.height(1)
|
||||
}
|
||||
|
||||
// ── Key handling ───────────────────────────────────────────────
|
||||
|
||||
fn handle_import(app: &mut App, key: KeyEvent) -> Result<()> {
|
||||
|
||||
+10
-15
@@ -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, GREEN, ORANGE, PURPLE, RED};
|
||||
use crate::ui::{ACCENT, GREEN, MUTED, ORANGE};
|
||||
|
||||
use super::{View, handle_form_nav};
|
||||
|
||||
@@ -31,7 +31,7 @@ impl View for SettingsView {
|
||||
let active = settings.active == field;
|
||||
let label = field.label().to_string();
|
||||
|
||||
if i == 2 || matches!(field, SettingsField::SyncUsage) {
|
||||
if i == 2 {
|
||||
rows.push(FormRow::Separator);
|
||||
}
|
||||
|
||||
@@ -40,15 +40,12 @@ impl View for SettingsView {
|
||||
SettingsField::Backend => match settings.backend {
|
||||
SyncBackend::Gist => badge_span("Gist", ACCENT),
|
||||
SyncBackend::Webdav => badge_span("WebDAV", ORANGE),
|
||||
SyncBackend::S3 => badge_span("S3", PURPLE),
|
||||
},
|
||||
SettingsField::SyncUsage => {
|
||||
if settings.sync_usage {
|
||||
badge_span("Yes", GREEN)
|
||||
} else {
|
||||
badge_span("No", RED)
|
||||
}
|
||||
}
|
||||
SettingsField::SyncOnStart => if settings.sync_on_start {
|
||||
badge_span("on", GREEN)
|
||||
} else {
|
||||
badge_span("off", MUTED)
|
||||
},
|
||||
_ => unreachable!(),
|
||||
};
|
||||
rows.push(FormRow::Toggle {
|
||||
@@ -62,7 +59,6 @@ impl View for SettingsView {
|
||||
field,
|
||||
SettingsField::SyncPassword
|
||||
| SettingsField::WebdavPassword
|
||||
| SettingsField::S3SecretKey
|
||||
);
|
||||
let (display, secret_cursor) = if is_secret {
|
||||
if raw.is_empty() {
|
||||
@@ -126,13 +122,12 @@ fn settings_toggle(settings: &mut SettingsState) {
|
||||
SettingsField::Backend => {
|
||||
settings.backend = match settings.backend {
|
||||
SyncBackend::Gist => SyncBackend::Webdav,
|
||||
SyncBackend::Webdav => SyncBackend::S3,
|
||||
SyncBackend::S3 => SyncBackend::Gist,
|
||||
SyncBackend::Webdav => SyncBackend::Gist,
|
||||
};
|
||||
settings.ensure_active_visible();
|
||||
}
|
||||
SettingsField::SyncUsage => {
|
||||
settings.sync_usage = !settings.sync_usage;
|
||||
SettingsField::SyncOnStart => {
|
||||
settings.sync_on_start = !settings.sync_on_start;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user