Initial commit: sshell project
This commit is contained in:
+17
@@ -0,0 +1,17 @@
|
|||||||
|
/target
|
||||||
|
debug/
|
||||||
|
*.pdb
|
||||||
|
|
||||||
|
*.rlib
|
||||||
|
*.d
|
||||||
|
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
.env
|
||||||
Generated
+3473
File diff suppressed because it is too large
Load Diff
+24
@@ -0,0 +1,24 @@
|
|||||||
|
[package]
|
||||||
|
name = "sshell"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
aes-gcm = "0.10"
|
||||||
|
anyhow = "1"
|
||||||
|
argon2 = "0.5"
|
||||||
|
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"
|
||||||
+109
@@ -0,0 +1,109 @@
|
|||||||
|
use super::{TextEditing, char_len};
|
||||||
|
use crate::app::AuthKind;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum CredFormField {
|
||||||
|
Name,
|
||||||
|
Kind,
|
||||||
|
Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CredFormField {
|
||||||
|
pub const ALL: [CredFormField; 3] = [
|
||||||
|
CredFormField::Name,
|
||||||
|
CredFormField::Kind,
|
||||||
|
CredFormField::Value,
|
||||||
|
];
|
||||||
|
|
||||||
|
pub fn label(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Name => "Name",
|
||||||
|
Self::Kind => "Kind",
|
||||||
|
Self::Value => "Value",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_toggle(self) -> bool {
|
||||||
|
matches!(self, Self::Kind)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_text(self) -> bool {
|
||||||
|
matches!(self, Self::Name | Self::Value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct CredFormState {
|
||||||
|
pub edit_name: Option<String>,
|
||||||
|
pub active: CredFormField,
|
||||||
|
pub cursor: usize,
|
||||||
|
pub name: String,
|
||||||
|
pub kind: AuthKind,
|
||||||
|
pub value: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CredFormState {
|
||||||
|
pub fn blank() -> Self {
|
||||||
|
Self {
|
||||||
|
edit_name: None,
|
||||||
|
active: CredFormField::Name,
|
||||||
|
cursor: 0,
|
||||||
|
name: String::new(),
|
||||||
|
kind: AuthKind::Password,
|
||||||
|
value: String::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn next_field(&mut self) {
|
||||||
|
let idx = CredFormField::ALL
|
||||||
|
.iter()
|
||||||
|
.position(|&f| f == self.active)
|
||||||
|
.unwrap_or(0);
|
||||||
|
self.active = CredFormField::ALL[(idx + 1) % CredFormField::ALL.len()];
|
||||||
|
self.cursor = char_len(self.active_text());
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn prev_field(&mut self) {
|
||||||
|
let idx = CredFormField::ALL
|
||||||
|
.iter()
|
||||||
|
.position(|&f| f == self.active)
|
||||||
|
.unwrap_or(0);
|
||||||
|
self.active =
|
||||||
|
CredFormField::ALL[(idx + CredFormField::ALL.len() - 1) % CredFormField::ALL.len()];
|
||||||
|
self.cursor = char_len(self.active_text());
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn field_value(&self, field: CredFormField) -> &str {
|
||||||
|
match field {
|
||||||
|
CredFormField::Name => &self.name,
|
||||||
|
CredFormField::Value => &self.value,
|
||||||
|
_ => "",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TextEditing for CredFormState {
|
||||||
|
fn active_text(&self) -> &str {
|
||||||
|
match self.active {
|
||||||
|
CredFormField::Name => &self.name,
|
||||||
|
CredFormField::Value => &self.value,
|
||||||
|
_ => "",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn active_text_mut(&mut self) -> Option<&mut String> {
|
||||||
|
match self.active {
|
||||||
|
CredFormField::Name => Some(&mut self.name),
|
||||||
|
CredFormField::Value => Some(&mut self.value),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cursor(&self) -> usize {
|
||||||
|
self.cursor
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_cursor(&mut self, pos: usize) {
|
||||||
|
self.cursor = pos;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
use crate::config::CredentialEntry;
|
||||||
|
use anyhow::{Result, bail};
|
||||||
|
|
||||||
|
use super::{App, AuthKind, Mode, TextEditing, char_len};
|
||||||
|
|
||||||
|
impl App {
|
||||||
|
pub fn cred_entries(&self) -> Vec<(&String, &CredentialEntry)> {
|
||||||
|
self.config.credentials.entries.iter().collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn selected_cred_name(&self) -> Option<String> {
|
||||||
|
self.cred_entries()
|
||||||
|
.get(self.session.credentials.selected)
|
||||||
|
.map(|(name, _)| (*name).clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cred_referenced_by(&self, cred_name: &str) -> Vec<&String> {
|
||||||
|
self.config
|
||||||
|
.connections
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, profile)| profile.auth_ref() == Some(cred_name))
|
||||||
|
.map(|(name, _)| name)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn enter_credentials(&mut self) {
|
||||||
|
self.session.credentials.selected = 0;
|
||||||
|
self.session.mode = Mode::Credentials;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn new_cred_form(&mut self) {
|
||||||
|
self.session.credentials.form = super::cred::CredFormState::blank();
|
||||||
|
self.session.mode = Mode::CredForm;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn edit_cred_form(&mut self) {
|
||||||
|
let Some(name) = self.selected_cred_name() else {
|
||||||
|
self.toast("no credential selected", false);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(entry) = self.config.credentials.entries.get(&name) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let mut form = super::cred::CredFormState::blank();
|
||||||
|
form.edit_name = Some(name.clone());
|
||||||
|
form.name = name;
|
||||||
|
form.kind = match entry {
|
||||||
|
CredentialEntry::Password { .. } => AuthKind::Password,
|
||||||
|
CredentialEntry::PrivateKey { .. } => AuthKind::PrivateKey,
|
||||||
|
};
|
||||||
|
form.value = entry.value().to_string();
|
||||||
|
form.cursor = char_len(form.active_text());
|
||||||
|
self.session.credentials.form = form;
|
||||||
|
self.session.mode = Mode::CredForm;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save_cred_form(&mut self) -> Result<()> {
|
||||||
|
let name = self.session.credentials.form.name.trim().to_string();
|
||||||
|
if name.is_empty() {
|
||||||
|
bail!("name is required");
|
||||||
|
}
|
||||||
|
if self.session.credentials.form.edit_name.as_deref() != Some(&name)
|
||||||
|
&& self.config.credentials.entries.contains_key(&name)
|
||||||
|
{
|
||||||
|
bail!("credential name already exists");
|
||||||
|
}
|
||||||
|
|
||||||
|
let value = self.session.credentials.form.value.clone();
|
||||||
|
let entry = match self.session.credentials.form.kind {
|
||||||
|
AuthKind::Password => CredentialEntry::password(value),
|
||||||
|
AuthKind::PrivateKey => CredentialEntry::private_key(value),
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(old) = self.session.credentials.form.edit_name.take()
|
||||||
|
&& old != name
|
||||||
|
{
|
||||||
|
for profile in self.config.connections.values_mut() {
|
||||||
|
if let Some(auth_ref) = profile.auth_ref_mut()
|
||||||
|
&& *auth_ref == old
|
||||||
|
{
|
||||||
|
*auth_ref = name.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.config.credentials.entries.shift_remove(&old);
|
||||||
|
}
|
||||||
|
|
||||||
|
self.config.credentials.entries.insert(name, entry);
|
||||||
|
self.config.save()?;
|
||||||
|
self.session.mode = Mode::Credentials;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn delete_cred(&mut self) -> Result<()> {
|
||||||
|
let Some(name) = self.selected_cred_name() else {
|
||||||
|
bail!("no credential selected");
|
||||||
|
};
|
||||||
|
let refs = self.cred_referenced_by(&name);
|
||||||
|
if !refs.is_empty() {
|
||||||
|
let list = refs
|
||||||
|
.iter()
|
||||||
|
.map(|s| s.as_str())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ");
|
||||||
|
bail!("still referenced by: {list}");
|
||||||
|
}
|
||||||
|
self.config.credentials.entries.shift_remove(&name);
|
||||||
|
self.config.save()?;
|
||||||
|
self.session.credentials.selected = self
|
||||||
|
.session
|
||||||
|
.credentials
|
||||||
|
.selected
|
||||||
|
.min(self.config.credentials.entries.len().saturating_sub(1));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
+254
@@ -0,0 +1,254 @@
|
|||||||
|
use super::{TextEditing, char_len};
|
||||||
|
use crate::app::AuthKind;
|
||||||
|
use crate::config::{ConnectionProfile, ConnectionType, CredentialEntry, SshellConfig};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum FormField {
|
||||||
|
Name,
|
||||||
|
Type,
|
||||||
|
Host,
|
||||||
|
Port,
|
||||||
|
User,
|
||||||
|
Command,
|
||||||
|
SyncArgs,
|
||||||
|
LocalArgs,
|
||||||
|
Auth,
|
||||||
|
CredId,
|
||||||
|
Secret,
|
||||||
|
Tags,
|
||||||
|
Sync,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FormField {
|
||||||
|
pub fn label(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Name => "Name",
|
||||||
|
Self::Type => "Type",
|
||||||
|
Self::Host => "Host",
|
||||||
|
Self::Port => "Port",
|
||||||
|
Self::User => "User",
|
||||||
|
Self::Command => "Command",
|
||||||
|
Self::SyncArgs => "Sync Args",
|
||||||
|
Self::LocalArgs => "Local Args",
|
||||||
|
Self::Auth => "Auth",
|
||||||
|
Self::CredId => "Cred ID",
|
||||||
|
Self::Secret => "Secret",
|
||||||
|
Self::Tags => "Tags",
|
||||||
|
Self::Sync => "Sync",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_toggle(self) -> bool {
|
||||||
|
matches!(self, Self::Type | Self::Auth | Self::Sync)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_text(self) -> bool {
|
||||||
|
matches!(
|
||||||
|
self,
|
||||||
|
Self::Name
|
||||||
|
| Self::Host
|
||||||
|
| Self::Port
|
||||||
|
| Self::User
|
||||||
|
| Self::Command
|
||||||
|
| Self::SyncArgs
|
||||||
|
| Self::LocalArgs
|
||||||
|
| Self::CredId
|
||||||
|
| Self::Secret
|
||||||
|
| Self::Tags
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct FormState {
|
||||||
|
pub edit_name: Option<String>,
|
||||||
|
pub active: FormField,
|
||||||
|
pub cursor: usize,
|
||||||
|
pub name: String,
|
||||||
|
pub is_shell: bool,
|
||||||
|
pub host: String,
|
||||||
|
pub port: String,
|
||||||
|
pub user: String,
|
||||||
|
pub auth_kind: AuthKind,
|
||||||
|
pub auth_ref: String,
|
||||||
|
pub secret: String,
|
||||||
|
pub sync: bool,
|
||||||
|
pub command: String,
|
||||||
|
pub sync_args: String,
|
||||||
|
pub local_args: String,
|
||||||
|
pub tags: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FormState {
|
||||||
|
pub fn blank() -> Self {
|
||||||
|
Self {
|
||||||
|
edit_name: None,
|
||||||
|
active: FormField::Name,
|
||||||
|
cursor: 0,
|
||||||
|
name: String::new(),
|
||||||
|
is_shell: false,
|
||||||
|
host: String::new(),
|
||||||
|
port: "22".to_string(),
|
||||||
|
user: whoami::username(),
|
||||||
|
auth_kind: AuthKind::Password,
|
||||||
|
auth_ref: String::new(),
|
||||||
|
secret: String::new(),
|
||||||
|
sync: true,
|
||||||
|
command: "bash".to_string(),
|
||||||
|
sync_args: String::new(),
|
||||||
|
local_args: String::new(),
|
||||||
|
tags: String::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_profile(name: &str, profile: &ConnectionProfile, cfg: &SshellConfig) -> Self {
|
||||||
|
let mut form = Self::blank();
|
||||||
|
form.edit_name = Some(name.to_string());
|
||||||
|
form.name = name.to_string();
|
||||||
|
form.tags = profile.tags.join(", ");
|
||||||
|
match &profile.kind {
|
||||||
|
ConnectionType::Ssh {
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
user,
|
||||||
|
auth_ref,
|
||||||
|
sync,
|
||||||
|
} => {
|
||||||
|
form.host = host.clone();
|
||||||
|
form.port = port.to_string();
|
||||||
|
form.user = user.clone();
|
||||||
|
form.auth_ref = auth_ref.clone();
|
||||||
|
form.sync = *sync;
|
||||||
|
}
|
||||||
|
ConnectionType::Shell {
|
||||||
|
shell_name: _,
|
||||||
|
auth_ref,
|
||||||
|
command,
|
||||||
|
sync_args,
|
||||||
|
local_args,
|
||||||
|
sync,
|
||||||
|
} => {
|
||||||
|
form.is_shell = true;
|
||||||
|
form.auth_ref = auth_ref.clone().unwrap_or_default();
|
||||||
|
form.command = command.clone();
|
||||||
|
form.sync_args = sync_args.join(" ");
|
||||||
|
form.local_args = local_args.join(" ");
|
||||||
|
form.sync = *sync;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(auth_ref) = profile.auth_ref()
|
||||||
|
&& let Some(credential) = cfg.credential(auth_ref)
|
||||||
|
{
|
||||||
|
form.auth_kind = match credential {
|
||||||
|
CredentialEntry::Password { .. } => AuthKind::Password,
|
||||||
|
CredentialEntry::PrivateKey { .. } => AuthKind::PrivateKey,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
form.cursor = char_len(form.active_text());
|
||||||
|
form
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn visible_fields(&self) -> Vec<FormField> {
|
||||||
|
let mut fields = vec![FormField::Name, FormField::Type];
|
||||||
|
if self.is_shell {
|
||||||
|
fields.extend_from_slice(&[
|
||||||
|
FormField::Command,
|
||||||
|
FormField::SyncArgs,
|
||||||
|
FormField::LocalArgs,
|
||||||
|
]);
|
||||||
|
fields.extend_from_slice(&[FormField::Tags, FormField::Sync]);
|
||||||
|
} else {
|
||||||
|
fields.extend_from_slice(&[FormField::Host, FormField::Port, FormField::User]);
|
||||||
|
fields.extend_from_slice(&[
|
||||||
|
FormField::Auth,
|
||||||
|
FormField::CredId,
|
||||||
|
FormField::Secret,
|
||||||
|
FormField::Tags,
|
||||||
|
FormField::Sync,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
fields
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn next_field(&mut self) {
|
||||||
|
let fields = self.visible_fields();
|
||||||
|
if let Some(idx) = fields.iter().position(|&f| f == self.active) {
|
||||||
|
self.active = fields[(idx + 1) % fields.len()];
|
||||||
|
}
|
||||||
|
self.cursor = char_len(self.active_text());
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn prev_field(&mut self) {
|
||||||
|
let fields = self.visible_fields();
|
||||||
|
if let Some(idx) = fields.iter().position(|&f| f == self.active) {
|
||||||
|
self.active = fields[(idx + fields.len() - 1) % fields.len()];
|
||||||
|
}
|
||||||
|
self.cursor = char_len(self.active_text());
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn field_value(&self, field: FormField) -> &str {
|
||||||
|
match field {
|
||||||
|
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,
|
||||||
|
_ => "",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn ensure_active_visible(&mut self) {
|
||||||
|
let fields = self.visible_fields();
|
||||||
|
if !fields.contains(&self.active) {
|
||||||
|
self.active = fields[0];
|
||||||
|
self.cursor = char_len(self.active_text());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
_ => "",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn active_text_mut(&mut self) -> Option<&mut String> {
|
||||||
|
match self.active {
|
||||||
|
FormField::Name => Some(&mut self.name),
|
||||||
|
FormField::Host => Some(&mut self.host),
|
||||||
|
FormField::Port => Some(&mut self.port),
|
||||||
|
FormField::User => Some(&mut self.user),
|
||||||
|
FormField::CredId => Some(&mut self.auth_ref),
|
||||||
|
FormField::Secret => Some(&mut self.secret),
|
||||||
|
FormField::Command => Some(&mut self.command),
|
||||||
|
FormField::SyncArgs => Some(&mut self.sync_args),
|
||||||
|
FormField::LocalArgs => Some(&mut self.local_args),
|
||||||
|
FormField::Tags => Some(&mut self.tags),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cursor(&self) -> usize {
|
||||||
|
self.cursor
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_cursor(&mut self, pos: usize) {
|
||||||
|
self.cursor = pos;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
use crate::config::{ConnectionProfile, ConnectionSource, ConnectionType, CredentialEntry};
|
||||||
|
use anyhow::{Result, bail};
|
||||||
|
|
||||||
|
use super::form::FormState;
|
||||||
|
use super::profile_ext::{non_empty, resolve_secret, shell_name_for_command, split_args};
|
||||||
|
use super::{App, AuthKind, Mode};
|
||||||
|
|
||||||
|
impl App {
|
||||||
|
pub fn new_form(&mut self) {
|
||||||
|
self.session.form = super::form::FormState::blank();
|
||||||
|
self.session.mode = Mode::Form;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn edit_form(&mut self) {
|
||||||
|
let Some(name) = self.selected_name() else {
|
||||||
|
self.toast("no connection selected", false);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(profile) = self.config.connections.get(&name) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
self.session.form = super::form::FormState::from_profile(&name, profile, &self.config);
|
||||||
|
self.session.mode = Mode::Form;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn delete_selected(&mut self) -> Result<()> {
|
||||||
|
let Some(name) = self.selected_name() else {
|
||||||
|
bail!("no connection selected");
|
||||||
|
};
|
||||||
|
let removed = self.config.connections.shift_remove(&name);
|
||||||
|
if let Some(profile) = removed {
|
||||||
|
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.save()?;
|
||||||
|
self.session.home.selected = self
|
||||||
|
.session
|
||||||
|
.home
|
||||||
|
.selected
|
||||||
|
.min(self.entries().len().saturating_sub(1));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save_form(&mut self) -> Result<()> {
|
||||||
|
let name = self.validate_form_name()?;
|
||||||
|
let auth_ref = auth_ref_for_form(&self.session.form, &name);
|
||||||
|
let old_auth_ref = self.old_form_auth_ref();
|
||||||
|
let profile = self.build_form_profile(&auth_ref)?;
|
||||||
|
|
||||||
|
self.remove_renamed_connection(&name);
|
||||||
|
self.save_form_credential(&name, &auth_ref, old_auth_ref);
|
||||||
|
|
||||||
|
self.config.connections.insert(name, profile);
|
||||||
|
self.config.save()?;
|
||||||
|
self.session.mode = Mode::Home;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_form_name(&self) -> Result<String> {
|
||||||
|
let name = self.session.form.name.trim().to_string();
|
||||||
|
if name.is_empty() {
|
||||||
|
bail!("name is required");
|
||||||
|
}
|
||||||
|
if self.session.form.edit_name.as_deref() != Some(&name)
|
||||||
|
&& self.config.connections.contains_key(&name)
|
||||||
|
{
|
||||||
|
bail!("connection name already exists");
|
||||||
|
}
|
||||||
|
Ok(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn old_form_auth_ref(&self) -> Option<String> {
|
||||||
|
self.session
|
||||||
|
.form
|
||||||
|
.edit_name
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|old| self.config.connections.get(old))
|
||||||
|
.and_then(|profile| profile.auth_ref())
|
||||||
|
.map(ToString::to_string)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_form_profile(&self, auth_ref: &str) -> Result<ConnectionProfile> {
|
||||||
|
let tags = parse_tags(&self.session.form.tags);
|
||||||
|
let local_tags = self.form_local_tags();
|
||||||
|
let (source, added_order, usage_count) = self.form_existing_metadata();
|
||||||
|
|
||||||
|
if self.session.form.is_shell {
|
||||||
|
Ok(ConnectionProfile {
|
||||||
|
tags,
|
||||||
|
local_tags,
|
||||||
|
source,
|
||||||
|
added_order,
|
||||||
|
usage_count,
|
||||||
|
kind: ConnectionType::Shell {
|
||||||
|
shell_name: shell_name_for_command(&self.session.form.command),
|
||||||
|
auth_ref: None,
|
||||||
|
command: non_empty(&self.session.form.command, "bash"),
|
||||||
|
sync_args: split_args(&self.session.form.sync_args),
|
||||||
|
local_args: split_args(&self.session.form.local_args),
|
||||||
|
sync: self.session.form.sync,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
let host = self.session.form.host.trim().to_string();
|
||||||
|
let user = self.session.form.user.trim().to_string();
|
||||||
|
if host.is_empty() || user.is_empty() {
|
||||||
|
bail!("ssh host and user are required");
|
||||||
|
}
|
||||||
|
let port = self.session.form.port.trim().parse::<u16>().unwrap_or(22);
|
||||||
|
Ok(ConnectionProfile {
|
||||||
|
tags,
|
||||||
|
local_tags,
|
||||||
|
source,
|
||||||
|
added_order,
|
||||||
|
usage_count,
|
||||||
|
kind: ConnectionType::Ssh {
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
user,
|
||||||
|
auth_ref: auth_ref.to_string(),
|
||||||
|
sync: self.session.form.sync,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn form_existing_metadata(&self) -> (ConnectionSource, u64, u64) {
|
||||||
|
self.session
|
||||||
|
.form
|
||||||
|
.edit_name
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|old| self.config.connections.get(old))
|
||||||
|
.map(|profile| (profile.source, profile.added_order, profile.usage_count))
|
||||||
|
.unwrap_or((ConnectionSource::Manual, self.config.next_added_order(), 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn form_local_tags(&self) -> Vec<String> {
|
||||||
|
self.session
|
||||||
|
.form
|
||||||
|
.edit_name
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|old| self.config.connections.get(old))
|
||||||
|
.map(|profile| profile.local_tags.clone())
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remove_renamed_connection(&mut self, name: &str) {
|
||||||
|
if let Some(old) = self.session.form.edit_name.take()
|
||||||
|
&& old != name
|
||||||
|
{
|
||||||
|
self.config.connections.shift_remove(&old);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
} else if !self.session.form.secret.is_empty() {
|
||||||
|
let secret = resolve_secret(&self.session.form.secret);
|
||||||
|
let entry = match self.session.form.auth_kind {
|
||||||
|
AuthKind::Password => CredentialEntry::password(secret),
|
||||||
|
AuthKind::PrivateKey => CredentialEntry::private_key(secret),
|
||||||
|
};
|
||||||
|
self.config
|
||||||
|
.credentials
|
||||||
|
.entries
|
||||||
|
.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> {
|
||||||
|
raw.split(',')
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.map(ToString::to_string)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn auth_ref_for_form(form: &FormState, name: &str) -> String {
|
||||||
|
if !form.auth_ref.trim().is_empty() {
|
||||||
|
return form.auth_ref.trim().to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
match form.auth_kind {
|
||||||
|
AuthKind::Password => format!("{name}-password"),
|
||||||
|
AuthKind::PrivateKey => format!("{name}-key"),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
use anyhow::Result;
|
||||||
|
|
||||||
|
use crate::config::SyncBackend;
|
||||||
|
use super::{App, Mode};
|
||||||
|
|
||||||
|
impl App {
|
||||||
|
pub fn request_quit(&mut self) {
|
||||||
|
self.session.should_quit = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn enter_quick_select(&mut self) {
|
||||||
|
if self.entries().is_empty() {
|
||||||
|
self.toast("no connections available", false);
|
||||||
|
} else {
|
||||||
|
self.session.mode = Mode::QuickSelect;
|
||||||
|
self.session.home.selected = 0;
|
||||||
|
self.toast("press 1-9 to connect, Tab to sort, Esc to cancel", true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn connect_selected(&mut self) -> Result<()> {
|
||||||
|
if let Some(name) = self.selected_name() {
|
||||||
|
self.record_use(&name)?;
|
||||||
|
crate::connection::connect(&name, &self.config)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn enter_search(&mut self) {
|
||||||
|
self.session.mode = Mode::Search;
|
||||||
|
self.session.home.search.clear();
|
||||||
|
self.session.home.selected = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn enter_delete_confirm_for_selected(&mut self) {
|
||||||
|
if self.selected_name().is_some() {
|
||||||
|
self.session.mode = Mode::DeleteConfirm;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn enter_import_selector(&mut self) -> Result<()> {
|
||||||
|
self.session.import.candidates = crate::import::load_candidates(&self.config)?;
|
||||||
|
self.session.import.selected = vec![true; self.session.import.candidates.len()];
|
||||||
|
self.session.import.cursor = 0;
|
||||||
|
self.session.mode = Mode::ImportSelector;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn push_sync_with_toast(&mut self) {
|
||||||
|
let result = match self.config.settings.backend {
|
||||||
|
SyncBackend::Gist => crate::gist::push(&mut self.config).map(|id| format!("pushed ({id})")),
|
||||||
|
SyncBackend::Webdav => crate::webdav::push(&mut self.config).map(|_| "pushed".to_string()),
|
||||||
|
SyncBackend::S3 => crate::s3::push(&mut self.config).map(|_| "pushed".to_string()),
|
||||||
|
};
|
||||||
|
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::gist::pull_with_strategy(&mut self.config, crate::gist::PullStrategy::Merge),
|
||||||
|
SyncBackend::Webdav => crate::webdav::pull_with_strategy(&mut self.config, crate::gist::PullStrategy::Merge),
|
||||||
|
SyncBackend::S3 => crate::s3::pull_with_strategy(&mut self.config, crate::gist::PullStrategy::Merge),
|
||||||
|
};
|
||||||
|
match result {
|
||||||
|
Ok(count) => self.toast(format!("pulled {count} items"), true),
|
||||||
|
Err(err) => self.toast(err.to_string(), false),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+149
@@ -0,0 +1,149 @@
|
|||||||
|
mod cred_ops;
|
||||||
|
mod form_ops;
|
||||||
|
mod home_ops;
|
||||||
|
mod profile_ext;
|
||||||
|
mod settings_ops;
|
||||||
|
mod shell_ops;
|
||||||
|
mod types;
|
||||||
|
|
||||||
|
pub mod cred;
|
||||||
|
pub mod form;
|
||||||
|
pub mod settings;
|
||||||
|
|
||||||
|
pub use cred::{CredFormField, CredFormState};
|
||||||
|
pub use form::{FormField, FormState};
|
||||||
|
pub use profile_ext::split_args;
|
||||||
|
pub use settings::{SettingsField, SettingsState};
|
||||||
|
pub use types::*;
|
||||||
|
|
||||||
|
use crate::config::{ConnectionProfile, ConnectionType, SshellConfig};
|
||||||
|
use anyhow::Result;
|
||||||
|
use std::cmp::Reverse;
|
||||||
|
|
||||||
|
pub struct App {
|
||||||
|
pub config: SshellConfig,
|
||||||
|
pub session: Session,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl App {
|
||||||
|
pub fn load() -> Result<Self> {
|
||||||
|
let config = SshellConfig::load()?;
|
||||||
|
let should_pick_shells = !config
|
||||||
|
.connections
|
||||||
|
.values()
|
||||||
|
.any(|profile| matches!(profile.kind, ConnectionType::Shell { .. }));
|
||||||
|
let mut app = Self {
|
||||||
|
config,
|
||||||
|
session: Session::new(),
|
||||||
|
};
|
||||||
|
if should_pick_shells {
|
||||||
|
app.enter_shell_import();
|
||||||
|
}
|
||||||
|
Ok(app)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn entries(&self) -> Vec<(&String, &ConnectionProfile)> {
|
||||||
|
let mut ssh_entries: Vec<_> = self
|
||||||
|
.config
|
||||||
|
.connections
|
||||||
|
.iter()
|
||||||
|
.filter(|(name, profile)| {
|
||||||
|
profile_ext::matches_search(name, profile, &self.session.home.search)
|
||||||
|
})
|
||||||
|
.filter(|(_, profile)| matches!(profile.kind, ConnectionType::Ssh { .. }))
|
||||||
|
.collect();
|
||||||
|
let shell_entries: Vec<_> = self
|
||||||
|
.config
|
||||||
|
.connections
|
||||||
|
.iter()
|
||||||
|
.filter(|(name, profile)| {
|
||||||
|
profile_ext::matches_search(name, profile, &self.session.home.search)
|
||||||
|
})
|
||||||
|
.filter(|(_, profile)| matches!(profile.kind, ConnectionType::Shell { .. }))
|
||||||
|
.collect();
|
||||||
|
ssh_entries.extend(shell_entries);
|
||||||
|
ssh_entries
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn quick_entries(&self) -> Vec<(&String, &ConnectionProfile)> {
|
||||||
|
let mut entries = self.entries();
|
||||||
|
match self.session.home.quick_sort {
|
||||||
|
QuickSortMode::Usage => entries.sort_by(|a, b| {
|
||||||
|
b.1.usage_count
|
||||||
|
.cmp(&a.1.usage_count)
|
||||||
|
.then_with(|| a.1.added_order.cmp(&b.1.added_order))
|
||||||
|
}),
|
||||||
|
QuickSortMode::Added => entries.sort_by_key(|entry| Reverse(entry.1.added_order)),
|
||||||
|
QuickSortMode::Name => entries.sort_by(|a, b| {
|
||||||
|
a.0.to_lowercase()
|
||||||
|
.cmp(&b.0.to_lowercase())
|
||||||
|
.then_with(|| a.1.added_order.cmp(&b.1.added_order))
|
||||||
|
}),
|
||||||
|
QuickSortMode::Smart => entries.sort_by(|a, b| {
|
||||||
|
profile_ext::smart_score(b.1)
|
||||||
|
.cmp(&profile_ext::smart_score(a.1))
|
||||||
|
.then_with(|| b.1.usage_count.cmp(&a.1.usage_count))
|
||||||
|
.then_with(|| a.1.added_order.cmp(&b.1.added_order))
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
entries
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn selected_name(&self) -> Option<String> {
|
||||||
|
self.entries()
|
||||||
|
.get(self.session.home.selected)
|
||||||
|
.map(|(name, _)| (*name).clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn move_selection(&mut self, delta: isize) {
|
||||||
|
let len = match self.session.mode {
|
||||||
|
Mode::ImportSelector => self.session.import.candidates.len(),
|
||||||
|
Mode::ShellImport => self.session.shell_import.candidates.len(),
|
||||||
|
Mode::Credentials => self.config.credentials.entries.len(),
|
||||||
|
_ => self.entries().len(),
|
||||||
|
};
|
||||||
|
let selected = match self.session.mode {
|
||||||
|
Mode::ImportSelector => &mut self.session.import.cursor,
|
||||||
|
Mode::ShellImport => &mut self.session.shell_import.cursor,
|
||||||
|
Mode::Credentials => &mut self.session.credentials.selected,
|
||||||
|
_ => &mut self.session.home.selected,
|
||||||
|
};
|
||||||
|
if len == 0 {
|
||||||
|
*selected = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
*selected = (*selected as isize + delta).rem_euclid(len as isize) as usize;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn jump_group(&mut self) {
|
||||||
|
let entries = self.entries();
|
||||||
|
let Some((_, current)) = entries.get(self.session.home.selected) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let target_is_shell = matches!(current.kind, ConnectionType::Ssh { .. });
|
||||||
|
if let Some(idx) = entries.iter().position(|(_, profile)| {
|
||||||
|
matches!(
|
||||||
|
(&profile.kind, target_is_shell),
|
||||||
|
(ConnectionType::Shell { .. }, true) | (ConnectionType::Ssh { .. }, false)
|
||||||
|
)
|
||||||
|
}) {
|
||||||
|
self.session.home.selected = idx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn record_use(&mut self, name: &str) -> Result<()> {
|
||||||
|
if let Some(profile) = self.config.connections.get_mut(name) {
|
||||||
|
profile.usage_count = profile.usage_count.saturating_add(1);
|
||||||
|
self.config.save()?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn toast(&mut self, message: impl Into<String>, success: bool) {
|
||||||
|
self.session.toast = Some(Toast {
|
||||||
|
message: message.into(),
|
||||||
|
success,
|
||||||
|
born: std::time::Instant::now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,303 @@
|
|||||||
|
use crate::config::{ConnectionProfile, ConnectionType};
|
||||||
|
use std::fs;
|
||||||
|
|
||||||
|
pub fn matches_search(name: &str, profile: &ConnectionProfile, query: &str) -> bool {
|
||||||
|
let query = query.trim().to_lowercase();
|
||||||
|
if query.is_empty() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if name.to_lowercase().contains(&query) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if profile
|
||||||
|
.tags
|
||||||
|
.iter()
|
||||||
|
.chain(profile.local_tags.iter())
|
||||||
|
.any(|t| t.to_lowercase().contains(&query))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
match &profile.kind {
|
||||||
|
ConnectionType::Ssh { host, user, .. } => {
|
||||||
|
host.to_lowercase().contains(&query)
|
||||||
|
|| format!("{user}@{host}").to_lowercase().contains(&query)
|
||||||
|
}
|
||||||
|
ConnectionType::Shell { command, .. } => command.to_lowercase().contains(&query),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn smart_score(profile: &ConnectionProfile) -> u64 {
|
||||||
|
profile.usage_count.saturating_mul(10_000) + profile.added_order
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ConnectionProfile {
|
||||||
|
pub fn auth_ref(&self) -> Option<&str> {
|
||||||
|
match &self.kind {
|
||||||
|
ConnectionType::Ssh { auth_ref, .. } => {
|
||||||
|
if auth_ref.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(auth_ref)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ConnectionType::Shell { auth_ref, .. } => auth_ref.as_deref(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn auth_ref_mut(&mut self) -> Option<&mut String> {
|
||||||
|
match &mut self.kind {
|
||||||
|
ConnectionType::Ssh { auth_ref, .. } => {
|
||||||
|
if auth_ref.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(auth_ref)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ConnectionType::Shell { auth_ref, .. } => auth_ref.as_mut(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn resolve_secret(secret: &str) -> String {
|
||||||
|
let path = crate::config::expand_user_path(secret);
|
||||||
|
if !looks_like_private_key(secret)
|
||||||
|
&& path.is_file()
|
||||||
|
&& let Ok(content) = fs::read_to_string(&path)
|
||||||
|
{
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
secret.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn non_empty(value: &str, fallback: &str) -> String {
|
||||||
|
let value = value.trim();
|
||||||
|
if value.is_empty() {
|
||||||
|
fallback.to_string()
|
||||||
|
} else {
|
||||||
|
value.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn shell_name_for_command(command: &str) -> String {
|
||||||
|
let command = non_empty(command, "bash");
|
||||||
|
std::path::Path::new(&command)
|
||||||
|
.file_name()
|
||||||
|
.and_then(|value| value.to_str())
|
||||||
|
.unwrap_or(command.as_str())
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn split_args(raw: &str) -> Vec<String> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
let mut current = String::new();
|
||||||
|
let mut quote = None;
|
||||||
|
let mut escaped = false;
|
||||||
|
|
||||||
|
for ch in raw.chars() {
|
||||||
|
if escaped {
|
||||||
|
current.push(ch);
|
||||||
|
escaped = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ch == '\\' {
|
||||||
|
escaped = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(q) = quote {
|
||||||
|
if ch == q {
|
||||||
|
quote = None;
|
||||||
|
} else {
|
||||||
|
current.push(ch);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match ch {
|
||||||
|
'\'' | '"' => quote = Some(ch),
|
||||||
|
ch if ch.is_whitespace() => {
|
||||||
|
if !current.is_empty() {
|
||||||
|
out.push(std::mem::take(&mut current));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => current.push(ch),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if escaped {
|
||||||
|
current.push('\\');
|
||||||
|
}
|
||||||
|
if !current.is_empty() {
|
||||||
|
out.push(current);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn looks_like_private_key(value: &str) -> bool {
|
||||||
|
value.contains("BEGIN ") && value.contains("PRIVATE KEY")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn split_args_empty() {
|
||||||
|
assert_eq!(split_args(""), Vec::<String>::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn split_args_simple() {
|
||||||
|
assert_eq!(split_args("hello world foo"), vec!["hello", "world", "foo"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn split_args_single_quotes() {
|
||||||
|
assert_eq!(
|
||||||
|
split_args("hello 'world baz' foo"),
|
||||||
|
vec!["hello", "world baz", "foo"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn split_args_double_quotes() {
|
||||||
|
assert_eq!(
|
||||||
|
split_args("hello \"world baz\" foo"),
|
||||||
|
vec!["hello", "world baz", "foo"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn split_args_escaped() {
|
||||||
|
assert_eq!(split_args(r"hello\ world foo"), vec!["hello world", "foo"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn split_args_trailing_backslash() {
|
||||||
|
assert_eq!(split_args(r"hello\"), vec!["hello\\"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn looks_like_private_key_positive() {
|
||||||
|
assert!(looks_like_private_key(
|
||||||
|
"-----BEGIN OPENSSH PRIVATE KEY-----"
|
||||||
|
));
|
||||||
|
assert!(looks_like_private_key(
|
||||||
|
"-----BEGIN RSA PRIVATE KEY-----\nsome data"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn looks_like_private_key_negative() {
|
||||||
|
assert!(!looks_like_private_key("just a password"));
|
||||||
|
assert!(!looks_like_private_key("BEGIN something"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn smart_score_basic() {
|
||||||
|
let make_profile = |usage: u64, order: u64| ConnectionProfile {
|
||||||
|
tags: vec![],
|
||||||
|
local_tags: vec![],
|
||||||
|
source: crate::config::ConnectionSource::Manual,
|
||||||
|
added_order: order,
|
||||||
|
usage_count: usage,
|
||||||
|
kind: crate::config::ConnectionType::Ssh {
|
||||||
|
host: "h".into(),
|
||||||
|
port: 22,
|
||||||
|
user: "u".into(),
|
||||||
|
auth_ref: "a".into(),
|
||||||
|
sync: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
assert_eq!(smart_score(&make_profile(0, 1)), 1);
|
||||||
|
assert_eq!(smart_score(&make_profile(3, 5)), 30_005);
|
||||||
|
assert!(smart_score(&make_profile(5, 1)) > smart_score(&make_profile(1, 5)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn matches_search_by_name() {
|
||||||
|
let profile = ConnectionProfile {
|
||||||
|
tags: vec![],
|
||||||
|
local_tags: vec![],
|
||||||
|
source: crate::config::ConnectionSource::Manual,
|
||||||
|
added_order: 1,
|
||||||
|
usage_count: 0,
|
||||||
|
kind: ConnectionType::Ssh {
|
||||||
|
host: "example.com".into(),
|
||||||
|
port: 22,
|
||||||
|
user: "admin".into(),
|
||||||
|
auth_ref: "a".into(),
|
||||||
|
sync: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
assert!(matches_search("myserver", &profile, "myserver"));
|
||||||
|
assert!(matches_search("myserver", &profile, "My"));
|
||||||
|
assert!(!matches_search("myserver", &profile, "other"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn matches_search_by_host() {
|
||||||
|
let profile = ConnectionProfile {
|
||||||
|
tags: vec![],
|
||||||
|
local_tags: vec![],
|
||||||
|
source: crate::config::ConnectionSource::Manual,
|
||||||
|
added_order: 1,
|
||||||
|
usage_count: 0,
|
||||||
|
kind: ConnectionType::Ssh {
|
||||||
|
host: "example.com".into(),
|
||||||
|
port: 22,
|
||||||
|
user: "admin".into(),
|
||||||
|
auth_ref: "a".into(),
|
||||||
|
sync: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
assert!(matches_search("x", &profile, "example"));
|
||||||
|
assert!(matches_search("x", &profile, "admin@example"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn matches_search_empty_query() {
|
||||||
|
let profile = ConnectionProfile {
|
||||||
|
tags: vec![],
|
||||||
|
local_tags: vec![],
|
||||||
|
source: crate::config::ConnectionSource::Manual,
|
||||||
|
added_order: 1,
|
||||||
|
usage_count: 0,
|
||||||
|
kind: ConnectionType::Ssh {
|
||||||
|
host: "h".into(),
|
||||||
|
port: 22,
|
||||||
|
user: "u".into(),
|
||||||
|
auth_ref: "a".into(),
|
||||||
|
sync: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
assert!(matches_search("anything", &profile, ""));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shell_name_for_command_path() {
|
||||||
|
assert_eq!(shell_name_for_command("/bin/bash"), "bash");
|
||||||
|
assert_eq!(shell_name_for_command("/usr/bin/zsh"), "zsh");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shell_name_for_command_empty() {
|
||||||
|
assert_eq!(shell_name_for_command(""), "bash");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shell_name_for_command_bare() {
|
||||||
|
assert_eq!(shell_name_for_command("bash"), "bash");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shell_name_for_command_windows_path() {
|
||||||
|
// Use forward slashes so Path::file_name() works on all platforms
|
||||||
|
assert_eq!(
|
||||||
|
shell_name_for_command("C:/Windows/System32/cmd.exe"),
|
||||||
|
"cmd.exe"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
shell_name_for_command("C:/Program Files/Git/bin/bash.exe"),
|
||||||
|
"bash.exe"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
use super::TextEditing;
|
||||||
|
use crate::config::SyncBackend;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum SettingsField {
|
||||||
|
SyncPassword,
|
||||||
|
Backend,
|
||||||
|
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];
|
||||||
|
match backend {
|
||||||
|
SyncBackend::Gist => fields.push(SettingsField::GistId),
|
||||||
|
SyncBackend::Webdav => {
|
||||||
|
fields.extend_from_slice(&[
|
||||||
|
SettingsField::WebdavUrl,
|
||||||
|
SettingsField::WebdavUser,
|
||||||
|
SettingsField::WebdavPassword,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
SyncBackend::S3 => {
|
||||||
|
fields.extend_from_slice(&[
|
||||||
|
SettingsField::S3Endpoint,
|
||||||
|
SettingsField::S3Bucket,
|
||||||
|
SettingsField::S3AccessKey,
|
||||||
|
SettingsField::S3SecretKey,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fields.push(SettingsField::SyncUsage);
|
||||||
|
fields
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn label(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::SyncPassword => "Encrypt Pwd",
|
||||||
|
Self::Backend => "Backend",
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_text(self) -> bool {
|
||||||
|
!self.is_toggle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct SettingsState {
|
||||||
|
pub password: String,
|
||||||
|
pub backend: SyncBackend,
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SettingsState {
|
||||||
|
pub fn field_text(&self, field: SettingsField) -> &str {
|
||||||
|
match field {
|
||||||
|
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,
|
||||||
|
_ => "",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn visible_fields(&self) -> Vec<SettingsField> {
|
||||||
|
SettingsField::visible_fields(self.backend)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn next_field(&mut self) {
|
||||||
|
let fields = self.visible_fields();
|
||||||
|
if let Some(idx) = fields.iter().position(|&f| f == self.active) {
|
||||||
|
self.active = fields[(idx + 1) % fields.len()];
|
||||||
|
}
|
||||||
|
self.cursor = self.active_text().chars().count();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn prev_field(&mut self) {
|
||||||
|
let fields = self.visible_fields();
|
||||||
|
if let Some(idx) = fields.iter().position(|&f| f == self.active) {
|
||||||
|
self.active = fields[(idx + fields.len() - 1) % fields.len()];
|
||||||
|
}
|
||||||
|
self.cursor = self.active_text().chars().count();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn ensure_active_visible(&mut self) {
|
||||||
|
let fields = self.visible_fields();
|
||||||
|
if !fields.contains(&self.active) {
|
||||||
|
self.active = fields[0];
|
||||||
|
self.cursor = self.active_text().chars().count();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for SettingsState {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
password: String::new(),
|
||||||
|
backend: SyncBackend::Gist,
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
_ => "",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn active_text_mut(&mut self) -> Option<&mut String> {
|
||||||
|
match self.active {
|
||||||
|
SettingsField::SyncPassword => Some(&mut self.password),
|
||||||
|
SettingsField::GistId => Some(&mut self.gist_id),
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cursor(&self) -> usize {
|
||||||
|
self.cursor
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_cursor(&mut self, pos: usize) {
|
||||||
|
self.cursor = pos;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
use anyhow::Result;
|
||||||
|
|
||||||
|
use super::settings::SettingsField;
|
||||||
|
use super::{App, Mode, TextEditing, char_len};
|
||||||
|
|
||||||
|
impl App {
|
||||||
|
pub fn enter_settings(&mut self) {
|
||||||
|
self.session.settings.password = self
|
||||||
|
.config
|
||||||
|
.settings
|
||||||
|
.sync_password
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_default();
|
||||||
|
self.session.settings.backend = self.config.settings.backend;
|
||||||
|
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();
|
||||||
|
self.session.settings.webdav_user =
|
||||||
|
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.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.save()?;
|
||||||
|
self.session.mode = Mode::Home;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
use anyhow::Result;
|
||||||
|
|
||||||
|
use super::{App, Mode};
|
||||||
|
|
||||||
|
impl App {
|
||||||
|
pub fn refresh_local_shells(&mut self) -> Result<()> {
|
||||||
|
self.enter_shell_import();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn enter_shell_import(&mut self) {
|
||||||
|
self.session.shell_import.candidates = self.config.local_shell_candidates();
|
||||||
|
self.session.shell_import.selected =
|
||||||
|
vec![false; self.session.shell_import.candidates.len()];
|
||||||
|
self.session.shell_import.cursor = 0;
|
||||||
|
self.session.mode = Mode::ShellImport;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn import_selected_shells(&mut self) -> Result<usize> {
|
||||||
|
let picked: Vec<_> = self
|
||||||
|
.session
|
||||||
|
.shell_import
|
||||||
|
.candidates
|
||||||
|
.iter()
|
||||||
|
.zip(&self.session.shell_import.selected)
|
||||||
|
.filter_map(|(item, selected)| {
|
||||||
|
(*selected && item.conflict.is_none()).then_some(item.clone())
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let mut count = 0;
|
||||||
|
for candidate in &picked {
|
||||||
|
self.config.add_local_shell(candidate)?;
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
if count > 0 {
|
||||||
|
self.config.save()?;
|
||||||
|
}
|
||||||
|
self.session.mode = Mode::Home;
|
||||||
|
Ok(count)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,318 @@
|
|||||||
|
use crate::import::ImportCandidate;
|
||||||
|
|
||||||
|
use super::cred::CredFormState;
|
||||||
|
use super::form::FormState;
|
||||||
|
use super::settings::SettingsState;
|
||||||
|
|
||||||
|
// ── Text editing trait ──────────────────────────────────────
|
||||||
|
|
||||||
|
pub trait TextEditing {
|
||||||
|
fn active_text(&self) -> &str;
|
||||||
|
fn active_text_mut(&mut self) -> Option<&mut String>;
|
||||||
|
fn cursor(&self) -> usize;
|
||||||
|
fn set_cursor(&mut self, pos: usize);
|
||||||
|
|
||||||
|
fn insert_char(&mut self, c: char) {
|
||||||
|
let pos = self.cursor().min(char_len(self.active_text()));
|
||||||
|
let byte_pos = char_to_byte_index(self.active_text(), pos);
|
||||||
|
if let Some(field) = self.active_text_mut() {
|
||||||
|
field.insert(byte_pos, c);
|
||||||
|
self.set_cursor(pos + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn delete_char(&mut self) {
|
||||||
|
let pos = self.cursor().min(char_len(self.active_text()));
|
||||||
|
if pos == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let start = char_to_byte_index(self.active_text(), pos - 1);
|
||||||
|
let end = char_to_byte_index(self.active_text(), pos);
|
||||||
|
if let Some(field) = self.active_text_mut() {
|
||||||
|
field.replace_range(start..end, "");
|
||||||
|
}
|
||||||
|
self.set_cursor(pos - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn delete_next_char(&mut self) {
|
||||||
|
let pos = self.cursor().min(char_len(self.active_text()));
|
||||||
|
if pos >= char_len(self.active_text()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let start = char_to_byte_index(self.active_text(), pos);
|
||||||
|
let end = char_to_byte_index(self.active_text(), pos + 1);
|
||||||
|
if let Some(field) = self.active_text_mut() {
|
||||||
|
field.replace_range(start..end, "");
|
||||||
|
}
|
||||||
|
self.set_cursor(pos);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn move_cursor_left(&mut self) {
|
||||||
|
let pos = self.cursor().min(char_len(self.active_text()));
|
||||||
|
if pos > 0 {
|
||||||
|
self.set_cursor(pos - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn move_cursor_right(&mut self) {
|
||||||
|
let pos = self.cursor().min(char_len(self.active_text()));
|
||||||
|
let len = char_len(self.active_text());
|
||||||
|
self.set_cursor((pos + 1).min(len));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cursor_home(&mut self) {
|
||||||
|
self.set_cursor(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cursor_end(&mut self) {
|
||||||
|
self.set_cursor(char_len(self.active_text()));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clear_field(&mut self) {
|
||||||
|
if let Some(field) = self.active_text_mut() {
|
||||||
|
field.clear();
|
||||||
|
}
|
||||||
|
self.set_cursor(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn char_len(value: &str) -> usize {
|
||||||
|
value.chars().count()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn char_to_byte_index(value: &str, char_pos: usize) -> usize {
|
||||||
|
value
|
||||||
|
.char_indices()
|
||||||
|
.nth(char_pos)
|
||||||
|
.map(|(idx, _)| idx)
|
||||||
|
.unwrap_or(value.len())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Types ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum Mode {
|
||||||
|
Home,
|
||||||
|
Search,
|
||||||
|
QuickSelect,
|
||||||
|
ShellImport,
|
||||||
|
Form,
|
||||||
|
DeleteConfirm,
|
||||||
|
ImportSelector,
|
||||||
|
Credentials,
|
||||||
|
CredForm,
|
||||||
|
Settings,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum QuickSortMode {
|
||||||
|
Smart,
|
||||||
|
Usage,
|
||||||
|
Added,
|
||||||
|
Name,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl QuickSortMode {
|
||||||
|
pub fn next(self) -> Self {
|
||||||
|
match self {
|
||||||
|
Self::Smart => Self::Usage,
|
||||||
|
Self::Usage => Self::Added,
|
||||||
|
Self::Added => Self::Name,
|
||||||
|
Self::Name => Self::Smart,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn label(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Smart => "smart",
|
||||||
|
Self::Usage => "usage",
|
||||||
|
Self::Added => "added",
|
||||||
|
Self::Name => "name",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum AuthKind {
|
||||||
|
Password,
|
||||||
|
PrivateKey,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Toast ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Toast {
|
||||||
|
pub message: String,
|
||||||
|
pub success: bool,
|
||||||
|
pub born: std::time::Instant,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Session ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Session {
|
||||||
|
pub mode: Mode,
|
||||||
|
pub home: HomeSession,
|
||||||
|
pub form: FormState,
|
||||||
|
pub credentials: CredentialSession,
|
||||||
|
pub import: ImportSession,
|
||||||
|
pub shell_import: ShellImportSession,
|
||||||
|
pub toast: Option<Toast>,
|
||||||
|
pub should_quit: bool,
|
||||||
|
pub settings: SettingsState,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct HomeSession {
|
||||||
|
pub selected: usize,
|
||||||
|
pub search: String,
|
||||||
|
pub quick_sort: QuickSortMode,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct CredentialSession {
|
||||||
|
pub selected: usize,
|
||||||
|
pub form: CredFormState,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct ImportSession {
|
||||||
|
pub cursor: usize,
|
||||||
|
pub candidates: Vec<ImportCandidate>,
|
||||||
|
pub selected: Vec<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct ShellImportSession {
|
||||||
|
pub cursor: usize,
|
||||||
|
pub candidates: Vec<crate::config::ShellCandidate>,
|
||||||
|
pub selected: Vec<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Session {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
mode: Mode::Home,
|
||||||
|
home: HomeSession::default(),
|
||||||
|
form: FormState::blank(),
|
||||||
|
credentials: CredentialSession::default(),
|
||||||
|
import: ImportSession::default(),
|
||||||
|
shell_import: ShellImportSession::default(),
|
||||||
|
toast: None,
|
||||||
|
should_quit: false,
|
||||||
|
settings: SettingsState::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Session {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for HomeSession {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
selected: 0,
|
||||||
|
search: String::new(),
|
||||||
|
quick_sort: QuickSortMode::Smart,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for CredentialSession {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
selected: 0,
|
||||||
|
form: CredFormState::blank(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct EditingState {
|
||||||
|
value: String,
|
||||||
|
cursor: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TextEditing for EditingState {
|
||||||
|
fn active_text(&self) -> &str {
|
||||||
|
&self.value
|
||||||
|
}
|
||||||
|
|
||||||
|
fn active_text_mut(&mut self) -> Option<&mut String> {
|
||||||
|
Some(&mut self.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cursor(&self) -> usize {
|
||||||
|
self.cursor
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_cursor(&mut self, pos: usize) {
|
||||||
|
self.cursor = pos;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn text_editing_inserts_at_character_cursor() {
|
||||||
|
let mut state = EditingState {
|
||||||
|
value: "你a".to_string(),
|
||||||
|
cursor: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
state.insert_char('好');
|
||||||
|
|
||||||
|
assert_eq!(state.value, "你好a");
|
||||||
|
assert_eq!(state.cursor, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn text_editing_backspace_removes_previous_character() {
|
||||||
|
let mut state = EditingState {
|
||||||
|
value: "你好a".to_string(),
|
||||||
|
cursor: 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
state.delete_char();
|
||||||
|
|
||||||
|
assert_eq!(state.value, "你a");
|
||||||
|
assert_eq!(state.cursor, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn text_editing_delete_removes_next_character() {
|
||||||
|
let mut state = EditingState {
|
||||||
|
value: "你a好".to_string(),
|
||||||
|
cursor: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
state.delete_next_char();
|
||||||
|
|
||||||
|
assert_eq!(state.value, "你好");
|
||||||
|
assert_eq!(state.cursor, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn text_editing_cursor_moves_by_character() {
|
||||||
|
let mut state = EditingState {
|
||||||
|
value: "你a好".to_string(),
|
||||||
|
cursor: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
state.move_cursor_right();
|
||||||
|
state.move_cursor_right();
|
||||||
|
assert_eq!(state.cursor, 2);
|
||||||
|
|
||||||
|
state.move_cursor_left();
|
||||||
|
assert_eq!(state.cursor, 1);
|
||||||
|
|
||||||
|
state.cursor_end();
|
||||||
|
assert_eq!(state.cursor, 3);
|
||||||
|
}
|
||||||
|
}
|
||||||
+216
@@ -0,0 +1,216 @@
|
|||||||
|
use crate::config::{ConnectionType, CredentialEntry, SshellConfig, SyncBackend, config_path, find_binary};
|
||||||
|
use crate::{connection, gist, import, s3, ui, webdav};
|
||||||
|
use anyhow::{Context, Result, bail};
|
||||||
|
use clap::{Parser, Subcommand};
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
#[derive(Debug, Parser)]
|
||||||
|
#[command(
|
||||||
|
name = "sshell",
|
||||||
|
version,
|
||||||
|
about = "Personal SSH and shell connection manager"
|
||||||
|
)]
|
||||||
|
pub struct Cli {
|
||||||
|
#[command(subcommand)]
|
||||||
|
command: Option<Command>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Subcommand)]
|
||||||
|
enum Command {
|
||||||
|
Tui,
|
||||||
|
Connect {
|
||||||
|
name: String,
|
||||||
|
},
|
||||||
|
Import,
|
||||||
|
Sync {
|
||||||
|
#[command(subcommand)]
|
||||||
|
command: SyncCommand,
|
||||||
|
},
|
||||||
|
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) {
|
||||||
|
Command::Tui => ui::app::run(),
|
||||||
|
Command::Connect { name } => {
|
||||||
|
let cfg = SshellConfig::load()?;
|
||||||
|
connection::connect(&name, &cfg)
|
||||||
|
}
|
||||||
|
Command::Import => {
|
||||||
|
let mut cfg = SshellConfig::load()?;
|
||||||
|
let candidates = import::load_candidates(&cfg)?;
|
||||||
|
let count = import::import_candidates(&mut cfg, &candidates)?;
|
||||||
|
println!("imported {count} connections");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Command::Sync { command } => run_sync(command),
|
||||||
|
Command::Doctor { name } => doctor(name),
|
||||||
|
Command::ConfigPath => {
|
||||||
|
println!("{}", config_path()?.display());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_sync(command: SyncCommand) -> Result<()> {
|
||||||
|
let mut cfg = SshellConfig::load()?;
|
||||||
|
let strat = |s: PullStrategy| match s {
|
||||||
|
PullStrategy::Merge => gist::PullStrategy::Merge,
|
||||||
|
PullStrategy::Overwrite => gist::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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn doctor(name: Option<String>) -> Result<()> {
|
||||||
|
let path = config_path()?;
|
||||||
|
println!("config: {}", path.display());
|
||||||
|
if path.exists() {
|
||||||
|
check_config_permissions(&path)?;
|
||||||
|
} else {
|
||||||
|
println!("config status: missing, will be created on first run");
|
||||||
|
}
|
||||||
|
|
||||||
|
let cfg = SshellConfig::load()?;
|
||||||
|
println!("connections: {}", cfg.connections.len());
|
||||||
|
println!("credentials: {}", cfg.credentials.entries.len());
|
||||||
|
let synced = cfg.connections.values().filter(|p| p.sync()).count();
|
||||||
|
println!("synced connections: {synced}");
|
||||||
|
println!(
|
||||||
|
"sync backend: {}",
|
||||||
|
match cfg.settings.backend {
|
||||||
|
SyncBackend::Gist => "gist",
|
||||||
|
SyncBackend::Webdav => "webdav",
|
||||||
|
SyncBackend::S3 => "s3",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
"ssh binary: {}",
|
||||||
|
find_binary("ssh").unwrap_or_else(|| "not found".to_string())
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
"sshpass binary: {}",
|
||||||
|
find_binary("sshpass").unwrap_or_else(|| "not found".to_string())
|
||||||
|
);
|
||||||
|
|
||||||
|
if let Some(name) = name {
|
||||||
|
let profile = cfg
|
||||||
|
.connections
|
||||||
|
.get(&name)
|
||||||
|
.with_context(|| format!("connection {name} not found"))?;
|
||||||
|
check_connection(&cfg, &name, profile)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check_connection(
|
||||||
|
cfg: &SshellConfig,
|
||||||
|
name: &str,
|
||||||
|
profile: &crate::config::ConnectionProfile,
|
||||||
|
) -> Result<()> {
|
||||||
|
println!("connection: {name}");
|
||||||
|
match &profile.kind {
|
||||||
|
ConnectionType::Ssh {
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
user,
|
||||||
|
auth_ref,
|
||||||
|
sync,
|
||||||
|
} => {
|
||||||
|
println!("type: ssh");
|
||||||
|
println!("target: {user}@{host}:{port}");
|
||||||
|
println!("sync: {}", if *sync { "yes" } else { "no" });
|
||||||
|
println!("credential: {auth_ref}");
|
||||||
|
let credential = cfg
|
||||||
|
.credential(auth_ref)
|
||||||
|
.with_context(|| format!("credential {auth_ref} missing"))?;
|
||||||
|
match credential {
|
||||||
|
CredentialEntry::Password { .. } => println!("auth: password"),
|
||||||
|
CredentialEntry::PrivateKey { value, .. } => {
|
||||||
|
if value.as_deref().is_some_and(|v| !v.is_empty()) {
|
||||||
|
println!(
|
||||||
|
"auth: embedded private key ({} bytes)",
|
||||||
|
value.as_deref().unwrap_or_default().len()
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
bail!("private key credential is empty");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
println!(
|
||||||
|
"ssh command: ssh -o StrictHostKeyChecking=accept-new -p {port} {user}@{host}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
ConnectionType::Shell {
|
||||||
|
command,
|
||||||
|
sync_args,
|
||||||
|
local_args,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
println!("type: shell");
|
||||||
|
let mut merged_args = sync_args.clone();
|
||||||
|
merged_args.extend(local_args.clone());
|
||||||
|
println!("command: {command} {}", merged_args.join(" "));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn check_config_permissions(path: &Path) -> Result<()> {
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
let mode = path.metadata()?.permissions().mode() & 0o777;
|
||||||
|
println!("config permissions: {mode:o}");
|
||||||
|
if mode != 0o600 {
|
||||||
|
println!("warning: config should be 600; saving from sshell will fix it");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
fn check_config_permissions(_path: &Path) -> Result<()> {
|
||||||
|
println!("config permissions: not checked on this platform");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
+496
@@ -0,0 +1,496 @@
|
|||||||
|
use anyhow::{Context, Result, bail};
|
||||||
|
use indexmap::IndexMap;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
const CONFIG_VERSION: u32 = 2;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct SshellConfig {
|
||||||
|
pub version: u32,
|
||||||
|
#[serde(default)]
|
||||||
|
pub settings: Settings,
|
||||||
|
#[serde(default)]
|
||||||
|
pub connections: IndexMap<String, ConnectionProfile>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub credentials: CredentialStore,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum ConnectionSource {
|
||||||
|
Manual,
|
||||||
|
Imported,
|
||||||
|
Scanned,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct ShellScanConflict {
|
||||||
|
pub name: String,
|
||||||
|
pub path: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ShellCandidate {
|
||||||
|
pub name: String,
|
||||||
|
pub path: PathBuf,
|
||||||
|
pub conflict: Option<ShellScanConflict>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum SyncBackend {
|
||||||
|
#[default]
|
||||||
|
Gist,
|
||||||
|
Webdav,
|
||||||
|
S3,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
pub struct Settings {
|
||||||
|
#[serde(default)]
|
||||||
|
pub backend: SyncBackend,
|
||||||
|
pub gist_id: Option<String>,
|
||||||
|
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>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ConnectionProfile {
|
||||||
|
#[serde(default)]
|
||||||
|
pub tags: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub local_tags: Vec<String>,
|
||||||
|
pub source: ConnectionSource,
|
||||||
|
pub added_order: u64,
|
||||||
|
pub usage_count: u64,
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub kind: ConnectionType,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "type", rename_all = "snake_case")]
|
||||||
|
pub enum ConnectionType {
|
||||||
|
Ssh {
|
||||||
|
host: String,
|
||||||
|
#[serde(default = "default_ssh_port")]
|
||||||
|
port: u16,
|
||||||
|
user: String,
|
||||||
|
auth_ref: String,
|
||||||
|
#[serde(default = "default_ssh_sync")]
|
||||||
|
sync: bool,
|
||||||
|
},
|
||||||
|
Shell {
|
||||||
|
shell_name: String,
|
||||||
|
#[serde(default)]
|
||||||
|
auth_ref: Option<String>,
|
||||||
|
#[serde(default = "default_shell")]
|
||||||
|
command: String,
|
||||||
|
#[serde(default)]
|
||||||
|
sync_args: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
local_args: Vec<String>,
|
||||||
|
#[serde(default = "default_shell_sync")]
|
||||||
|
sync: bool,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
pub struct CredentialStore {
|
||||||
|
#[serde(default)]
|
||||||
|
pub entries: IndexMap<String, CredentialEntry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "type", rename_all = "snake_case")]
|
||||||
|
pub enum CredentialEntry {
|
||||||
|
Password {
|
||||||
|
value: String,
|
||||||
|
},
|
||||||
|
PrivateKey {
|
||||||
|
#[serde(alias = "data", default)]
|
||||||
|
value: Option<String>,
|
||||||
|
#[serde(default, skip_serializing)]
|
||||||
|
path: Option<String>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for SshellConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
version: CONFIG_VERSION,
|
||||||
|
settings: Settings::default(),
|
||||||
|
connections: IndexMap::new(),
|
||||||
|
credentials: CredentialStore::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SshellConfig {
|
||||||
|
pub fn load() -> Result<Self> {
|
||||||
|
let path = config_path()?;
|
||||||
|
if !path.exists() {
|
||||||
|
let cfg = Self::default();
|
||||||
|
cfg.save()?;
|
||||||
|
return Ok(cfg);
|
||||||
|
}
|
||||||
|
|
||||||
|
let raw = fs::read_to_string(&path)
|
||||||
|
.with_context(|| format!("failed to read {}", path.display()))?;
|
||||||
|
let mut cfg: Self =
|
||||||
|
toml::from_str(&raw).with_context(|| format!("failed to parse {}", path.display()))?;
|
||||||
|
cfg.migrate_path_to_embedded();
|
||||||
|
Ok(cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save(&self) -> Result<()> {
|
||||||
|
let path = config_path()?;
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
fs::create_dir_all(parent)
|
||||||
|
.with_context(|| format!("failed to create {}", parent.display()))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let data = toml::to_string_pretty(self)?;
|
||||||
|
let tmp_path = path.with_extension("toml.tmp");
|
||||||
|
fs::write(&tmp_path, &data)
|
||||||
|
.with_context(|| format!("failed to write {}", tmp_path.display()))?;
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
fs::set_permissions(&tmp_path, fs::Permissions::from_mode(0o600))
|
||||||
|
.with_context(|| format!("failed to chmod {}", tmp_path.display()))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
fs::rename(&tmp_path, &path).with_context(|| {
|
||||||
|
format!(
|
||||||
|
"failed to rename {} -> {}",
|
||||||
|
tmp_path.display(),
|
||||||
|
path.display()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn credential(&self, auth_ref: &str) -> Option<&CredentialEntry> {
|
||||||
|
self.credentials.entries.get(auth_ref)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn next_added_order(&self) -> u64 {
|
||||||
|
self.connections
|
||||||
|
.values()
|
||||||
|
.map(|profile| profile.added_order)
|
||||||
|
.max()
|
||||||
|
.unwrap_or(0)
|
||||||
|
+ 1
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn local_shell_candidates(&self) -> Vec<ShellCandidate> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for path in local_shell_paths() {
|
||||||
|
let Some(base_name) = path.file_name().and_then(|value| value.to_str()) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let command = path.to_string_lossy().to_string();
|
||||||
|
if self.connections.values().any(|profile| {
|
||||||
|
matches!(&profile.kind, ConnectionType::Shell { command: existing, .. } if existing == &command)
|
||||||
|
}) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let conflict = self
|
||||||
|
.connections
|
||||||
|
.contains_key(base_name)
|
||||||
|
.then(|| ShellScanConflict {
|
||||||
|
name: base_name.to_string(),
|
||||||
|
path: path.clone(),
|
||||||
|
});
|
||||||
|
out.push(ShellCandidate {
|
||||||
|
name: base_name.to_string(),
|
||||||
|
path,
|
||||||
|
conflict,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn local_shell_command(&self, shell_name: &str) -> Option<String> {
|
||||||
|
self.connections
|
||||||
|
.values()
|
||||||
|
.find_map(|profile| {
|
||||||
|
if let ConnectionType::Shell {
|
||||||
|
shell_name: existing_shell_name,
|
||||||
|
command,
|
||||||
|
..
|
||||||
|
} = &profile.kind
|
||||||
|
&& existing_shell_name == shell_name
|
||||||
|
{
|
||||||
|
Some(command.clone())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.or_else(|| {
|
||||||
|
local_shell_paths()
|
||||||
|
.into_iter()
|
||||||
|
.find(|path| {
|
||||||
|
path.file_name().and_then(|value| value.to_str()) == Some(shell_name)
|
||||||
|
})
|
||||||
|
.map(|path| path.to_string_lossy().to_string())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add_local_shell(&mut self, candidate: &ShellCandidate) -> Result<()> {
|
||||||
|
if candidate.conflict.is_some() || self.connections.contains_key(&candidate.name) {
|
||||||
|
bail!("shell name conflict: {}", candidate.name);
|
||||||
|
}
|
||||||
|
let command = candidate.path.to_string_lossy().to_string();
|
||||||
|
if self.connections.values().any(|profile| {
|
||||||
|
matches!(&profile.kind, ConnectionType::Shell { command: existing, .. } if existing == &command)
|
||||||
|
}) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
self.connections.insert(
|
||||||
|
candidate.name.clone(),
|
||||||
|
ConnectionProfile {
|
||||||
|
tags: Vec::new(),
|
||||||
|
local_tags: vec!["local".to_string(), "scanned".to_string()],
|
||||||
|
source: ConnectionSource::Scanned,
|
||||||
|
added_order: self.next_added_order(),
|
||||||
|
usage_count: 0,
|
||||||
|
kind: ConnectionType::Shell {
|
||||||
|
shell_name: candidate.name.clone(),
|
||||||
|
auth_ref: None,
|
||||||
|
command,
|
||||||
|
sync_args: Vec::new(),
|
||||||
|
local_args: Vec::new(),
|
||||||
|
sync: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn migrate_path_to_embedded(&mut self) {
|
||||||
|
for entry in self.credentials.entries.values_mut() {
|
||||||
|
let CredentialEntry::PrivateKey { value, path, .. } = entry else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if (value.is_none() || value.as_deref().is_some_and(|v| v.is_empty()))
|
||||||
|
&& let Some(p) = path.take()
|
||||||
|
{
|
||||||
|
let expanded = expand_user_path(&p);
|
||||||
|
if let Ok(content) = fs::read_to_string(&expanded) {
|
||||||
|
*value = Some(content);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*path = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn local_shell_paths() -> Vec<PathBuf> {
|
||||||
|
let mut out: Vec<PathBuf> = Vec::new();
|
||||||
|
if let Ok(raw) = fs::read_to_string("/etc/shells") {
|
||||||
|
for line in raw.lines().map(str::trim) {
|
||||||
|
if line.is_empty() || line.starts_with('#') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let path = PathBuf::from(line);
|
||||||
|
if is_executable_file(&path)
|
||||||
|
&& !out.iter().any(|existing| same_file_name(existing, &path))
|
||||||
|
{
|
||||||
|
out.push(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if out.is_empty() {
|
||||||
|
for candidate in [
|
||||||
|
"/bin/bash",
|
||||||
|
"/bin/zsh",
|
||||||
|
"/bin/sh",
|
||||||
|
"/usr/bin/bash",
|
||||||
|
"/usr/bin/zsh",
|
||||||
|
"/usr/bin/sh",
|
||||||
|
] {
|
||||||
|
let path = PathBuf::from(candidate);
|
||||||
|
if is_executable_file(&path)
|
||||||
|
&& !out.iter().any(|existing| same_file_name(existing, &path))
|
||||||
|
{
|
||||||
|
out.push(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
fn local_shell_paths() -> Vec<PathBuf> {
|
||||||
|
let mut out: Vec<PathBuf> = Vec::new();
|
||||||
|
|
||||||
|
for name in &["pwsh", "powershell", "cmd", "bash"] {
|
||||||
|
if let Some(found) = find_binary(name) {
|
||||||
|
let path = PathBuf::from(&found);
|
||||||
|
if !out.iter().any(|existing| same_file_name(existing, &path)) {
|
||||||
|
out.push(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let system_root = std::env::var_os("SystemRoot").unwrap_or_else(|| r"C:\Windows".into());
|
||||||
|
for path in [
|
||||||
|
PathBuf::from(&system_root).join("System32").join("WindowsPowerShell").join("v1.0").join("powershell.exe"),
|
||||||
|
PathBuf::from(&system_root).join("System32").join("cmd.exe"),
|
||||||
|
] {
|
||||||
|
if path.is_file() && !out.iter().any(|existing| same_file_name(existing, &path)) {
|
||||||
|
out.push(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for path in [
|
||||||
|
PathBuf::from(r"C:\Program Files\Git\bin\bash.exe"),
|
||||||
|
PathBuf::from(r"C:\Program Files (x86)\Git\bin\bash.exe"),
|
||||||
|
] {
|
||||||
|
if path.is_file() && !out.iter().any(|existing| same_file_name(existing, &path)) {
|
||||||
|
out.push(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn same_file_name(a: &Path, b: &Path) -> bool {
|
||||||
|
a.file_name() == b.file_name()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_executable_file(path: &Path) -> bool {
|
||||||
|
path.is_file() && is_executable(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn is_executable(path: &Path) -> bool {
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
path.metadata()
|
||||||
|
.map(|metadata| metadata.permissions().mode() & 0o111 != 0)
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
fn is_executable(path: &Path) -> bool {
|
||||||
|
let exts = [
|
||||||
|
std::ffi::OsStr::new("exe"),
|
||||||
|
std::ffi::OsStr::new("cmd"),
|
||||||
|
std::ffi::OsStr::new("bat"),
|
||||||
|
std::ffi::OsStr::new("ps1"),
|
||||||
|
];
|
||||||
|
path.extension().is_some_and(|ext| exts.contains(&ext))
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CredentialEntry {
|
||||||
|
pub fn password(value: String) -> Self {
|
||||||
|
Self::Password { value }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn private_key(value: String) -> Self {
|
||||||
|
Self::PrivateKey {
|
||||||
|
value: Some(value),
|
||||||
|
path: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn value(&self) -> &str {
|
||||||
|
match self {
|
||||||
|
Self::Password { value } => value,
|
||||||
|
Self::PrivateKey { value, .. } => value.as_deref().unwrap_or(""),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn has_value(&self) -> bool {
|
||||||
|
match self {
|
||||||
|
Self::Password { value } => !value.is_empty(),
|
||||||
|
Self::PrivateKey { value, .. } => value.as_deref().is_some_and(|v| !v.is_empty()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ConnectionProfile {
|
||||||
|
pub fn sync(&self) -> bool {
|
||||||
|
match &self.kind {
|
||||||
|
ConnectionType::Ssh { sync, .. } => *sync,
|
||||||
|
ConnectionType::Shell { sync, .. } => *sync,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn expand_user_path(value: &str) -> PathBuf {
|
||||||
|
if let Some(rest) = value.strip_prefix("~/")
|
||||||
|
&& let Some(home) = dirs::home_dir()
|
||||||
|
{
|
||||||
|
return home.join(rest);
|
||||||
|
}
|
||||||
|
PathBuf::from(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn config_path() -> Result<PathBuf> {
|
||||||
|
let dir = dirs::config_dir().context("could not find user config directory")?;
|
||||||
|
Ok(dir.join("sshell").join("config.toml"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn find_binary(name: &str) -> Option<String> {
|
||||||
|
let path = std::env::var_os("PATH")?;
|
||||||
|
let candidates = binary_candidates(name);
|
||||||
|
std::env::split_paths(&path)
|
||||||
|
.flat_map(|dir| candidates.iter().map(move |c| dir.join(c)))
|
||||||
|
.find(|p| p.is_file())
|
||||||
|
.map(|p| p.display().to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn binary_candidates(name: &str) -> Vec<String> {
|
||||||
|
vec![name.to_string()]
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
fn binary_candidates(name: &str) -> Vec<String> {
|
||||||
|
let mut out = vec![name.to_string()];
|
||||||
|
if !name.contains('.') {
|
||||||
|
if let Ok(ext) = std::env::var("PATHEXT") {
|
||||||
|
for ext in ext.split(';') {
|
||||||
|
out.push(format!("{name}{ext}"));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for ext in &[".exe", ".cmd", ".bat"] {
|
||||||
|
out.push(format!("{name}{ext}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_ssh_port() -> u16 {
|
||||||
|
22
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_shell() -> String {
|
||||||
|
"bash".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_shell_sync() -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_ssh_sync() -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
use crate::config::{ConnectionType, CredentialEntry, SshellConfig};
|
||||||
|
use anyhow::{Context, Result, bail};
|
||||||
|
use std::io::Write;
|
||||||
|
use std::process::Command;
|
||||||
|
use tempfile::NamedTempFile;
|
||||||
|
|
||||||
|
pub fn connect(name: &str, cfg: &SshellConfig) -> Result<()> {
|
||||||
|
let profile = cfg
|
||||||
|
.connections
|
||||||
|
.get(name)
|
||||||
|
.with_context(|| format!("connection {name} not found"))?;
|
||||||
|
crate::ui::restore_terminal()?;
|
||||||
|
match &profile.kind {
|
||||||
|
ConnectionType::Ssh {
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
user,
|
||||||
|
auth_ref,
|
||||||
|
..
|
||||||
|
} => 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());
|
||||||
|
exec_shell(command, &merged_args)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn connect_ssh(
|
||||||
|
cfg: &SshellConfig,
|
||||||
|
host: &str,
|
||||||
|
port: u16,
|
||||||
|
user: &str,
|
||||||
|
auth_ref: &str,
|
||||||
|
) -> Result<()> {
|
||||||
|
if auth_ref.is_empty() {
|
||||||
|
bail!("this connection has no credential; edit it and set password or private key");
|
||||||
|
}
|
||||||
|
match cfg.credential(auth_ref) {
|
||||||
|
Some(CredentialEntry::Password { value, .. }) => {
|
||||||
|
let args = vec![
|
||||||
|
"ssh".to_string(),
|
||||||
|
"-o".to_string(),
|
||||||
|
"StrictHostKeyChecking=accept-new".to_string(),
|
||||||
|
"-p".to_string(),
|
||||||
|
port.to_string(),
|
||||||
|
format!("{user}@{host}"),
|
||||||
|
];
|
||||||
|
run_sshpass(&args, value)
|
||||||
|
}
|
||||||
|
Some(CredentialEntry::PrivateKey { value, .. }) => {
|
||||||
|
let key_content = match value {
|
||||||
|
Some(v) if !v.is_empty() => v,
|
||||||
|
_ => bail!("private key credential {auth_ref} is empty"),
|
||||||
|
};
|
||||||
|
let mut key = NamedTempFile::new()?;
|
||||||
|
key.write_all(key_content.as_bytes())?;
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
key.as_file()
|
||||||
|
.set_permissions(std::fs::Permissions::from_mode(0o600))?;
|
||||||
|
}
|
||||||
|
let status = Command::new("ssh")
|
||||||
|
.arg("-o")
|
||||||
|
.arg("StrictHostKeyChecking=accept-new")
|
||||||
|
.arg("-i")
|
||||||
|
.arg(key.path())
|
||||||
|
.arg("-p")
|
||||||
|
.arg(port.to_string())
|
||||||
|
.arg(format!("{user}@{host}"))
|
||||||
|
.status()?;
|
||||||
|
std::process::exit(status.code().unwrap_or(1));
|
||||||
|
}
|
||||||
|
None => bail!("credential {auth_ref} not found"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_sshpass(args: &[String], password: &str) -> Result<()> {
|
||||||
|
let status = Command::new("sshpass")
|
||||||
|
.arg("-e")
|
||||||
|
.args(args)
|
||||||
|
.env("SSHPASS", password)
|
||||||
|
.status()
|
||||||
|
.context("failed to run sshpass — is it installed?")?;
|
||||||
|
std::process::exit(status.code().unwrap_or(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn exec_shell(command: &str, args: &[String]) -> Result<()> {
|
||||||
|
use std::os::unix::process::CommandExt;
|
||||||
|
let err = Command::new(command).args(args).exec();
|
||||||
|
Err(err).with_context(|| format!("failed to exec {command}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
fn exec_shell(command: &str, args: &[String]) -> Result<()> {
|
||||||
|
let status = Command::new(command)
|
||||||
|
.args(args)
|
||||||
|
.status()
|
||||||
|
.with_context(|| format!("failed to run {command}"))?;
|
||||||
|
std::process::exit(status.code().unwrap_or(1));
|
||||||
|
}
|
||||||
+283
@@ -0,0 +1,283 @@
|
|||||||
|
use crate::config::ConnectionType;
|
||||||
|
use crate::config::{ConnectionSource, CredentialEntry, CredentialStore, SshellConfig};
|
||||||
|
use anyhow::{Context, Result, bail};
|
||||||
|
use reqwest::blocking::Client;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
mod crypto;
|
||||||
|
|
||||||
|
const FILE_NAME: &str = "sshell-config.toml";
|
||||||
|
pub(crate) const GIST_TOKEN_REF: &str = "__gist_token";
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub enum PullStrategy {
|
||||||
|
Merge,
|
||||||
|
Overwrite,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn push(cfg: &mut SshellConfig) -> Result<String> {
|
||||||
|
let token = gist_token(cfg)?;
|
||||||
|
|
||||||
|
let payload = build_sync_payload(cfg, cfg.settings.sync_password.as_deref())?;
|
||||||
|
let content = toml::to_string_pretty(&payload)?;
|
||||||
|
let body = json!({
|
||||||
|
"description": "sshell sync",
|
||||||
|
"public": false,
|
||||||
|
"files": { FILE_NAME: { "content": content } }
|
||||||
|
});
|
||||||
|
|
||||||
|
let client = Client::new();
|
||||||
|
let response = if let Some(id) = &cfg.settings.gist_id {
|
||||||
|
client
|
||||||
|
.patch(format!("https://api.github.com/gists/{id}"))
|
||||||
|
.bearer_auth(&token)
|
||||||
|
.header("User-Agent", "sshell")
|
||||||
|
.json(&body)
|
||||||
|
.send()?
|
||||||
|
} else {
|
||||||
|
client
|
||||||
|
.post("https://api.github.com/gists")
|
||||||
|
.bearer_auth(&token)
|
||||||
|
.header("User-Agent", "sshell")
|
||||||
|
.json(&body)
|
||||||
|
.send()?
|
||||||
|
};
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
bail!("sync push failed: {}", response.status());
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn gist_token(cfg: &SshellConfig) -> Result<String> {
|
||||||
|
if let Ok(token) = std::env::var("GITHUB_TOKEN")
|
||||||
|
&& !token.trim().is_empty()
|
||||||
|
{
|
||||||
|
return Ok(token);
|
||||||
|
}
|
||||||
|
match cfg.credentials.entries.get(GIST_TOKEN_REF) {
|
||||||
|
Some(CredentialEntry::Password { value, .. }) if !value.trim().is_empty() => {
|
||||||
|
Ok(value.clone())
|
||||||
|
}
|
||||||
|
_ => bail!("set GITHUB_TOKEN or create password credential __gist_token"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn localize_shell_profile(
|
||||||
|
cfg: &SshellConfig,
|
||||||
|
name: &str,
|
||||||
|
profile: &mut crate::config::ConnectionProfile,
|
||||||
|
) -> bool {
|
||||||
|
let ConnectionType::Shell {
|
||||||
|
shell_name,
|
||||||
|
command,
|
||||||
|
local_args,
|
||||||
|
auth_ref,
|
||||||
|
..
|
||||||
|
} = &mut profile.kind
|
||||||
|
else {
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
local_args.clear();
|
||||||
|
*auth_ref = None;
|
||||||
|
|
||||||
|
let Some(local_command) = cfg.local_shell_command(shell_name) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
*command = local_command;
|
||||||
|
|
||||||
|
if let Some(local_profile) = cfg.connections.get(name) {
|
||||||
|
profile.local_tags = local_profile.local_tags.clone();
|
||||||
|
profile.added_order = local_profile.added_order;
|
||||||
|
profile.usage_count = local_profile.usage_count;
|
||||||
|
if let ConnectionType::Shell {
|
||||||
|
local_args: existing_local_args,
|
||||||
|
..
|
||||||
|
} = &local_profile.kind
|
||||||
|
{
|
||||||
|
*local_args = existing_local_args.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn build_sync_payload(cfg: &SshellConfig, 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;
|
||||||
|
|
||||||
|
// Collect auth_refs from synced connections
|
||||||
|
let synced_refs: Vec<String> = payload
|
||||||
|
.connections
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, profile)| profile.sync())
|
||||||
|
.filter_map(|(_, profile)| profile.auth_ref().map(String::from))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
payload.credentials.entries.retain(|name, _| {
|
||||||
|
name != GIST_TOKEN_REF && synced_refs.iter().any(|r| r == name)
|
||||||
|
});
|
||||||
|
|
||||||
|
let encrypted = if !payload.credentials.entries.is_empty() {
|
||||||
|
if let Some(pw) = sync_password {
|
||||||
|
Some(crypto::encrypt_credentials(&payload.credentials, pw)?)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut table = toml::map::Map::new();
|
||||||
|
table.insert(
|
||||||
|
"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;
|
||||||
|
}
|
||||||
|
match &mut profile.kind {
|
||||||
|
ConnectionType::Shell {
|
||||||
|
auth_ref,
|
||||||
|
command,
|
||||||
|
shell_name: _,
|
||||||
|
local_args,
|
||||||
|
sync,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
if !*sync {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
local_args.clear();
|
||||||
|
*command = String::new();
|
||||||
|
*auth_ref = None;
|
||||||
|
}
|
||||||
|
ConnectionType::Ssh { sync, .. } => {
|
||||||
|
if !*sync {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
profile.source = ConnectionSource::Manual;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
conns.insert(name.clone(), to_toml_value(&*profile)?);
|
||||||
|
}
|
||||||
|
table.insert("connections".to_string(), toml::Value::Table(conns));
|
||||||
|
|
||||||
|
if let Some(enc) = encrypted {
|
||||||
|
table.insert(
|
||||||
|
"credentials_encrypted".to_string(),
|
||||||
|
toml::Value::String(enc),
|
||||||
|
);
|
||||||
|
} else if !payload.credentials.entries.is_empty() {
|
||||||
|
table.insert("credentials".to_string(), to_toml_value(&payload.credentials)?);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(toml::Value::Table(table))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn merge_remote(
|
||||||
|
cfg: &mut SshellConfig,
|
||||||
|
remote: toml::Value,
|
||||||
|
strategy: PullStrategy,
|
||||||
|
) -> Result<usize> {
|
||||||
|
let mut count = 0;
|
||||||
|
|
||||||
|
// Merge connections
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge credentials: try encrypted first, then plaintext fallback
|
||||||
|
let remote_credentials =
|
||||||
|
if let Some(enc) = remote.get("credentials_encrypted").and_then(|v| v.as_str()) {
|
||||||
|
let sync_password = cfg
|
||||||
|
.settings
|
||||||
|
.sync_password
|
||||||
|
.as_deref()
|
||||||
|
.context("sync_password not set; needed to decrypt remote credentials")?;
|
||||||
|
Some(crypto::decrypt_credentials(enc, sync_password)?)
|
||||||
|
} else if let Some(creds_val) = remote.get("credentials") {
|
||||||
|
creds_val.clone().try_into::<CredentialStore>().ok()
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(remote_creds) = remote_credentials {
|
||||||
|
for (name, credential) in remote_creds.entries {
|
||||||
|
if name == GIST_TOKEN_REF {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let should_insert = match strategy {
|
||||||
|
PullStrategy::Merge => !cfg.credentials.entries.contains_key(&name),
|
||||||
|
PullStrategy::Overwrite => true,
|
||||||
|
};
|
||||||
|
if should_insert {
|
||||||
|
cfg.credentials.entries.insert(name, credential);
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg.save()?;
|
||||||
|
Ok(count)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn to_toml_value<T: serde::Serialize>(val: &T) -> Result<toml::Value> {
|
||||||
|
toml::Value::try_from(val).map_err(|e| anyhow::anyhow!("toml conversion failed: {e}"))
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
use crate::config::CredentialStore;
|
||||||
|
use aes_gcm::aead::{Aead, AeadCore, KeyInit, OsRng, rand_core::RngCore};
|
||||||
|
use aes_gcm::{Aes256Gcm, Nonce};
|
||||||
|
use anyhow::{Context, Result, bail};
|
||||||
|
use argon2::{Algorithm, Argon2, Params, Version};
|
||||||
|
use base64::{Engine as _, engine::general_purpose::STANDARD};
|
||||||
|
|
||||||
|
const SALT_LEN: usize = 16;
|
||||||
|
const NONCE_LEN: usize = 12;
|
||||||
|
|
||||||
|
pub(super) fn encrypt_credentials(store: &CredentialStore, password: &str) -> Result<String> {
|
||||||
|
let json = serde_json::to_string(store).context("failed to serialize credentials")?;
|
||||||
|
|
||||||
|
let mut salt = [0u8; SALT_LEN];
|
||||||
|
OsRng.fill_bytes(&mut salt);
|
||||||
|
let key = derive_key(password, &salt)?;
|
||||||
|
let cipher =
|
||||||
|
Aes256Gcm::new_from_slice(&key).map_err(|e| anyhow::anyhow!("aes init failed: {e}"))?;
|
||||||
|
let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
|
||||||
|
let ciphertext = cipher
|
||||||
|
.encrypt(&nonce, json.as_bytes())
|
||||||
|
.map_err(|e| anyhow::anyhow!("encryption failed: {e}"))?;
|
||||||
|
|
||||||
|
let mut blob = Vec::with_capacity(SALT_LEN + NONCE_LEN + ciphertext.len());
|
||||||
|
blob.extend_from_slice(&salt);
|
||||||
|
blob.extend_from_slice(&nonce);
|
||||||
|
blob.extend_from_slice(&ciphertext);
|
||||||
|
|
||||||
|
Ok(STANDARD.encode(&blob))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn decrypt_credentials(encoded: &str, password: &str) -> Result<CredentialStore> {
|
||||||
|
let blob = STANDARD
|
||||||
|
.decode(encoded)
|
||||||
|
.context("failed to decode encrypted credentials")?;
|
||||||
|
if blob.len() < SALT_LEN + NONCE_LEN {
|
||||||
|
bail!("encrypted credentials blob too short");
|
||||||
|
}
|
||||||
|
|
||||||
|
let salt = &blob[..SALT_LEN];
|
||||||
|
let nonce_bytes = &blob[SALT_LEN..SALT_LEN + NONCE_LEN];
|
||||||
|
let ciphertext = &blob[SALT_LEN + NONCE_LEN..];
|
||||||
|
|
||||||
|
let key = derive_key(password, salt)?;
|
||||||
|
let cipher =
|
||||||
|
Aes256Gcm::new_from_slice(&key).map_err(|e| anyhow::anyhow!("aes init failed: {e}"))?;
|
||||||
|
let nonce = Nonce::from_slice(nonce_bytes);
|
||||||
|
let plaintext = cipher
|
||||||
|
.decrypt(nonce, ciphertext)
|
||||||
|
.map_err(|_| anyhow::anyhow!("decryption failed; wrong sync_password?"))?;
|
||||||
|
|
||||||
|
serde_json::from_slice(&plaintext).context("failed to parse decrypted credentials")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn derive_key(password: &str, salt: &[u8]) -> Result<[u8; 32]> {
|
||||||
|
let params = Params::new(64 * 1024, 3, 4, Some(32))
|
||||||
|
.map_err(|e| anyhow::anyhow!("argon2 params: {e}"))?;
|
||||||
|
let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
|
||||||
|
let mut key = [0u8; 32];
|
||||||
|
argon2
|
||||||
|
.hash_password_into(password.as_bytes(), salt, &mut key)
|
||||||
|
.map_err(|e| anyhow::anyhow!("key derivation: {e}"))?;
|
||||||
|
Ok(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::config::CredentialEntry;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn credentials_encrypt_decrypt_round_trip() {
|
||||||
|
let mut store = CredentialStore::default();
|
||||||
|
store.entries.insert(
|
||||||
|
"main".to_string(),
|
||||||
|
CredentialEntry::password("secret".to_string()),
|
||||||
|
);
|
||||||
|
store.entries.insert(
|
||||||
|
"key".to_string(),
|
||||||
|
CredentialEntry::private_key("private-key".to_string()),
|
||||||
|
);
|
||||||
|
|
||||||
|
let encrypted = encrypt_credentials(&store, "sync-password").unwrap();
|
||||||
|
let decrypted = decrypt_credentials(&encrypted, "sync-password").unwrap();
|
||||||
|
|
||||||
|
assert_eq!(decrypted.entries.len(), 2);
|
||||||
|
assert_eq!(
|
||||||
|
decrypted.entries.get("main").map(CredentialEntry::value),
|
||||||
|
Some("secret")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
decrypted.entries.get("key").map(CredentialEntry::value),
|
||||||
|
Some("private-key")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn credentials_decrypt_rejects_wrong_password() {
|
||||||
|
let mut store = CredentialStore::default();
|
||||||
|
store.entries.insert(
|
||||||
|
"main".to_string(),
|
||||||
|
CredentialEntry::password("secret".to_string()),
|
||||||
|
);
|
||||||
|
|
||||||
|
let encrypted = encrypt_credentials(&store, "sync-password").unwrap();
|
||||||
|
|
||||||
|
assert!(decrypt_credentials(&encrypted, "wrong-password").is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
+158
@@ -0,0 +1,158 @@
|
|||||||
|
use crate::config::{
|
||||||
|
ConnectionProfile, ConnectionSource, ConnectionType, CredentialEntry, SshellConfig,
|
||||||
|
};
|
||||||
|
use anyhow::{Context, Result, bail};
|
||||||
|
use std::fs;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ImportCandidate {
|
||||||
|
pub name: String,
|
||||||
|
pub host: String,
|
||||||
|
pub port: u16,
|
||||||
|
pub user: String,
|
||||||
|
pub identity_file: Option<PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_candidates(cfg: &SshellConfig) -> Result<Vec<ImportCandidate>> {
|
||||||
|
let path = dirs::home_dir()
|
||||||
|
.context("could not find home directory")?
|
||||||
|
.join(".ssh")
|
||||||
|
.join("config");
|
||||||
|
if !path.exists() {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
let raw =
|
||||||
|
fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?;
|
||||||
|
let mut out = Vec::new();
|
||||||
|
let mut current: Option<ImportCandidate> = None;
|
||||||
|
|
||||||
|
for line in raw.lines() {
|
||||||
|
let line = line.trim();
|
||||||
|
if line.is_empty() || line.starts_with('#') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mut parts = line.splitn(2, char::is_whitespace);
|
||||||
|
let key = parts.next().unwrap_or("").to_ascii_lowercase();
|
||||||
|
let value = parts.next().unwrap_or("").trim();
|
||||||
|
if key == "host" {
|
||||||
|
push_candidate(&mut out, current.take(), cfg);
|
||||||
|
let name = value.split_whitespace().next().unwrap_or("").to_string();
|
||||||
|
if name.is_empty() || name.contains('*') || name.contains('?') {
|
||||||
|
current = None;
|
||||||
|
} else {
|
||||||
|
current = Some(ImportCandidate {
|
||||||
|
host: name.clone(),
|
||||||
|
name,
|
||||||
|
port: 22,
|
||||||
|
user: whoami::username(),
|
||||||
|
identity_file: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some(candidate) = current.as_mut() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
match key.as_str() {
|
||||||
|
"hostname" => candidate.host = value.to_string(),
|
||||||
|
"port" => candidate.port = value.parse().unwrap_or(22),
|
||||||
|
"user" => candidate.user = value.to_string(),
|
||||||
|
"identityfile" => candidate.identity_file = Some(expand_ssh_path(value)),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
push_candidate(&mut out, current.take(), cfg);
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn import_candidates(cfg: &mut SshellConfig, candidates: &[ImportCandidate]) -> Result<usize> {
|
||||||
|
let mut count = 0;
|
||||||
|
for item in candidates {
|
||||||
|
if cfg.connections.contains_key(&item.name) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let auth_ref = imported_auth_ref(item);
|
||||||
|
let mut tags = vec!["imported".to_string()];
|
||||||
|
|
||||||
|
if let Some(path) = &item.identity_file {
|
||||||
|
let key_content = fs::read_to_string(path).ok();
|
||||||
|
if let Some(existing) = cfg.credentials.entries.get(&auth_ref) {
|
||||||
|
if let Some(content) = key_content.as_deref()
|
||||||
|
&& existing.value() != content
|
||||||
|
{
|
||||||
|
bail!("credential {auth_ref} already exists with different content");
|
||||||
|
}
|
||||||
|
tags.push("key-reused".to_string());
|
||||||
|
} else {
|
||||||
|
match key_content {
|
||||||
|
Some(content) => {
|
||||||
|
cfg.credentials.entries.insert(
|
||||||
|
auth_ref.clone(),
|
||||||
|
CredentialEntry::private_key(content),
|
||||||
|
);
|
||||||
|
tags.push("key".to_string());
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
cfg.credentials.entries.insert(
|
||||||
|
auth_ref.clone(),
|
||||||
|
CredentialEntry::PrivateKey {
|
||||||
|
value: None,
|
||||||
|
path: None,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
tags.push("key-missing".to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg.connections.insert(
|
||||||
|
item.name.clone(),
|
||||||
|
ConnectionProfile {
|
||||||
|
tags,
|
||||||
|
local_tags: Vec::new(),
|
||||||
|
source: ConnectionSource::Imported,
|
||||||
|
added_order: cfg.next_added_order(),
|
||||||
|
usage_count: 0,
|
||||||
|
kind: ConnectionType::Ssh {
|
||||||
|
host: item.host.clone(),
|
||||||
|
port: item.port,
|
||||||
|
user: item.user.clone(),
|
||||||
|
auth_ref,
|
||||||
|
sync: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
cfg.save()?;
|
||||||
|
Ok(count)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push_candidate(
|
||||||
|
out: &mut Vec<ImportCandidate>,
|
||||||
|
candidate: Option<ImportCandidate>,
|
||||||
|
cfg: &SshellConfig,
|
||||||
|
) {
|
||||||
|
if let Some(candidate) = candidate
|
||||||
|
&& !cfg.connections.contains_key(&candidate.name)
|
||||||
|
{
|
||||||
|
out.push(candidate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn expand_ssh_path(value: &str) -> PathBuf {
|
||||||
|
crate::config::expand_user_path(value.trim_matches('"'))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn imported_auth_ref(item: &ImportCandidate) -> String {
|
||||||
|
item.identity_file
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|path| path.file_name())
|
||||||
|
.and_then(|name| name.to_str())
|
||||||
|
.filter(|name| !name.trim().is_empty())
|
||||||
|
.map(ToString::to_string)
|
||||||
|
.unwrap_or_else(|| format!("{}-auth", item.name))
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
pub mod app;
|
||||||
|
pub mod cli;
|
||||||
|
pub mod config;
|
||||||
|
pub mod connection;
|
||||||
|
pub mod gist;
|
||||||
|
pub mod import;
|
||||||
|
pub mod s3;
|
||||||
|
pub mod ui;
|
||||||
|
pub mod webdav;
|
||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
use anyhow::Result;
|
||||||
|
|
||||||
|
fn main() -> Result<()> {
|
||||||
|
let original_hook = std::panic::take_hook();
|
||||||
|
std::panic::set_hook(Box::new(move |panic| {
|
||||||
|
let _ = sshell::ui::restore_terminal();
|
||||||
|
original_hook(panic);
|
||||||
|
}));
|
||||||
|
|
||||||
|
sshell::cli::run()
|
||||||
|
}
|
||||||
@@ -0,0 +1,313 @@
|
|||||||
|
use crate::config::SshellConfig;
|
||||||
|
use crate::gist::{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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
use crate::app::{App, Mode};
|
||||||
|
use anyhow::Result;
|
||||||
|
use crossterm::{
|
||||||
|
cursor::{Hide, Show},
|
||||||
|
event::{self, Event, KeyCode, KeyEvent, KeyModifiers},
|
||||||
|
execute,
|
||||||
|
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
|
||||||
|
};
|
||||||
|
use ratatui::{Terminal, backend::CrosstermBackend};
|
||||||
|
use std::io;
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use super::view::{
|
||||||
|
CredFormView, CredListView, DeleteConfirmView, FormView, HomeListView, ImportView,
|
||||||
|
QuickSelectView, SearchView, SettingsView, ShellImportView, View,
|
||||||
|
};
|
||||||
|
|
||||||
|
static TERMINAL_RESTORED: AtomicBool = AtomicBool::new(false);
|
||||||
|
|
||||||
|
pub fn run() -> Result<()> {
|
||||||
|
let _guard = TuiGuard::init()?;
|
||||||
|
let backend = CrosstermBackend::new(io::stdout());
|
||||||
|
let mut terminal = Terminal::new(backend)?;
|
||||||
|
let mut app = App::load()?;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
terminal.draw(|frame| super::draw(frame, &mut app))?;
|
||||||
|
if app.session.should_quit {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if event::poll(Duration::from_millis(200))?
|
||||||
|
&& let Event::Key(key) = event::read()?
|
||||||
|
{
|
||||||
|
handle_key(&mut app, key)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TuiGuard;
|
||||||
|
|
||||||
|
impl TuiGuard {
|
||||||
|
fn init() -> Result<Self> {
|
||||||
|
enable_raw_mode()?;
|
||||||
|
execute!(io::stdout(), EnterAlternateScreen, Hide)?;
|
||||||
|
Ok(Self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for TuiGuard {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let _ = restore_terminal();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn restore_terminal() -> Result<()> {
|
||||||
|
if TERMINAL_RESTORED.swap(true, Ordering::SeqCst) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let _ = disable_raw_mode();
|
||||||
|
execute!(io::stdout(), Show, LeaveAlternateScreen)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_key(app: &mut App, key: KeyEvent) -> Result<()> {
|
||||||
|
if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
|
||||||
|
app.session.should_quit = true;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
match app.session.mode {
|
||||||
|
Mode::Home => HomeListView.handle_key(app, key),
|
||||||
|
Mode::Search => SearchView.handle_key(app, key),
|
||||||
|
Mode::QuickSelect => QuickSelectView.handle_key(app, key),
|
||||||
|
Mode::DeleteConfirm => DeleteConfirmView.handle_key(app, key),
|
||||||
|
Mode::Form => FormView.handle_key(app, key),
|
||||||
|
Mode::ImportSelector => ImportView.handle_key(app, key),
|
||||||
|
Mode::ShellImport => ShellImportView.handle_key(app, key),
|
||||||
|
Mode::Credentials => CredListView.handle_key(app, key),
|
||||||
|
Mode::CredForm => CredFormView.handle_key(app, key),
|
||||||
|
Mode::Settings => SettingsView.handle_key(app, key),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
use crate::ui::{BG, ORANGE};
|
||||||
|
use ratatui::{
|
||||||
|
style::{Color, Style},
|
||||||
|
text::Span,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub fn badge_span(text: &str, bg: Color) -> Span<'static> {
|
||||||
|
Span::styled(format!(" {text} "), Style::default().fg(BG).bg(bg).bold())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn tag_badge(tag: &str) -> Span<'static> {
|
||||||
|
Span::styled(
|
||||||
|
format!(" #{tag} "),
|
||||||
|
Style::default().fg(Color::Rgb(18, 20, 24)).bg(ORANGE),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
use crate::ui::PANEL;
|
||||||
|
use ratatui::{
|
||||||
|
layout::Alignment,
|
||||||
|
style::{Color, Style, Stylize},
|
||||||
|
widgets::{Block, BorderType, Borders, Clear, Paragraph, Widget, Wrap},
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::layout::centered_rect;
|
||||||
|
|
||||||
|
pub fn draw_dialog(
|
||||||
|
frame: &mut ratatui::Frame<'_>,
|
||||||
|
width: u16,
|
||||||
|
height: u16,
|
||||||
|
border_color: Color,
|
||||||
|
title: &str,
|
||||||
|
content: &str,
|
||||||
|
) {
|
||||||
|
let area = centered_rect(width, height, frame.area());
|
||||||
|
frame.render_widget(Clear, area);
|
||||||
|
Paragraph::new(content.to_string())
|
||||||
|
.fg(crate::ui::TEXT)
|
||||||
|
.alignment(Alignment::Center)
|
||||||
|
.wrap(Wrap { trim: true })
|
||||||
|
.block(
|
||||||
|
Block::default()
|
||||||
|
.title(title.to_string())
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.border_type(BorderType::Plain)
|
||||||
|
.border_style(Style::default().fg(border_color))
|
||||||
|
.bg(PANEL),
|
||||||
|
)
|
||||||
|
.render(area, frame.buffer_mut());
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
use crate::ui::{ACCENT, BLUE, DIM_BORDER, MUTED, PANEL_ALT, SELECTED_BG, TEXT};
|
||||||
|
use ratatui::{
|
||||||
|
Frame,
|
||||||
|
layout::Rect,
|
||||||
|
style::{Color, Style},
|
||||||
|
text::{Line, Span},
|
||||||
|
widgets::{List, ListItem, Widget},
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::panel::panel_with_subtitle;
|
||||||
|
|
||||||
|
// ── Row descriptor ──────────────────────────────────────────
|
||||||
|
|
||||||
|
pub enum FormRow {
|
||||||
|
Separator,
|
||||||
|
Toggle {
|
||||||
|
label: String,
|
||||||
|
active: bool,
|
||||||
|
badge: Span<'static>,
|
||||||
|
},
|
||||||
|
Text {
|
||||||
|
label: String,
|
||||||
|
active: bool,
|
||||||
|
display: String,
|
||||||
|
cursor: usize,
|
||||||
|
placeholder: Option<String>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Rendering ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
const ACTIVE_BG: Color = SELECTED_BG;
|
||||||
|
|
||||||
|
pub fn draw_form_list(
|
||||||
|
frame: &mut Frame<'_>,
|
||||||
|
area: Rect,
|
||||||
|
title: &str,
|
||||||
|
subtitle: &str,
|
||||||
|
rows: Vec<FormRow>,
|
||||||
|
) {
|
||||||
|
let mut items: Vec<ListItem> = Vec::new();
|
||||||
|
|
||||||
|
for row in rows {
|
||||||
|
match row {
|
||||||
|
FormRow::Separator => {
|
||||||
|
items.push(ListItem::new(Line::from(Span::styled(
|
||||||
|
" ─────────────────────────────────────────────",
|
||||||
|
Style::default().fg(DIM_BORDER),
|
||||||
|
))));
|
||||||
|
}
|
||||||
|
FormRow::Toggle {
|
||||||
|
label,
|
||||||
|
active,
|
||||||
|
badge,
|
||||||
|
} => {
|
||||||
|
let marker = if active { ">" } else { " " };
|
||||||
|
let label_span = Span::styled(
|
||||||
|
format!("{marker} {label:<13} "),
|
||||||
|
Style::default().fg(if active { ACCENT } else { MUTED }),
|
||||||
|
);
|
||||||
|
let hint = Span::styled(" Enter toggles", Style::default().fg(BLUE));
|
||||||
|
let line = if active {
|
||||||
|
Line::from(vec![label_span, badge, hint]).style(Style::default().bg(ACTIVE_BG))
|
||||||
|
} else {
|
||||||
|
Line::from(vec![label_span, badge, hint])
|
||||||
|
};
|
||||||
|
items.push(ListItem::new(line));
|
||||||
|
}
|
||||||
|
FormRow::Text {
|
||||||
|
label,
|
||||||
|
active,
|
||||||
|
display,
|
||||||
|
cursor,
|
||||||
|
placeholder,
|
||||||
|
} => {
|
||||||
|
let marker = if active { ">" } else { " " };
|
||||||
|
let label_span = Span::styled(
|
||||||
|
format!("{marker} {label:<13} "),
|
||||||
|
Style::default().fg(if active { ACCENT } else { MUTED }),
|
||||||
|
);
|
||||||
|
|
||||||
|
let (shown, is_placeholder) = if display.is_empty() {
|
||||||
|
match &placeholder {
|
||||||
|
Some(ph) => (ph.clone(), true),
|
||||||
|
None => (String::new(), false),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
(display, false)
|
||||||
|
};
|
||||||
|
|
||||||
|
let val_color = if is_placeholder || shown.is_empty() {
|
||||||
|
MUTED
|
||||||
|
} else {
|
||||||
|
TEXT
|
||||||
|
};
|
||||||
|
|
||||||
|
let (before, after) = if active {
|
||||||
|
let pos = cursor.min(shown.len());
|
||||||
|
let b: String = shown.chars().take(pos).collect();
|
||||||
|
let a: String = shown.chars().skip(pos).collect();
|
||||||
|
(b, a)
|
||||||
|
} else {
|
||||||
|
(shown, String::new())
|
||||||
|
};
|
||||||
|
|
||||||
|
let val_span = Span::styled(before, Style::default().fg(val_color));
|
||||||
|
let cursor_span = if active {
|
||||||
|
Span::styled("▌", Style::default().fg(ACCENT))
|
||||||
|
} else {
|
||||||
|
Span::raw("")
|
||||||
|
};
|
||||||
|
let after_span = Span::styled(after, Style::default().fg(val_color));
|
||||||
|
|
||||||
|
let row_bg = if active { ACTIVE_BG } else { PANEL_ALT };
|
||||||
|
let line = Line::from(vec![label_span, val_span, cursor_span, after_span])
|
||||||
|
.style(Style::default().bg(row_bg));
|
||||||
|
items.push(ListItem::new(line));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List::new(items)
|
||||||
|
.block(panel_with_subtitle(title, subtitle))
|
||||||
|
.render(area, frame.buffer_mut());
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
use crate::ui::{ACCENT, DIM_BORDER, MUTED, PANEL_ALT, SELECTED_BG, TEXT};
|
||||||
|
use ratatui::{
|
||||||
|
style::{Style, Stylize},
|
||||||
|
widgets::{Block, BorderType, Borders, Paragraph, Widget},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub fn draw_input(
|
||||||
|
frame: &mut ratatui::Frame<'_>,
|
||||||
|
area: ratatui::layout::Rect,
|
||||||
|
title: &str,
|
||||||
|
value: &str,
|
||||||
|
placeholder: &str,
|
||||||
|
focused: bool,
|
||||||
|
) {
|
||||||
|
let display = if value.is_empty() { placeholder } else { value };
|
||||||
|
let fg = if value.is_empty() { MUTED } else { TEXT };
|
||||||
|
Paragraph::new(format!(" {display}"))
|
||||||
|
.fg(fg)
|
||||||
|
.bg(if focused { SELECTED_BG } else { PANEL_ALT })
|
||||||
|
.block(
|
||||||
|
Block::default()
|
||||||
|
.title(title.to_string())
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.border_type(BorderType::Plain)
|
||||||
|
.border_style(if focused {
|
||||||
|
Style::default().fg(ACCENT)
|
||||||
|
} else {
|
||||||
|
Style::default().fg(DIM_BORDER)
|
||||||
|
})
|
||||||
|
.bg(PANEL_ALT),
|
||||||
|
)
|
||||||
|
.render(area, frame.buffer_mut());
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
use ratatui::layout::Rect;
|
||||||
|
|
||||||
|
pub fn centered_rect(width: u16, height: u16, area: Rect) -> Rect {
|
||||||
|
let x = area.x + area.width.saturating_sub(width) / 2;
|
||||||
|
let y = area.y + area.height.saturating_sub(height) / 2;
|
||||||
|
Rect {
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
width: width.min(area.width),
|
||||||
|
height: height.min(area.height),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
pub mod badge;
|
||||||
|
pub mod dialog;
|
||||||
|
pub mod form_list;
|
||||||
|
pub mod input;
|
||||||
|
pub mod layout;
|
||||||
|
pub mod panel;
|
||||||
|
pub mod toast;
|
||||||
|
|
||||||
|
pub use badge::*;
|
||||||
|
pub use dialog::*;
|
||||||
|
pub use form_list::{FormRow, draw_form_list};
|
||||||
|
pub use input::draw_input;
|
||||||
|
pub use layout::*;
|
||||||
|
pub use panel::*;
|
||||||
|
pub use toast::draw_toast;
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
use crate::ui::{ACCENT, DIM_BORDER, MUTED, PANEL, TEXT};
|
||||||
|
use ratatui::{
|
||||||
|
style::Style,
|
||||||
|
style::Stylize,
|
||||||
|
text::Line,
|
||||||
|
widgets::{Block, BorderType, Borders},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub fn panel(title: impl Into<String>) -> Block<'static> {
|
||||||
|
Block::default()
|
||||||
|
.title(Line::from(format!(" {} ", title.into())).fg(TEXT).bold())
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.border_type(BorderType::Plain)
|
||||||
|
.border_style(Style::default().fg(DIM_BORDER))
|
||||||
|
.bg(PANEL)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn panel_accent(title: impl Into<String>) -> Block<'static> {
|
||||||
|
Block::default()
|
||||||
|
.title(Line::from(format!(" {} ", title.into())).fg(ACCENT).bold())
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.border_type(BorderType::Plain)
|
||||||
|
.border_style(Style::default().fg(ACCENT))
|
||||||
|
.bg(PANEL)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn panel_with_subtitle(title: &str, subtitle: &str) -> Block<'static> {
|
||||||
|
panel(title).title_bottom(Line::from(format!(" {subtitle} ")).fg(MUTED))
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
use crate::ui::{GREEN, PANEL_ALT, RED};
|
||||||
|
use ratatui::{
|
||||||
|
style::{Style, Stylize},
|
||||||
|
widgets::{Block, Borders, Clear, Paragraph, Widget},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub fn draw_toast(frame: &mut ratatui::Frame<'_>, message: &str, success: bool) {
|
||||||
|
let width = (message.len() as u16 + 6).clamp(28, 70);
|
||||||
|
let area = ratatui::layout::Rect {
|
||||||
|
x: frame.area().right().saturating_sub(width + 2),
|
||||||
|
y: frame.area().bottom().saturating_sub(4),
|
||||||
|
width,
|
||||||
|
height: 3,
|
||||||
|
};
|
||||||
|
frame.render_widget(Clear, area);
|
||||||
|
let border_color = if success { GREEN } else { RED };
|
||||||
|
Paragraph::new(format!(" {}", message))
|
||||||
|
.fg(border_color)
|
||||||
|
.block(
|
||||||
|
Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.border_style(Style::default().fg(border_color))
|
||||||
|
.bg(PANEL_ALT),
|
||||||
|
)
|
||||||
|
.render(area, frame.buffer_mut());
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
use crate::app::App;
|
||||||
|
use crate::ui::RED;
|
||||||
|
use crate::ui::component::dialog::draw_dialog;
|
||||||
|
|
||||||
|
pub fn draw_delete_confirm(frame: &mut ratatui::Frame<'_>, app: &App) {
|
||||||
|
let name = app.selected_name().unwrap_or_default();
|
||||||
|
draw_dialog(
|
||||||
|
frame,
|
||||||
|
46,
|
||||||
|
7,
|
||||||
|
RED,
|
||||||
|
" Confirm Delete ",
|
||||||
|
&format!("Delete connection '{name}'?\n\n Enter confirm · Esc cancel"),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
use crate::app::{App, Mode};
|
||||||
|
use crate::config::SyncBackend;
|
||||||
|
use crate::ui::{ACCENT, BG, BLUE, DIM_BORDER, GREEN, MUTED, ORANGE, PANEL, PURPLE, TEXT};
|
||||||
|
use ratatui::{
|
||||||
|
layout::{Alignment, Rect},
|
||||||
|
style::{Color, Style, Stylize},
|
||||||
|
text::{Line, Span},
|
||||||
|
widgets::{Block, Borders, Paragraph, Widget},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub fn draw_header(frame: &mut ratatui::Frame<'_>, app: &App, area: Rect) {
|
||||||
|
let mode = match app.session.mode {
|
||||||
|
Mode::Home => "Home",
|
||||||
|
Mode::Search => "Search",
|
||||||
|
Mode::QuickSelect => "Quick Select",
|
||||||
|
Mode::Form => "Editor",
|
||||||
|
Mode::DeleteConfirm => "Delete",
|
||||||
|
Mode::ImportSelector => "Import",
|
||||||
|
Mode::ShellImport => "Shells",
|
||||||
|
Mode::Credentials => "Credentials",
|
||||||
|
Mode::CredForm => "Cred Editor",
|
||||||
|
Mode::Settings => "Settings",
|
||||||
|
};
|
||||||
|
let synced = app.config.connections.values().filter(|p| p.sync()).count();
|
||||||
|
let (sync_text, sync_color) = if synced > 0 {
|
||||||
|
(format!("synced {synced}"), GREEN)
|
||||||
|
} else {
|
||||||
|
("no sync".to_string(), MUTED)
|
||||||
|
};
|
||||||
|
let creds = app.config.credentials.entries.len();
|
||||||
|
let backend = match app.config.settings.backend {
|
||||||
|
SyncBackend::Gist => {
|
||||||
|
if app.config.settings.gist_id.is_some() {
|
||||||
|
("gist ready", BLUE)
|
||||||
|
} else {
|
||||||
|
("gist not set", MUTED)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SyncBackend::Webdav => {
|
||||||
|
if app.config.settings.webdav_url.is_some() {
|
||||||
|
("webdav ready", ORANGE)
|
||||||
|
} else {
|
||||||
|
("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![
|
||||||
|
Span::styled(
|
||||||
|
" SSHELL ",
|
||||||
|
Style::default().fg(Color::Black).bg(ACCENT).bold(),
|
||||||
|
),
|
||||||
|
Span::raw(" "),
|
||||||
|
Span::styled(mode.to_string(), Style::default().fg(TEXT).bold()),
|
||||||
|
];
|
||||||
|
|
||||||
|
let conn_pill = pill(
|
||||||
|
"connections",
|
||||||
|
&app.config.connections.len().to_string(),
|
||||||
|
ACCENT,
|
||||||
|
);
|
||||||
|
let cred_pill = pill("credentials", &creds.to_string(), BLUE);
|
||||||
|
let sync_span = Span::styled(sync_text, Style::default().fg(sync_color));
|
||||||
|
let backend_span = Span::styled(backend.0.to_string(), Style::default().fg(backend.1));
|
||||||
|
|
||||||
|
let base_width: u16 = spans.iter().map(|s| s.width() as u16).sum();
|
||||||
|
let w = area.width;
|
||||||
|
|
||||||
|
if w >= base_width + 1 + conn_pill.width() as u16 {
|
||||||
|
spans.push(Span::raw(" "));
|
||||||
|
spans.push(conn_pill);
|
||||||
|
}
|
||||||
|
if w >= spans.iter().map(|s| s.width() as u16).sum::<u16>() + 1 + cred_pill.width() as u16 {
|
||||||
|
spans.push(Span::raw(" "));
|
||||||
|
spans.push(cred_pill);
|
||||||
|
}
|
||||||
|
if w >= spans.iter().map(|s| s.width() as u16).sum::<u16>() + 2 + sync_span.width() as u16 {
|
||||||
|
spans.push(Span::raw(" "));
|
||||||
|
spans.push(sync_span);
|
||||||
|
}
|
||||||
|
if w >= spans.iter().map(|s| s.width() as u16).sum::<u16>() + 2 + backend_span.width() as u16 {
|
||||||
|
spans.push(Span::raw(" "));
|
||||||
|
spans.push(backend_span);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut lines = vec![Line::from(spans)];
|
||||||
|
if w >= 35 {
|
||||||
|
lines.push(Line::from(vec![
|
||||||
|
Span::raw(" "),
|
||||||
|
Span::styled("fast ssh and shell launcher", Style::default().fg(MUTED)),
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
Paragraph::new(lines)
|
||||||
|
.block(
|
||||||
|
Block::default()
|
||||||
|
.bg(PANEL)
|
||||||
|
.borders(Borders::BOTTOM)
|
||||||
|
.border_style(Style::default().fg(DIM_BORDER)),
|
||||||
|
)
|
||||||
|
.alignment(Alignment::Left)
|
||||||
|
.style(Style::default().bg(BG))
|
||||||
|
.render(area, frame.buffer_mut());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pill(label: &str, value: &str, color: Color) -> Span<'static> {
|
||||||
|
Span::styled(
|
||||||
|
format!(" {label} {value} "),
|
||||||
|
Style::default().fg(color).bg(BG).bold(),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
use crate::app::{App, Mode};
|
||||||
|
use crate::ui::{ACCENT, DIM_BORDER, MUTED, PANEL};
|
||||||
|
use ratatui::{
|
||||||
|
layout::{Alignment, Rect},
|
||||||
|
style::{Modifier, Style, Stylize},
|
||||||
|
text::{Line, Span},
|
||||||
|
widgets::{Paragraph, Widget},
|
||||||
|
};
|
||||||
|
|
||||||
|
struct Hint {
|
||||||
|
key: &'static str,
|
||||||
|
desc: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn home_hints() -> Vec<Hint> {
|
||||||
|
vec![
|
||||||
|
Hint { key: "j/k", desc: "move" },
|
||||||
|
Hint { key: "Tab", desc: "group" },
|
||||||
|
Hint { key: "Enter", desc: "connect" },
|
||||||
|
Hint { key: "Ctrl+Q", desc: "quick" },
|
||||||
|
Hint { key: "/", desc: "search" },
|
||||||
|
Hint { key: "a", desc: "add" },
|
||||||
|
Hint { key: "e", desc: "edit" },
|
||||||
|
Hint { key: "d", desc: "delete" },
|
||||||
|
Hint { key: "r", desc: "scan" },
|
||||||
|
Hint { key: "p/P", desc: "push/pull" },
|
||||||
|
Hint { key: "c", desc: "creds" },
|
||||||
|
Hint { key: "s", desc: "settings" },
|
||||||
|
Hint { key: "q", desc: "quit" },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn search_hints() -> Vec<Hint> {
|
||||||
|
vec![
|
||||||
|
Hint { key: "type", desc: "filter" },
|
||||||
|
Hint { key: "j/k", desc: "move" },
|
||||||
|
Hint { key: "Esc", desc: "close" },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn quick_select_hints() -> Vec<Hint> {
|
||||||
|
vec![
|
||||||
|
Hint { key: "1-9", desc: "connect" },
|
||||||
|
Hint { key: "Tab", desc: "sort" },
|
||||||
|
Hint { key: "Esc", desc: "cancel" },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn form_hints() -> Vec<Hint> {
|
||||||
|
vec![
|
||||||
|
Hint { key: "Tab", desc: "next" },
|
||||||
|
Hint { key: "Enter", desc: "save" },
|
||||||
|
Hint { key: "Esc", desc: "cancel" },
|
||||||
|
Hint { key: "Ctrl+U", desc: "clear" },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn delete_hints() -> Vec<Hint> {
|
||||||
|
vec![
|
||||||
|
Hint { key: "Enter", desc: "confirm" },
|
||||||
|
Hint { key: "Esc", desc: "cancel" },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn credentials_hints() -> Vec<Hint> {
|
||||||
|
vec![
|
||||||
|
Hint { key: "j/k", desc: "move" },
|
||||||
|
Hint { key: "Enter", desc: "edit" },
|
||||||
|
Hint { key: "a", desc: "add" },
|
||||||
|
Hint { key: "d", desc: "delete" },
|
||||||
|
Hint { key: "Esc", desc: "back" },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cred_form_hints() -> Vec<Hint> {
|
||||||
|
vec![
|
||||||
|
Hint { key: "Tab", desc: "next" },
|
||||||
|
Hint { key: "Enter", desc: "save" },
|
||||||
|
Hint { key: "Esc", desc: "back" },
|
||||||
|
Hint { key: "Ctrl+U", desc: "clear" },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn import_hints() -> Vec<Hint> {
|
||||||
|
vec![
|
||||||
|
Hint { key: "j/k", desc: "move" },
|
||||||
|
Hint { key: "Space", desc: "toggle" },
|
||||||
|
Hint { key: "a/A", desc: "all/none" },
|
||||||
|
Hint { key: "Enter", desc: "import" },
|
||||||
|
Hint { key: "Esc", desc: "cancel" },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn shell_import_hints() -> Vec<Hint> {
|
||||||
|
vec![
|
||||||
|
Hint { key: "j/k", desc: "move" },
|
||||||
|
Hint { key: "Space", desc: "toggle" },
|
||||||
|
Hint { key: "a/A", desc: "all/none" },
|
||||||
|
Hint { key: "Enter", desc: "enable" },
|
||||||
|
Hint { key: "Esc", desc: "skip" },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn settings_hints() -> Vec<Hint> {
|
||||||
|
vec![
|
||||||
|
Hint { key: "type", desc: "edit" },
|
||||||
|
Hint { key: "Enter", desc: "save" },
|
||||||
|
Hint { key: "Esc", desc: "cancel" },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn draw_help(frame: &mut ratatui::Frame<'_>, app: &App, area: Rect) {
|
||||||
|
let hints = match app.session.mode {
|
||||||
|
Mode::Home => home_hints(),
|
||||||
|
Mode::Search => search_hints(),
|
||||||
|
Mode::QuickSelect => quick_select_hints(),
|
||||||
|
Mode::Form => form_hints(),
|
||||||
|
Mode::DeleteConfirm => delete_hints(),
|
||||||
|
Mode::Credentials => credentials_hints(),
|
||||||
|
Mode::CredForm => cred_form_hints(),
|
||||||
|
Mode::ImportSelector => import_hints(),
|
||||||
|
Mode::ShellImport => shell_import_hints(),
|
||||||
|
Mode::Settings => settings_hints(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let sep_width: usize = 5; // " | "
|
||||||
|
let mut spans: Vec<Span<'static>> = Vec::new();
|
||||||
|
let mut used: usize = 0;
|
||||||
|
let max_w = area.width as usize;
|
||||||
|
|
||||||
|
for (i, hint) in hints.iter().enumerate() {
|
||||||
|
let hint_span = key_hint(hint.key, hint.desc);
|
||||||
|
let needed = hint_span.width() + if i > 0 { sep_width } else { 0 };
|
||||||
|
if used + needed > max_w {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if i > 0 {
|
||||||
|
spans.push(sep());
|
||||||
|
}
|
||||||
|
spans.push(hint_span);
|
||||||
|
used += needed;
|
||||||
|
}
|
||||||
|
|
||||||
|
Paragraph::new(Line::from(spans))
|
||||||
|
.fg(MUTED)
|
||||||
|
.bg(PANEL)
|
||||||
|
.alignment(Alignment::Center)
|
||||||
|
.render(area, frame.buffer_mut());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn key_hint(key: &str, desc: &str) -> Span<'static> {
|
||||||
|
Span::styled(
|
||||||
|
format!(" {key} {desc} "),
|
||||||
|
Style::default()
|
||||||
|
.fg(ACCENT)
|
||||||
|
.bg(PANEL)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sep() -> Span<'static> {
|
||||||
|
Span::styled(" | ", Style::default().fg(DIM_BORDER))
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
pub mod delete_confirm;
|
||||||
|
pub mod header;
|
||||||
|
pub mod help_bar;
|
||||||
|
|
||||||
|
pub use delete_confirm::draw_delete_confirm;
|
||||||
|
pub use header::draw_header;
|
||||||
|
pub use help_bar::draw_help;
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
pub mod common;
|
||||||
|
pub mod custom;
|
||||||
|
|
||||||
|
pub use common::*;
|
||||||
|
pub use custom::*;
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
pub mod app;
|
||||||
|
pub mod component;
|
||||||
|
pub mod view;
|
||||||
|
|
||||||
|
use crate::app::Mode;
|
||||||
|
use ratatui::{
|
||||||
|
Frame,
|
||||||
|
layout::{Alignment, Constraint, Direction, Layout, Margin, Rect},
|
||||||
|
style::{Color, Stylize},
|
||||||
|
widgets::{Block, Paragraph, Widget},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub use app::restore_terminal;
|
||||||
|
|
||||||
|
use component::{draw_delete_confirm, draw_header, draw_help, draw_toast};
|
||||||
|
|
||||||
|
// ── Theme ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub const BG: Color = Color::Rgb(12, 14, 17);
|
||||||
|
pub const PANEL: Color = Color::Rgb(20, 24, 29);
|
||||||
|
pub const PANEL_ALT: Color = Color::Rgb(25, 30, 36);
|
||||||
|
pub const ACCENT: Color = Color::Rgb(79, 209, 197);
|
||||||
|
pub const BLUE: Color = Color::Rgb(96, 165, 250);
|
||||||
|
pub const GREEN: Color = Color::Rgb(74, 222, 128);
|
||||||
|
pub const RED: Color = Color::Rgb(248, 113, 113);
|
||||||
|
pub const ORANGE: Color = Color::Rgb(251, 146, 60);
|
||||||
|
pub const YELLOW: Color = Color::Rgb(250, 204, 21);
|
||||||
|
pub const PURPLE: Color = Color::Rgb(167, 139, 250);
|
||||||
|
pub const TEXT: Color = Color::Rgb(232, 238, 247);
|
||||||
|
pub const MUTED: Color = Color::Rgb(139, 150, 166);
|
||||||
|
pub const DIM_BORDER: Color = Color::Rgb(45, 52, 62);
|
||||||
|
pub const SELECTED_BG: Color = Color::Rgb(34, 48, 58);
|
||||||
|
|
||||||
|
// ── Draw dispatcher ──────────────────────────────────────────
|
||||||
|
|
||||||
|
const MIN_WIDTH: u16 = 50;
|
||||||
|
const MIN_HEIGHT: u16 = 25;
|
||||||
|
|
||||||
|
pub fn draw(frame: &mut Frame<'_>, app: &mut crate::app::App) {
|
||||||
|
frame.render_widget(Block::new().bg(BG), frame.area());
|
||||||
|
if let Some(toast) = &app.session.toast
|
||||||
|
&& toast.born.elapsed().as_secs() > 3
|
||||||
|
{
|
||||||
|
app.session.toast = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let area = frame.area();
|
||||||
|
if area.width < MIN_WIDTH || area.height < MIN_HEIGHT {
|
||||||
|
let msg = format!(
|
||||||
|
"Terminal too small: {}x{} (min {}x{})",
|
||||||
|
area.width, area.height, MIN_WIDTH, MIN_HEIGHT,
|
||||||
|
);
|
||||||
|
Paragraph::new(msg)
|
||||||
|
.fg(ACCENT)
|
||||||
|
.alignment(Alignment::Center)
|
||||||
|
.render(
|
||||||
|
Rect { x: 0, y: area.height / 2, width: area.width, height: 1 },
|
||||||
|
frame.buffer_mut(),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let shell = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Length(3),
|
||||||
|
Constraint::Min(8),
|
||||||
|
Constraint::Length(1),
|
||||||
|
])
|
||||||
|
.split(area);
|
||||||
|
|
||||||
|
draw_header(frame, app, shell[0]);
|
||||||
|
let content = shell[1].inner(Margin {
|
||||||
|
horizontal: 1,
|
||||||
|
vertical: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
use view::View;
|
||||||
|
match app.session.mode {
|
||||||
|
Mode::Home => 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::ShellImport => view::ShellImportView.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),
|
||||||
|
}
|
||||||
|
|
||||||
|
draw_help(frame, app, shell[2]);
|
||||||
|
if app.session.mode == Mode::DeleteConfirm {
|
||||||
|
draw_delete_confirm(frame, app);
|
||||||
|
}
|
||||||
|
if let Some(toast) = &app.session.toast {
|
||||||
|
draw_toast(frame, toast.message.as_str(), toast.success);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
use crate::app::{App, AuthKind, CredFormField, CredFormState, Mode, TextEditing, char_len};
|
||||||
|
use crate::ui::component::{FormRow, badge_span};
|
||||||
|
use crate::ui::{GREEN, ORANGE};
|
||||||
|
|
||||||
|
use super::View;
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||||
|
use ratatui::{Frame, layout::Rect};
|
||||||
|
|
||||||
|
pub struct CredFormView;
|
||||||
|
|
||||||
|
impl View for CredFormView {
|
||||||
|
fn draw(&self, frame: &mut Frame<'_>, app: &App, area: Rect) {
|
||||||
|
let form = &app.session.credentials.form;
|
||||||
|
let is_new = form.edit_name.is_none();
|
||||||
|
let title = if is_new {
|
||||||
|
"New Credential"
|
||||||
|
} else {
|
||||||
|
"Edit Credential"
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut rows: Vec<FormRow> = Vec::new();
|
||||||
|
|
||||||
|
for (i, &field) in CredFormField::ALL.iter().enumerate() {
|
||||||
|
let active = form.active == field;
|
||||||
|
|
||||||
|
if i == 1 {
|
||||||
|
rows.push(FormRow::Separator);
|
||||||
|
}
|
||||||
|
|
||||||
|
let label = field.label().to_string();
|
||||||
|
|
||||||
|
if field.is_toggle() {
|
||||||
|
let badge = match field {
|
||||||
|
CredFormField::Kind => match form.kind {
|
||||||
|
AuthKind::Password => badge_span("Password", GREEN),
|
||||||
|
AuthKind::PrivateKey => badge_span("Private Key", ORANGE),
|
||||||
|
},
|
||||||
|
_ => unreachable!(),
|
||||||
|
};
|
||||||
|
rows.push(FormRow::Toggle {
|
||||||
|
label,
|
||||||
|
active,
|
||||||
|
badge,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
let raw = form.field_value(field).to_string();
|
||||||
|
let (display, secret_cursor) = if matches!(field, CredFormField::Value) && !raw.is_empty() {
|
||||||
|
if active && matches!(form.kind, AuthKind::Password) {
|
||||||
|
let d: String = "*".repeat(raw.chars().count());
|
||||||
|
(d, form.cursor)
|
||||||
|
} else {
|
||||||
|
("<set>".into(), 0)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
(raw, form.cursor)
|
||||||
|
};
|
||||||
|
let cursor = if active && field.is_text() {
|
||||||
|
secret_cursor.min(char_len(&display))
|
||||||
|
} else {
|
||||||
|
char_len(&display)
|
||||||
|
};
|
||||||
|
rows.push(FormRow::Text {
|
||||||
|
label,
|
||||||
|
active,
|
||||||
|
display,
|
||||||
|
cursor,
|
||||||
|
placeholder: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let subtitle = "Tab ▽ ↑ △ Enter save/toggle Esc back Ctrl+U clear";
|
||||||
|
crate::ui::component::draw_form_list(frame, area, title, subtitle, rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_key(&self, app: &mut App, key: KeyEvent) -> Result<()> {
|
||||||
|
handle_cred_form(app, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Key handling ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn handle_cred_form(app: &mut App, key: KeyEvent) -> Result<()> {
|
||||||
|
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
|
||||||
|
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Esc => {
|
||||||
|
app.session.mode = Mode::Credentials;
|
||||||
|
}
|
||||||
|
|
||||||
|
KeyCode::Tab | KeyCode::Down => {
|
||||||
|
app.session.credentials.form.next_field();
|
||||||
|
}
|
||||||
|
KeyCode::BackTab | KeyCode::Up => {
|
||||||
|
app.session.credentials.form.prev_field();
|
||||||
|
}
|
||||||
|
|
||||||
|
KeyCode::Enter => {
|
||||||
|
if app.session.credentials.form.active.is_toggle() {
|
||||||
|
toggle_cred_field(&mut app.session.credentials.form);
|
||||||
|
} else {
|
||||||
|
match app.save_cred_form() {
|
||||||
|
Ok(()) => app.toast("saved", true),
|
||||||
|
Err(err) => app.toast(err.to_string(), false),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
KeyCode::Backspace => {
|
||||||
|
app.session.credentials.form.delete_char();
|
||||||
|
}
|
||||||
|
KeyCode::Left => app.session.credentials.form.move_cursor_left(),
|
||||||
|
KeyCode::Right => app.session.credentials.form.move_cursor_right(),
|
||||||
|
KeyCode::Home => app.session.credentials.form.cursor_home(),
|
||||||
|
KeyCode::End => app.session.credentials.form.cursor_end(),
|
||||||
|
KeyCode::Char('a') if ctrl => app.session.credentials.form.cursor_home(),
|
||||||
|
KeyCode::Char('e') if ctrl => app.session.credentials.form.cursor_end(),
|
||||||
|
KeyCode::Char('u') if ctrl => app.session.credentials.form.clear_field(),
|
||||||
|
|
||||||
|
KeyCode::Char(' ') => {
|
||||||
|
if app.session.credentials.form.active.is_toggle() {
|
||||||
|
toggle_cred_field(&mut app.session.credentials.form);
|
||||||
|
} else {
|
||||||
|
app.session.credentials.form.insert_char(' ');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
KeyCode::Char(c) if !ctrl && !app.session.credentials.form.active.is_toggle() => {
|
||||||
|
app.session.credentials.form.insert_char(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn toggle_cred_field(form: &mut CredFormState) {
|
||||||
|
if form.active == CredFormField::Kind {
|
||||||
|
form.kind = match form.kind {
|
||||||
|
AuthKind::Password => AuthKind::PrivateKey,
|
||||||
|
AuthKind::PrivateKey => AuthKind::Password,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
use crate::app::{App, Mode};
|
||||||
|
use crate::config::CredentialEntry;
|
||||||
|
use crate::ui::component::panel;
|
||||||
|
use crate::ui::{BLUE, GREEN, MUTED, RED, SELECTED_BG, TEXT};
|
||||||
|
|
||||||
|
use super::{View, scroll_rows};
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use crossterm::event::{KeyCode, KeyEvent};
|
||||||
|
use ratatui::{
|
||||||
|
Frame,
|
||||||
|
layout::{Alignment, Constraint, Rect},
|
||||||
|
style::{Modifier, Style, Stylize},
|
||||||
|
widgets::{Cell, Paragraph, Row, Table, Widget},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub struct CredListView;
|
||||||
|
|
||||||
|
impl View for CredListView {
|
||||||
|
fn draw(&self, frame: &mut Frame<'_>, app: &App, area: Rect) {
|
||||||
|
draw_credentials(frame, app, area);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_key(&self, app: &mut App, key: KeyEvent) -> Result<()> {
|
||||||
|
handle_credentials(app, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Rendering ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn draw_credentials(frame: &mut Frame<'_>, app: &App, area: Rect) {
|
||||||
|
let entries: Vec<_> = app.config.credentials.entries.iter().collect();
|
||||||
|
if entries.is_empty() {
|
||||||
|
Paragraph::new("\n No credentials configured\n\n Press a to add one")
|
||||||
|
.fg(MUTED)
|
||||||
|
.alignment(Alignment::Center)
|
||||||
|
.block(panel("Credentials"))
|
||||||
|
.render(area, frame.buffer_mut());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let rows: Vec<Row<'_>> = entries
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(idx, (name, entry))| {
|
||||||
|
let selected = idx == app.session.credentials.selected;
|
||||||
|
let style = if selected {
|
||||||
|
Style::default()
|
||||||
|
.bg(SELECTED_BG)
|
||||||
|
.fg(TEXT)
|
||||||
|
.add_modifier(Modifier::BOLD)
|
||||||
|
} else {
|
||||||
|
Style::default().fg(TEXT)
|
||||||
|
};
|
||||||
|
|
||||||
|
let has_value = entry.has_value();
|
||||||
|
let dot_color = if has_value { GREEN } else { RED };
|
||||||
|
|
||||||
|
let kind_text = match entry {
|
||||||
|
CredentialEntry::Password { .. } => "password",
|
||||||
|
CredentialEntry::PrivateKey { .. } => "private key",
|
||||||
|
};
|
||||||
|
|
||||||
|
let refs = app.cred_referenced_by(name);
|
||||||
|
let ref_text = if refs.is_empty() {
|
||||||
|
"(unused)".to_string()
|
||||||
|
} else {
|
||||||
|
refs.iter()
|
||||||
|
.map(|s| s.as_str())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ")
|
||||||
|
};
|
||||||
|
|
||||||
|
Row::new([
|
||||||
|
Cell::from(format!("{} {}", if selected { ">" } else { " " }, (*name)))
|
||||||
|
.style(style),
|
||||||
|
Cell::from(format!("● {kind_text}")).style(Style::default().fg(dot_color)),
|
||||||
|
Cell::from(ref_text).style(style),
|
||||||
|
])
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let rows = scroll_rows(rows, app.session.credentials.selected, area.height);
|
||||||
|
|
||||||
|
let table = Table::new(
|
||||||
|
rows,
|
||||||
|
[
|
||||||
|
Constraint::Percentage(30),
|
||||||
|
Constraint::Percentage(20),
|
||||||
|
Constraint::Percentage(50),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.header(
|
||||||
|
Row::new([" Name", "Type", "Used by"])
|
||||||
|
.style(Style::default().fg(BLUE).add_modifier(Modifier::BOLD)),
|
||||||
|
)
|
||||||
|
.block(panel("Credentials"))
|
||||||
|
.column_spacing(2)
|
||||||
|
.row_highlight_style(Style::default().bg(SELECTED_BG));
|
||||||
|
frame.render_widget(table, area);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Key handling ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn handle_credentials(app: &mut App, key: KeyEvent) -> Result<()> {
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Esc => app.session.mode = Mode::Home,
|
||||||
|
KeyCode::Down | KeyCode::Char('j') => {
|
||||||
|
let len = app.config.credentials.entries.len();
|
||||||
|
if len > 0 {
|
||||||
|
app.session.credentials.selected = (app.session.credentials.selected as isize + 1)
|
||||||
|
.rem_euclid(len as isize)
|
||||||
|
as usize;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Up | KeyCode::Char('k') => {
|
||||||
|
let len = app.config.credentials.entries.len();
|
||||||
|
if len > 0 {
|
||||||
|
app.session.credentials.selected = (app.session.credentials.selected as isize - 1)
|
||||||
|
.rem_euclid(len as isize)
|
||||||
|
as usize;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Enter => {
|
||||||
|
app.edit_cred_form();
|
||||||
|
}
|
||||||
|
KeyCode::Char('a') => {
|
||||||
|
app.new_cred_form();
|
||||||
|
}
|
||||||
|
KeyCode::Char('d') => match app.delete_cred() {
|
||||||
|
Ok(()) => app.toast("deleted", true),
|
||||||
|
Err(err) => app.toast(err.to_string(), false),
|
||||||
|
},
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
use crate::app::{App, Mode};
|
||||||
|
|
||||||
|
use super::View;
|
||||||
|
use super::home_list::HomeListView;
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use crossterm::event::{KeyCode, KeyEvent};
|
||||||
|
use ratatui::{Frame, layout::Rect};
|
||||||
|
|
||||||
|
pub struct DeleteConfirmView;
|
||||||
|
|
||||||
|
impl View for DeleteConfirmView {
|
||||||
|
fn draw(&self, frame: &mut Frame<'_>, app: &App, area: Rect) {
|
||||||
|
HomeListView.draw(frame, app, area);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_key(&self, app: &mut App, key: KeyEvent) -> Result<()> {
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Esc => app.session.mode = Mode::Home,
|
||||||
|
KeyCode::Enter => {
|
||||||
|
match app.delete_selected() {
|
||||||
|
Ok(()) => app.toast("deleted", true),
|
||||||
|
Err(err) => app.toast(err.to_string(), false),
|
||||||
|
}
|
||||||
|
app.session.mode = Mode::Home;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
use crate::app::{App, AuthKind, FormField, FormState, Mode, TextEditing, char_len};
|
||||||
|
use crate::ui::component::{FormRow, badge_span};
|
||||||
|
use crate::ui::{ACCENT, GREEN, ORANGE, PURPLE, RED};
|
||||||
|
|
||||||
|
use super::View;
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||||
|
use ratatui::{Frame, layout::Rect};
|
||||||
|
|
||||||
|
pub struct FormView;
|
||||||
|
|
||||||
|
impl View for FormView {
|
||||||
|
fn draw(&self, frame: &mut Frame<'_>, app: &App, area: Rect) {
|
||||||
|
let form = &app.session.form;
|
||||||
|
let fields = form.visible_fields();
|
||||||
|
let is_new = form.edit_name.is_none();
|
||||||
|
let title = if is_new {
|
||||||
|
"New Connection"
|
||||||
|
} else {
|
||||||
|
"Edit Connection"
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut rows: Vec<FormRow> = Vec::new();
|
||||||
|
|
||||||
|
for (i, &field) in fields.iter().enumerate() {
|
||||||
|
let active = form.active == field;
|
||||||
|
|
||||||
|
if i == 1 || matches!(field, FormField::Auth) || matches!(field, FormField::Tags) {
|
||||||
|
rows.push(FormRow::Separator);
|
||||||
|
}
|
||||||
|
|
||||||
|
let label = field.label().to_string();
|
||||||
|
|
||||||
|
if field.is_toggle() {
|
||||||
|
let badge = match field {
|
||||||
|
FormField::Type => {
|
||||||
|
if form.is_shell {
|
||||||
|
badge_span("Shell", PURPLE)
|
||||||
|
} else {
|
||||||
|
badge_span("SSH", ACCENT)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
FormField::Auth => match form.auth_kind {
|
||||||
|
AuthKind::Password => badge_span("Password", GREEN),
|
||||||
|
AuthKind::PrivateKey => badge_span("Private Key", ORANGE),
|
||||||
|
},
|
||||||
|
FormField::Sync => {
|
||||||
|
if form.sync {
|
||||||
|
badge_span("Yes", GREEN)
|
||||||
|
} else {
|
||||||
|
badge_span("No", RED)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => unreachable!(),
|
||||||
|
};
|
||||||
|
rows.push(FormRow::Toggle {
|
||||||
|
label,
|
||||||
|
active,
|
||||||
|
badge,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
let raw = form.field_value(field).to_string();
|
||||||
|
let (display, secret_cursor) = if matches!(field, FormField::Secret) {
|
||||||
|
if raw.is_empty() {
|
||||||
|
("<unchanged>".into(), 0)
|
||||||
|
} else if active && matches!(form.auth_kind, AuthKind::Password) {
|
||||||
|
let d: String = "*".repeat(raw.chars().count());
|
||||||
|
(d, form.cursor)
|
||||||
|
} else {
|
||||||
|
("<set>".into(), 0)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
(raw, form.cursor)
|
||||||
|
};
|
||||||
|
let cursor = if active && field.is_text() {
|
||||||
|
secret_cursor.min(char_len(&display))
|
||||||
|
} else {
|
||||||
|
char_len(&display)
|
||||||
|
};
|
||||||
|
rows.push(FormRow::Text {
|
||||||
|
label,
|
||||||
|
active,
|
||||||
|
display,
|
||||||
|
cursor,
|
||||||
|
placeholder: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let subtitle = "Tab ▽ ↑ △ Enter save/toggle Esc cancel Ctrl+U clear";
|
||||||
|
crate::ui::component::draw_form_list(frame, area, title, subtitle, rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_key(&self, app: &mut App, key: KeyEvent) -> Result<()> {
|
||||||
|
handle_form(app, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Key handling ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn handle_form(app: &mut App, key: KeyEvent) -> Result<()> {
|
||||||
|
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
|
||||||
|
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Esc => app.session.mode = Mode::Home,
|
||||||
|
|
||||||
|
KeyCode::Tab | KeyCode::Down => {
|
||||||
|
app.session.form.next_field();
|
||||||
|
}
|
||||||
|
KeyCode::BackTab | KeyCode::Up => {
|
||||||
|
app.session.form.prev_field();
|
||||||
|
}
|
||||||
|
|
||||||
|
KeyCode::Enter => {
|
||||||
|
if app.session.form.active.is_toggle() {
|
||||||
|
toggle_field(&mut app.session.form);
|
||||||
|
} else {
|
||||||
|
match app.save_form() {
|
||||||
|
Ok(()) => app.toast("saved", true),
|
||||||
|
Err(err) => app.toast(err.to_string(), false),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
KeyCode::Backspace => {
|
||||||
|
app.session.form.delete_char();
|
||||||
|
}
|
||||||
|
KeyCode::Delete => {
|
||||||
|
app.session.form.delete_next_char();
|
||||||
|
}
|
||||||
|
KeyCode::Left => {
|
||||||
|
app.session.form.move_cursor_left();
|
||||||
|
}
|
||||||
|
KeyCode::Right => {
|
||||||
|
app.session.form.move_cursor_right();
|
||||||
|
}
|
||||||
|
KeyCode::Home => app.session.form.cursor_home(),
|
||||||
|
KeyCode::End => app.session.form.cursor_end(),
|
||||||
|
|
||||||
|
KeyCode::Char('a') if ctrl => app.session.form.cursor_home(),
|
||||||
|
KeyCode::Char('e') if ctrl => app.session.form.cursor_end(),
|
||||||
|
KeyCode::Char('u') if ctrl => app.session.form.clear_field(),
|
||||||
|
|
||||||
|
KeyCode::Char(' ') => {
|
||||||
|
if app.session.form.active.is_toggle() {
|
||||||
|
toggle_field(&mut app.session.form);
|
||||||
|
} else {
|
||||||
|
app.session.form.insert_char(' ');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
KeyCode::Char(c) if !ctrl && !app.session.form.active.is_toggle() => {
|
||||||
|
app.session.form.insert_char(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn toggle_field(form: &mut FormState) {
|
||||||
|
match form.active {
|
||||||
|
FormField::Type => {
|
||||||
|
form.is_shell = !form.is_shell;
|
||||||
|
if form.is_shell {
|
||||||
|
form.auth_ref.clear();
|
||||||
|
form.secret.clear();
|
||||||
|
form.sync = false;
|
||||||
|
} else {
|
||||||
|
form.sync = true;
|
||||||
|
}
|
||||||
|
form.ensure_active_visible();
|
||||||
|
}
|
||||||
|
FormField::Auth => {
|
||||||
|
form.auth_kind = match form.auth_kind {
|
||||||
|
AuthKind::Password => AuthKind::PrivateKey,
|
||||||
|
AuthKind::PrivateKey => AuthKind::Password,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
FormField::Sync => {
|
||||||
|
form.sync = !form.sync;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,464 @@
|
|||||||
|
use crate::app::{App, Mode};
|
||||||
|
use crate::config::{ConnectionSource, ConnectionType, CredentialEntry};
|
||||||
|
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};
|
||||||
|
|
||||||
|
use super::View;
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||||
|
use ratatui::{
|
||||||
|
Frame,
|
||||||
|
layout::{Alignment, Constraint, Direction, Layout, Rect},
|
||||||
|
style::{Modifier, Style, Stylize},
|
||||||
|
text::{Line, Span},
|
||||||
|
widgets::{Cell, Paragraph, Row, Table, Widget},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub struct HomeListView;
|
||||||
|
|
||||||
|
impl View for HomeListView {
|
||||||
|
fn draw(&self, frame: &mut Frame<'_>, app: &App, area: Rect) {
|
||||||
|
let has_search = app.session.mode == Mode::Search;
|
||||||
|
let top_height = if has_search { 3 } else { 0 };
|
||||||
|
|
||||||
|
let outer = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([Constraint::Length(top_height), Constraint::Min(4)])
|
||||||
|
.split(area);
|
||||||
|
|
||||||
|
if has_search {
|
||||||
|
draw_search_box(frame, app, outer[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if outer[1].width < 96 {
|
||||||
|
let panels = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Percentage(58),
|
||||||
|
Constraint::Length(1),
|
||||||
|
Constraint::Min(8),
|
||||||
|
])
|
||||||
|
.split(outer[1]);
|
||||||
|
draw_connection_list(frame, app, panels[0]);
|
||||||
|
draw_detail_panel(frame, app, panels[2]);
|
||||||
|
} else {
|
||||||
|
let panels = Layout::default()
|
||||||
|
.direction(Direction::Horizontal)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Percentage(60),
|
||||||
|
Constraint::Length(1),
|
||||||
|
Constraint::Percentage(40),
|
||||||
|
])
|
||||||
|
.split(outer[1]);
|
||||||
|
|
||||||
|
draw_connection_list(frame, app, panels[0]);
|
||||||
|
draw_detail_panel(frame, app, panels[2]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_key(&self, app: &mut App, key: KeyEvent) -> Result<()> {
|
||||||
|
handle_home(app, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Rendering ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub fn draw_search_box(frame: &mut Frame<'_>, app: &App, area: Rect) {
|
||||||
|
let is_empty = app.session.home.search.is_empty();
|
||||||
|
let value = if is_empty {
|
||||||
|
""
|
||||||
|
} else {
|
||||||
|
&app.session.home.search
|
||||||
|
};
|
||||||
|
let placeholder = if is_empty { "type to filter..." } else { "" };
|
||||||
|
draw_input(frame, area, " Search ", value, placeholder, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn draw_connection_list(frame: &mut Frame<'_>, app: &App, area: Rect) {
|
||||||
|
let entries = if app.session.mode == Mode::QuickSelect {
|
||||||
|
app.quick_entries()
|
||||||
|
} else {
|
||||||
|
app.entries()
|
||||||
|
};
|
||||||
|
if entries.is_empty() {
|
||||||
|
let message = if app.session.home.search.is_empty() {
|
||||||
|
"\n No connections yet\n\n Press a to create one or i to import from ~/.ssh/config"
|
||||||
|
} else {
|
||||||
|
"\n No matching connections\n\n Press Esc to clear search"
|
||||||
|
};
|
||||||
|
Paragraph::new(message)
|
||||||
|
.fg(MUTED)
|
||||||
|
.alignment(Alignment::Center)
|
||||||
|
.block(panel("Connections"))
|
||||||
|
.render(area, frame.buffer_mut());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut rows = Vec::new();
|
||||||
|
let mut entry_row = Vec::new(); // entry_idx -> visual row index
|
||||||
|
if app.session.mode == Mode::QuickSelect {
|
||||||
|
rows.push(section_row(
|
||||||
|
&format!("Quick {}", app.session.home.quick_sort.label()),
|
||||||
|
entries.len().min(9),
|
||||||
|
));
|
||||||
|
for (idx, (name, profile)) in entries.iter().take(9).enumerate() {
|
||||||
|
entry_row.push(rows.len());
|
||||||
|
rows.push(connection_row(app, idx, name, profile));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let mut entry_idx = 0;
|
||||||
|
let ssh_count = entries
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, profile)| matches!(profile.kind, ConnectionType::Ssh { .. }))
|
||||||
|
.count();
|
||||||
|
let shell_count = entries.len().saturating_sub(ssh_count);
|
||||||
|
|
||||||
|
if ssh_count > 0 {
|
||||||
|
rows.push(section_row("SSH", ssh_count));
|
||||||
|
}
|
||||||
|
for (name, profile) in entries
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, profile)| matches!(profile.kind, ConnectionType::Ssh { .. }))
|
||||||
|
{
|
||||||
|
entry_row.push(rows.len());
|
||||||
|
rows.push(connection_row(app, entry_idx, name, profile));
|
||||||
|
entry_idx += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ssh_count > 0 && shell_count > 0 {
|
||||||
|
rows.push(Row::new(["", "", "", ""]).height(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
if shell_count > 0 {
|
||||||
|
rows.push(section_row("Shell", shell_count));
|
||||||
|
}
|
||||||
|
for (name, profile) in entries
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, profile)| matches!(profile.kind, ConnectionType::Shell { .. }))
|
||||||
|
{
|
||||||
|
entry_row.push(rows.len());
|
||||||
|
rows.push(connection_row(app, entry_idx, name, profile));
|
||||||
|
entry_idx += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let title = if app.session.mode == Mode::QuickSelect {
|
||||||
|
"Connections - Quick Select"
|
||||||
|
} else {
|
||||||
|
"Connections"
|
||||||
|
};
|
||||||
|
|
||||||
|
let table = Table::new(
|
||||||
|
rows,
|
||||||
|
[
|
||||||
|
Constraint::Percentage(28),
|
||||||
|
Constraint::Length(6),
|
||||||
|
Constraint::Percentage(50),
|
||||||
|
Constraint::Length(8),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.header(
|
||||||
|
Row::new([" Name", "Type", "Target", "Auth"])
|
||||||
|
.style(Style::default().fg(BLUE).add_modifier(Modifier::BOLD)),
|
||||||
|
)
|
||||||
|
.block(panel(title))
|
||||||
|
.column_spacing(2)
|
||||||
|
.row_highlight_style(Style::default().bg(SELECTED_BG));
|
||||||
|
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,
|
||||||
|
name: &str,
|
||||||
|
profile: &crate::config::ConnectionProfile,
|
||||||
|
) -> Row<'static> {
|
||||||
|
let selected = idx == app.session.home.selected;
|
||||||
|
let marker = if app.session.mode == Mode::QuickSelect {
|
||||||
|
quick_key(idx).unwrap_or(' ')
|
||||||
|
} else if selected {
|
||||||
|
'>'
|
||||||
|
} else {
|
||||||
|
' '
|
||||||
|
};
|
||||||
|
let row_style = if selected {
|
||||||
|
Style::default()
|
||||||
|
.bg(SELECTED_BG)
|
||||||
|
.fg(TEXT)
|
||||||
|
.add_modifier(Modifier::BOLD)
|
||||||
|
} else {
|
||||||
|
Style::default().fg(TEXT)
|
||||||
|
};
|
||||||
|
let is_shell = matches!(profile.kind, ConnectionType::Shell { .. });
|
||||||
|
let type_badge = if is_shell { "[SHL]" } else { "[SSH]" };
|
||||||
|
let badge_color = if is_shell { PURPLE } else { ACCENT };
|
||||||
|
|
||||||
|
let target = match &profile.kind {
|
||||||
|
ConnectionType::Ssh {
|
||||||
|
host, port, user, ..
|
||||||
|
} => {
|
||||||
|
if *port == 22 {
|
||||||
|
format!("{user}@{host}")
|
||||||
|
} else {
|
||||||
|
format!("{user}@{host}:{port}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ConnectionType::Shell {
|
||||||
|
command,
|
||||||
|
sync_args,
|
||||||
|
local_args,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
let merged_args = shell_args(sync_args, local_args);
|
||||||
|
if merged_args.is_empty() {
|
||||||
|
command.clone()
|
||||||
|
} else {
|
||||||
|
format!("{command} {}", merged_args.join(" "))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let auth_state = profile
|
||||||
|
.auth_ref()
|
||||||
|
.and_then(|auth| app.config.credential(auth))
|
||||||
|
.map(|cred| if cred.has_value() { "ready" } else { "empty" })
|
||||||
|
.unwrap_or("none");
|
||||||
|
let auth_color = match auth_state {
|
||||||
|
"ready" => GREEN,
|
||||||
|
"empty" => RED,
|
||||||
|
_ => MUTED,
|
||||||
|
};
|
||||||
|
|
||||||
|
Row::new([
|
||||||
|
Cell::from(format!("{marker} {name}")).style(row_style),
|
||||||
|
Cell::from(type_badge).style(
|
||||||
|
Style::default()
|
||||||
|
.fg(crate::ui::BG)
|
||||||
|
.bg(badge_color)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
),
|
||||||
|
Cell::from(target).style(row_style),
|
||||||
|
Cell::from(auth_state).style(Style::default().fg(auth_color)),
|
||||||
|
])
|
||||||
|
.height(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn quick_key(idx: usize) -> Option<char> {
|
||||||
|
(idx < 9).then(|| (b'1' + idx as u8) as char)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn draw_detail_panel(frame: &mut Frame<'_>, app: &App, area: Rect) {
|
||||||
|
let entries = if app.session.mode == Mode::QuickSelect {
|
||||||
|
app.quick_entries()
|
||||||
|
} else {
|
||||||
|
app.entries()
|
||||||
|
};
|
||||||
|
let Some((name, profile)) = entries.get(app.session.home.selected) else {
|
||||||
|
Paragraph::new("\n Select a connection")
|
||||||
|
.fg(MUTED)
|
||||||
|
.alignment(Alignment::Center)
|
||||||
|
.block(panel("Detail"))
|
||||||
|
.render(area, frame.buffer_mut());
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut lines: Vec<Line> = Vec::new();
|
||||||
|
|
||||||
|
let is_shell = matches!(profile.kind, ConnectionType::Shell { .. });
|
||||||
|
let badge = if is_shell { "[SHL]" } else { "[SSH]" };
|
||||||
|
let badge_color = if is_shell { PURPLE } else { ACCENT };
|
||||||
|
lines.push(Line::raw(""));
|
||||||
|
lines.push(Line::from(vec![
|
||||||
|
Span::raw(" "),
|
||||||
|
badge_span(badge, badge_color),
|
||||||
|
Span::raw(" "),
|
||||||
|
Span::styled((*name).clone(), Style::default().fg(TEXT).bold()),
|
||||||
|
]));
|
||||||
|
lines.push(Line::from(vec![
|
||||||
|
Span::raw(" "),
|
||||||
|
Span::styled(
|
||||||
|
if is_shell {
|
||||||
|
"local command profile"
|
||||||
|
} else {
|
||||||
|
"remote ssh profile"
|
||||||
|
},
|
||||||
|
Style::default().fg(MUTED),
|
||||||
|
),
|
||||||
|
]));
|
||||||
|
lines.push(Line::raw(""));
|
||||||
|
|
||||||
|
match &profile.kind {
|
||||||
|
ConnectionType::Ssh {
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
user,
|
||||||
|
auth_ref,
|
||||||
|
sync,
|
||||||
|
} => {
|
||||||
|
lines.push(detail_text("Target", &format!("{user}@{host}")));
|
||||||
|
lines.push(detail_text("Port", &port.to_string()));
|
||||||
|
lines.push(detail_text("User", user));
|
||||||
|
lines.push(detail_text("Protocol", "ssh"));
|
||||||
|
lines.push(detail_text(
|
||||||
|
"Sync",
|
||||||
|
if *sync { "enabled" } else { "disabled" },
|
||||||
|
));
|
||||||
|
lines.push(detail_text("Source", source_label(profile.source)));
|
||||||
|
lines.push(detail_text("Uses", &profile.usage_count.to_string()));
|
||||||
|
lines.push(Line::raw(""));
|
||||||
|
lines.push(detail_credential_line(app, auth_ref));
|
||||||
|
}
|
||||||
|
ConnectionType::Shell {
|
||||||
|
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);
|
||||||
|
if !merged_args.is_empty() {
|
||||||
|
lines.push(detail_text("Args", &merged_args.join(" ")));
|
||||||
|
}
|
||||||
|
lines.push(detail_text(
|
||||||
|
"Sync",
|
||||||
|
if *sync { "enabled" } else { "disabled" },
|
||||||
|
));
|
||||||
|
lines.push(detail_text("Source", source_label(profile.source)));
|
||||||
|
lines.push(detail_text("Uses", &profile.usage_count.to_string()));
|
||||||
|
lines.push(Line::raw(""));
|
||||||
|
if let Some(ref_val) = auth_ref {
|
||||||
|
lines.push(detail_credential_line(app, ref_val));
|
||||||
|
} else {
|
||||||
|
lines.push(detail_text("Auth", "not required"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !profile.tags.is_empty() {
|
||||||
|
lines.push(Line::raw(""));
|
||||||
|
let mut tag_spans: Vec<Span> = Vec::new();
|
||||||
|
for tag in &profile.tags {
|
||||||
|
if !tag_spans.is_empty() {
|
||||||
|
tag_spans.push(Span::raw(" "));
|
||||||
|
}
|
||||||
|
tag_spans.push(tag_badge(tag));
|
||||||
|
}
|
||||||
|
lines.push(detail_line("Tags", tag_spans));
|
||||||
|
}
|
||||||
|
|
||||||
|
Paragraph::new(lines)
|
||||||
|
.style(Style::default().bg(PANEL_ALT))
|
||||||
|
.block(crate::ui::component::panel_accent("Detail"))
|
||||||
|
.render(area, frame.buffer_mut());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn source_label(source: ConnectionSource) -> &'static str {
|
||||||
|
match source {
|
||||||
|
ConnectionSource::Manual => "manual",
|
||||||
|
ConnectionSource::Imported => "imported",
|
||||||
|
ConnectionSource::Scanned => "scanned",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn detail_text(label: &str, value: &str) -> Line<'static> {
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled(format!(" {:<11}", label), Style::default().fg(MUTED)),
|
||||||
|
Span::styled(value.to_string(), Style::default().fg(TEXT)),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
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),
|
||||||
|
Style::default().fg(MUTED),
|
||||||
|
)];
|
||||||
|
out.extend(spans);
|
||||||
|
Line::from(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn detail_credential_line(app: &App, auth_ref: &str) -> Line<'static> {
|
||||||
|
let (dot_color, status_text) = match app.config.credential(auth_ref) {
|
||||||
|
Some(cred) if cred.has_value() => (GREEN, "set"),
|
||||||
|
Some(_) => (RED, "empty"),
|
||||||
|
None => (RED, "missing"),
|
||||||
|
};
|
||||||
|
let auth_type = match app.config.credential(auth_ref) {
|
||||||
|
Some(CredentialEntry::Password { .. }) => "password",
|
||||||
|
Some(CredentialEntry::PrivateKey { .. }) => "private key",
|
||||||
|
None => "?",
|
||||||
|
};
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled(" Auth ", Style::default().fg(MUTED)),
|
||||||
|
Span::styled("● ", Style::default().fg(dot_color)),
|
||||||
|
Span::styled(format!("{auth_type} "), Style::default().fg(TEXT)),
|
||||||
|
Span::styled(format!("({status_text})"), Style::default().fg(dot_color)),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Key handling ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn handle_home(app: &mut App, key: KeyEvent) -> Result<()> {
|
||||||
|
if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('q') {
|
||||||
|
app.enter_quick_select();
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Char('q') | KeyCode::Esc => app.request_quit(),
|
||||||
|
KeyCode::Tab => app.jump_group(),
|
||||||
|
KeyCode::Down | KeyCode::Char('j') => app.move_selection(1),
|
||||||
|
KeyCode::Up | KeyCode::Char('k') => app.move_selection(-1),
|
||||||
|
KeyCode::Enter => app.connect_selected()?,
|
||||||
|
KeyCode::Char('/') => app.enter_search(),
|
||||||
|
KeyCode::Char('a') => app.new_form(),
|
||||||
|
KeyCode::Char('e') => app.edit_form(),
|
||||||
|
KeyCode::Char('d') => app.enter_delete_confirm_for_selected(),
|
||||||
|
KeyCode::Char('i') => app.enter_import_selector()?,
|
||||||
|
KeyCode::Char('r') => app.refresh_local_shells()?,
|
||||||
|
KeyCode::Char('p') => app.push_sync_with_toast(),
|
||||||
|
KeyCode::Char('P') => app.pull_sync_with_toast(),
|
||||||
|
KeyCode::Char('c') => app.enter_credentials(),
|
||||||
|
KeyCode::Char('s') => app.enter_settings(),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
use crate::app::App;
|
||||||
|
use crate::ui::component::{panel, panel_with_subtitle};
|
||||||
|
use crate::ui::{BLUE, GREEN, MUTED, SELECTED_BG, TEXT};
|
||||||
|
|
||||||
|
use super::{View, scroll_rows};
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use crossterm::event::{KeyCode, KeyEvent};
|
||||||
|
use ratatui::{
|
||||||
|
Frame,
|
||||||
|
layout::{Alignment, Constraint, Rect},
|
||||||
|
style::{Modifier, Style, Stylize},
|
||||||
|
widgets::{Cell, Paragraph, Row, Table, Widget},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub struct ImportView;
|
||||||
|
pub struct ShellImportView;
|
||||||
|
|
||||||
|
impl View for ImportView {
|
||||||
|
fn draw(&self, frame: &mut Frame<'_>, app: &App, area: Rect) {
|
||||||
|
draw_import(frame, app, area);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_key(&self, app: &mut App, key: KeyEvent) -> Result<()> {
|
||||||
|
handle_import(app, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl View for ShellImportView {
|
||||||
|
fn draw(&self, frame: &mut Frame<'_>, app: &App, area: Rect) {
|
||||||
|
draw_shell_import(frame, app, area);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_key(&self, app: &mut App, key: KeyEvent) -> Result<()> {
|
||||||
|
handle_shell_import(app, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Rendering ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn draw_import(frame: &mut Frame<'_>, app: &App, area: Rect) {
|
||||||
|
if app.session.import.candidates.is_empty() {
|
||||||
|
Paragraph::new("\n No importable hosts found in ~/.ssh/config")
|
||||||
|
.fg(MUTED)
|
||||||
|
.alignment(Alignment::Center)
|
||||||
|
.block(panel("SSH Config Import"))
|
||||||
|
.render(area, frame.buffer_mut());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let rows: Vec<Row<'_>> = app
|
||||||
|
.session
|
||||||
|
.import
|
||||||
|
.candidates
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(idx, item)| {
|
||||||
|
let selected_row = idx == app.session.import.cursor;
|
||||||
|
let checked = app
|
||||||
|
.session
|
||||||
|
.import
|
||||||
|
.selected
|
||||||
|
.get(idx)
|
||||||
|
.copied()
|
||||||
|
.unwrap_or(false);
|
||||||
|
let style = if selected_row {
|
||||||
|
Style::default()
|
||||||
|
.bg(SELECTED_BG)
|
||||||
|
.fg(TEXT)
|
||||||
|
.add_modifier(Modifier::BOLD)
|
||||||
|
} else {
|
||||||
|
Style::default().fg(TEXT)
|
||||||
|
};
|
||||||
|
let check_style = if checked {
|
||||||
|
Style::default().fg(GREEN).add_modifier(Modifier::BOLD)
|
||||||
|
} else {
|
||||||
|
Style::default().fg(MUTED)
|
||||||
|
};
|
||||||
|
Row::new([
|
||||||
|
Cell::from(if checked { " [x]" } else { " [ ]" }).style(check_style),
|
||||||
|
Cell::from(item.name.clone()).style(style),
|
||||||
|
Cell::from(format!("{}@{}:{}", item.user, item.host, item.port)).style(style),
|
||||||
|
Cell::from(
|
||||||
|
item.identity_file
|
||||||
|
.as_ref()
|
||||||
|
.map(|p| p.display().to_string())
|
||||||
|
.unwrap_or_else(|| "-".to_string()),
|
||||||
|
)
|
||||||
|
.style(style),
|
||||||
|
])
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let rows = scroll_rows(rows, app.session.import.cursor, area.height);
|
||||||
|
|
||||||
|
let table = Table::new(
|
||||||
|
rows,
|
||||||
|
[
|
||||||
|
Constraint::Length(5),
|
||||||
|
Constraint::Length(24),
|
||||||
|
Constraint::Percentage(35),
|
||||||
|
Constraint::Percentage(45),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.header(
|
||||||
|
Row::new([" Use", "Name", "Target", "Identity File"])
|
||||||
|
.style(Style::default().fg(BLUE).bold()),
|
||||||
|
)
|
||||||
|
.block(panel_with_subtitle(
|
||||||
|
"SSH Config Import",
|
||||||
|
"Space toggle a all A none Enter import Esc cancel",
|
||||||
|
))
|
||||||
|
.column_spacing(2);
|
||||||
|
frame.render_widget(table, area);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_shell_import(frame: &mut Frame<'_>, app: &App, area: Rect) {
|
||||||
|
if app.session.shell_import.candidates.is_empty() {
|
||||||
|
Paragraph::new("\n No new local shells found\n\n Press Esc to return home")
|
||||||
|
.fg(MUTED)
|
||||||
|
.alignment(Alignment::Center)
|
||||||
|
.block(panel("Detected Shells"))
|
||||||
|
.render(area, frame.buffer_mut());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let rows: Vec<Row<'_>> = app
|
||||||
|
.session
|
||||||
|
.shell_import
|
||||||
|
.candidates
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(idx, item)| {
|
||||||
|
let selected_row = idx == app.session.shell_import.cursor;
|
||||||
|
let checked = app
|
||||||
|
.session
|
||||||
|
.shell_import
|
||||||
|
.selected
|
||||||
|
.get(idx)
|
||||||
|
.copied()
|
||||||
|
.unwrap_or(false);
|
||||||
|
let has_conflict = item.conflict.is_some();
|
||||||
|
let style = if selected_row {
|
||||||
|
Style::default()
|
||||||
|
.bg(SELECTED_BG)
|
||||||
|
.fg(TEXT)
|
||||||
|
.add_modifier(Modifier::BOLD)
|
||||||
|
} else if has_conflict {
|
||||||
|
Style::default().fg(MUTED)
|
||||||
|
} else {
|
||||||
|
Style::default().fg(TEXT)
|
||||||
|
};
|
||||||
|
let check_style = if checked {
|
||||||
|
Style::default().fg(GREEN).add_modifier(Modifier::BOLD)
|
||||||
|
} else {
|
||||||
|
Style::default().fg(MUTED)
|
||||||
|
};
|
||||||
|
let status = item
|
||||||
|
.conflict
|
||||||
|
.as_ref()
|
||||||
|
.map(|conflict| format!("conflict: {}", conflict.name))
|
||||||
|
.unwrap_or_else(|| "ready".to_string());
|
||||||
|
Row::new([
|
||||||
|
Cell::from(if checked { " [x]" } else { " [ ]" }).style(check_style),
|
||||||
|
Cell::from(item.name.clone()).style(style),
|
||||||
|
Cell::from(item.path.display().to_string()).style(style),
|
||||||
|
Cell::from(status).style(style),
|
||||||
|
])
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let rows = scroll_rows(rows, app.session.shell_import.cursor, area.height);
|
||||||
|
|
||||||
|
let table = Table::new(
|
||||||
|
rows,
|
||||||
|
[
|
||||||
|
Constraint::Length(5),
|
||||||
|
Constraint::Length(20),
|
||||||
|
Constraint::Percentage(50),
|
||||||
|
Constraint::Percentage(30),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.header(Row::new([" Use", "Name", "Path", "Status"]).style(Style::default().fg(BLUE).bold()))
|
||||||
|
.block(panel_with_subtitle(
|
||||||
|
"Detected Shells",
|
||||||
|
"Space toggle a all A none Enter enable Esc skip",
|
||||||
|
))
|
||||||
|
.column_spacing(2);
|
||||||
|
frame.render_widget(table, area);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Key handling ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn handle_import(app: &mut App, key: KeyEvent) -> Result<()> {
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Esc => app.session.mode = crate::app::Mode::Home,
|
||||||
|
KeyCode::Down | KeyCode::Char('j') => app.move_selection(1),
|
||||||
|
KeyCode::Up | KeyCode::Char('k') => app.move_selection(-1),
|
||||||
|
KeyCode::Char(' ') => {
|
||||||
|
if let Some(v) = app
|
||||||
|
.session
|
||||||
|
.import
|
||||||
|
.selected
|
||||||
|
.get_mut(app.session.import.cursor)
|
||||||
|
{
|
||||||
|
*v = !*v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Char('a') => app.session.import.selected.fill(true),
|
||||||
|
KeyCode::Char('A') => app.session.import.selected.fill(false),
|
||||||
|
KeyCode::Enter => {
|
||||||
|
let picked: Vec<_> = app
|
||||||
|
.session
|
||||||
|
.import
|
||||||
|
.candidates
|
||||||
|
.iter()
|
||||||
|
.zip(&app.session.import.selected)
|
||||||
|
.filter_map(|(item, selected)| selected.then_some(item.clone()))
|
||||||
|
.collect();
|
||||||
|
match crate::import::import_candidates(&mut app.config, &picked) {
|
||||||
|
Ok(count) => app.toast(format!("imported {count} connections"), true),
|
||||||
|
Err(err) => app.toast(err.to_string(), false),
|
||||||
|
}
|
||||||
|
app.session.mode = crate::app::Mode::Home;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_shell_import(app: &mut App, key: KeyEvent) -> Result<()> {
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Esc => app.session.mode = crate::app::Mode::Home,
|
||||||
|
KeyCode::Down | KeyCode::Char('j') => app.move_selection(1),
|
||||||
|
KeyCode::Up | KeyCode::Char('k') => app.move_selection(-1),
|
||||||
|
KeyCode::Char(' ') => {
|
||||||
|
let can_select = app
|
||||||
|
.session
|
||||||
|
.shell_import
|
||||||
|
.candidates
|
||||||
|
.get(app.session.shell_import.cursor)
|
||||||
|
.is_some_and(|candidate| candidate.conflict.is_none());
|
||||||
|
if can_select
|
||||||
|
&& let Some(v) = app
|
||||||
|
.session
|
||||||
|
.shell_import
|
||||||
|
.selected
|
||||||
|
.get_mut(app.session.shell_import.cursor)
|
||||||
|
{
|
||||||
|
*v = !*v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Char('a') => {
|
||||||
|
for (selected, candidate) in app
|
||||||
|
.session
|
||||||
|
.shell_import
|
||||||
|
.selected
|
||||||
|
.iter_mut()
|
||||||
|
.zip(&app.session.shell_import.candidates)
|
||||||
|
{
|
||||||
|
*selected = candidate.conflict.is_none();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Char('A') => app.session.shell_import.selected.fill(false),
|
||||||
|
KeyCode::Enter => {
|
||||||
|
match app.import_selected_shells() {
|
||||||
|
Ok(count) => app.toast(format!("enabled {count} shells"), true),
|
||||||
|
Err(err) => app.toast(err.to_string(), false),
|
||||||
|
}
|
||||||
|
app.session.mode = crate::app::Mode::Home;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
mod cred_form;
|
||||||
|
mod cred_list;
|
||||||
|
mod delete_confirm_view;
|
||||||
|
mod form;
|
||||||
|
mod home_list;
|
||||||
|
mod import;
|
||||||
|
mod quick_select;
|
||||||
|
mod search;
|
||||||
|
mod settings;
|
||||||
|
|
||||||
|
use crate::app::App;
|
||||||
|
use anyhow::Result;
|
||||||
|
use crossterm::event::KeyEvent;
|
||||||
|
use ratatui::{
|
||||||
|
Frame,
|
||||||
|
layout::Rect,
|
||||||
|
widgets::Row,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// A View represents a full screen that handles both rendering and key events.
|
||||||
|
pub trait View {
|
||||||
|
fn draw(&self, frame: &mut Frame<'_>, app: &App, area: Rect);
|
||||||
|
fn handle_key(&self, app: &mut App, key: KeyEvent) -> Result<()>;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub use cred_form::CredFormView;
|
||||||
|
pub use cred_list::CredListView;
|
||||||
|
pub use delete_confirm_view::DeleteConfirmView;
|
||||||
|
pub use form::FormView;
|
||||||
|
pub use home_list::HomeListView;
|
||||||
|
pub use import::{ImportView, ShellImportView};
|
||||||
|
pub use quick_select::QuickSelectView;
|
||||||
|
pub use search::SearchView;
|
||||||
|
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>> {
|
||||||
|
let visible = area_height.saturating_sub(3) as usize; // 2 borders + 1 header
|
||||||
|
let total = rows.len();
|
||||||
|
if visible == 0 || total <= visible {
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
let scroll = if selected < visible / 2 {
|
||||||
|
0
|
||||||
|
} else if selected + visible / 2 >= total {
|
||||||
|
total.saturating_sub(visible)
|
||||||
|
} else {
|
||||||
|
selected - visible / 2
|
||||||
|
};
|
||||||
|
rows.into_iter().skip(scroll).take(visible).collect()
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
use crate::app::{App, Mode};
|
||||||
|
|
||||||
|
use super::View;
|
||||||
|
use super::home_list::HomeListView;
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||||
|
use ratatui::{Frame, layout::Rect};
|
||||||
|
|
||||||
|
pub struct QuickSelectView;
|
||||||
|
|
||||||
|
impl View for QuickSelectView {
|
||||||
|
fn draw(&self, frame: &mut Frame<'_>, app: &App, area: Rect) {
|
||||||
|
HomeListView.draw(frame, app, area);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_key(&self, app: &mut App, key: KeyEvent) -> Result<()> {
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Esc => app.session.mode = Mode::Home,
|
||||||
|
KeyCode::Tab => {
|
||||||
|
app.session.home.quick_sort = app.session.home.quick_sort.next();
|
||||||
|
app.toast(
|
||||||
|
format!(
|
||||||
|
"quick select sorted by {}",
|
||||||
|
app.session.home.quick_sort.label()
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
KeyCode::Char(c)
|
||||||
|
if key.modifiers.is_empty() || key.modifiers == KeyModifiers::SHIFT =>
|
||||||
|
{
|
||||||
|
if !('1'..='9').contains(&c) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let idx = (c as u8 - b'1') as usize;
|
||||||
|
let entries = app.quick_entries();
|
||||||
|
if let Some((name, _)) = entries.get(idx) {
|
||||||
|
let name = (*name).clone();
|
||||||
|
if let Some(home_idx) = app
|
||||||
|
.entries()
|
||||||
|
.iter()
|
||||||
|
.position(|(entry_name, _)| entry_name.as_str() == name)
|
||||||
|
{
|
||||||
|
app.session.home.selected = home_idx;
|
||||||
|
}
|
||||||
|
app.record_use(&name)?;
|
||||||
|
crate::connection::connect(&name, &app.config)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
use crate::app::{App, Mode};
|
||||||
|
|
||||||
|
use super::View;
|
||||||
|
use super::home_list::HomeListView;
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||||
|
use ratatui::{Frame, layout::Rect};
|
||||||
|
|
||||||
|
pub struct SearchView;
|
||||||
|
|
||||||
|
impl View for SearchView {
|
||||||
|
fn draw(&self, frame: &mut Frame<'_>, app: &App, area: Rect) {
|
||||||
|
HomeListView.draw(frame, app, area);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_key(&self, app: &mut App, key: KeyEvent) -> Result<()> {
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Esc | KeyCode::Enter => {
|
||||||
|
app.session.mode = Mode::Home;
|
||||||
|
}
|
||||||
|
KeyCode::Char('j') => app.move_selection(1),
|
||||||
|
KeyCode::Char('k') => app.move_selection(-1),
|
||||||
|
KeyCode::Down => app.move_selection(1),
|
||||||
|
KeyCode::Up => app.move_selection(-1),
|
||||||
|
KeyCode::Backspace => {
|
||||||
|
app.session.home.search.pop();
|
||||||
|
app.session.home.selected = 0;
|
||||||
|
}
|
||||||
|
KeyCode::Char(c)
|
||||||
|
if key.modifiers.is_empty() || key.modifiers == KeyModifiers::SHIFT =>
|
||||||
|
{
|
||||||
|
app.session.home.search.push(c);
|
||||||
|
app.session.home.selected = 0;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
use crate::app::{App, Mode, SettingsField, SettingsState, TextEditing, char_len};
|
||||||
|
use crate::config::SyncBackend;
|
||||||
|
use crate::ui::component::{FormRow, badge_span};
|
||||||
|
use crate::ui::{ACCENT, GREEN, ORANGE, PURPLE, RED};
|
||||||
|
|
||||||
|
use super::View;
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||||
|
use ratatui::{Frame, layout::Rect};
|
||||||
|
|
||||||
|
pub struct SettingsView;
|
||||||
|
|
||||||
|
impl View for SettingsView {
|
||||||
|
fn draw(&self, frame: &mut Frame<'_>, app: &App, area: Rect) {
|
||||||
|
let settings = &app.session.settings;
|
||||||
|
let fields = settings.visible_fields();
|
||||||
|
let mut rows: Vec<FormRow> = Vec::new();
|
||||||
|
|
||||||
|
for (i, &field) in fields.iter().enumerate() {
|
||||||
|
let active = settings.active == field;
|
||||||
|
let label = field.label().to_string();
|
||||||
|
|
||||||
|
if i == 2 || matches!(field, SettingsField::SyncUsage) {
|
||||||
|
rows.push(FormRow::Separator);
|
||||||
|
}
|
||||||
|
|
||||||
|
if field.is_toggle() {
|
||||||
|
let badge = match field {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => unreachable!(),
|
||||||
|
};
|
||||||
|
rows.push(FormRow::Toggle {
|
||||||
|
label,
|
||||||
|
active,
|
||||||
|
badge,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
let raw = settings.field_text(field).to_string();
|
||||||
|
let is_secret = matches!(
|
||||||
|
field,
|
||||||
|
SettingsField::SyncPassword
|
||||||
|
| SettingsField::WebdavPassword
|
||||||
|
| SettingsField::S3SecretKey
|
||||||
|
);
|
||||||
|
let (display, secret_cursor) = if is_secret {
|
||||||
|
if raw.is_empty() {
|
||||||
|
(String::new(), settings.cursor)
|
||||||
|
} else if active {
|
||||||
|
let d: String = "*".repeat(raw.chars().count());
|
||||||
|
(d, settings.cursor)
|
||||||
|
} else {
|
||||||
|
("<set>".into(), 0)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
(raw, settings.cursor)
|
||||||
|
};
|
||||||
|
let cursor = if active {
|
||||||
|
secret_cursor.min(char_len(&display))
|
||||||
|
} else {
|
||||||
|
char_len(&display)
|
||||||
|
};
|
||||||
|
let placeholder = if is_secret || matches!(field, SettingsField::GistId) {
|
||||||
|
Some("<not set>".to_string())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
rows.push(FormRow::Text {
|
||||||
|
label,
|
||||||
|
active,
|
||||||
|
display,
|
||||||
|
cursor,
|
||||||
|
placeholder,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let subtitle = "Tab ▽ ↑ △ Enter save/toggle Esc cancel Ctrl+U clear";
|
||||||
|
crate::ui::component::draw_form_list(frame, area, "Settings", subtitle, rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_key(&self, app: &mut App, key: KeyEvent) -> Result<()> {
|
||||||
|
handle_settings(app, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Key handling ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn handle_settings(app: &mut App, key: KeyEvent) -> Result<()> {
|
||||||
|
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
|
||||||
|
let settings = &mut app.session.settings;
|
||||||
|
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Esc => {
|
||||||
|
app.session.mode = Mode::Home;
|
||||||
|
}
|
||||||
|
|
||||||
|
KeyCode::Tab | KeyCode::Down => {
|
||||||
|
settings.next_field();
|
||||||
|
}
|
||||||
|
KeyCode::BackTab | KeyCode::Up => {
|
||||||
|
settings.prev_field();
|
||||||
|
}
|
||||||
|
|
||||||
|
KeyCode::Enter => {
|
||||||
|
if settings.active.is_toggle() {
|
||||||
|
settings_toggle(settings);
|
||||||
|
} else {
|
||||||
|
match app.save_settings() {
|
||||||
|
Ok(()) => app.toast("settings saved", true),
|
||||||
|
Err(err) => app.toast(err.to_string(), false),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
KeyCode::Backspace if settings.active.is_text() => {
|
||||||
|
settings.delete_char();
|
||||||
|
}
|
||||||
|
KeyCode::Delete if settings.active.is_text() => {
|
||||||
|
settings.delete_next_char();
|
||||||
|
}
|
||||||
|
KeyCode::Left if settings.active.is_text() => {
|
||||||
|
settings.move_cursor_left();
|
||||||
|
}
|
||||||
|
KeyCode::Right if settings.active.is_text() => {
|
||||||
|
settings.move_cursor_right();
|
||||||
|
}
|
||||||
|
KeyCode::Home if settings.active.is_text() => {
|
||||||
|
settings.cursor_home();
|
||||||
|
}
|
||||||
|
KeyCode::End if settings.active.is_text() => {
|
||||||
|
settings.cursor_end();
|
||||||
|
}
|
||||||
|
KeyCode::Char('a') if ctrl && settings.active.is_text() => {
|
||||||
|
settings.cursor_home();
|
||||||
|
}
|
||||||
|
KeyCode::Char('e') if ctrl && settings.active.is_text() => {
|
||||||
|
settings.cursor_end();
|
||||||
|
}
|
||||||
|
KeyCode::Char('u') if ctrl && settings.active.is_text() => {
|
||||||
|
settings.clear_field();
|
||||||
|
}
|
||||||
|
|
||||||
|
KeyCode::Char(' ') => {
|
||||||
|
if settings.active.is_toggle() {
|
||||||
|
settings_toggle(settings);
|
||||||
|
} else if settings.active.is_text() {
|
||||||
|
settings.insert_char(' ');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
KeyCode::Char(c) if !ctrl && settings.active.is_text() => {
|
||||||
|
settings.insert_char(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn settings_toggle(settings: &mut SettingsState) {
|
||||||
|
match settings.active {
|
||||||
|
SettingsField::Backend => {
|
||||||
|
settings.backend = match settings.backend {
|
||||||
|
SyncBackend::Gist => SyncBackend::Webdav,
|
||||||
|
SyncBackend::Webdav => SyncBackend::S3,
|
||||||
|
SyncBackend::S3 => SyncBackend::Gist,
|
||||||
|
};
|
||||||
|
settings.ensure_active_visible();
|
||||||
|
}
|
||||||
|
SettingsField::SyncUsage => {
|
||||||
|
settings.sync_usage = !settings.sync_usage;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
use crate::config::SshellConfig;
|
||||||
|
use crate::gist::{PullStrategy, build_sync_payload, merge_remote};
|
||||||
|
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> {
|
||||||
|
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 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))
|
||||||
|
.header(CONTENT_TYPE, "text/plain")
|
||||||
|
.header(ACCEPT, "*/*")
|
||||||
|
.body(content)
|
||||||
|
.send()?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
bail!("sync push 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn webdav_file_url(cfg: &SshellConfig) -> Result<String> {
|
||||||
|
let base = cfg
|
||||||
|
.settings
|
||||||
|
.webdav_url
|
||||||
|
.as_deref()
|
||||||
|
.context("webdav_url not set")?;
|
||||||
|
let base = base.trim_end_matches('/');
|
||||||
|
Ok(format!("{base}/{FILE_NAME}"))
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user