Initial commit: sshell project

This commit is contained in:
2026-05-26 20:13:31 +08:00
commit 35470fc277
49 changed files with 9483 additions and 0 deletions
+17
View File
@@ -0,0 +1,17 @@
/target
debug/
*.pdb
*.rlib
*.d
.idea/
.vscode/
*.swp
*.swo
*~
.DS_Store
Thumbs.db
.env
Generated
+3473
View File
File diff suppressed because it is too large Load Diff
+24
View File
@@ -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
View File
@@ -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;
}
}
+115
View File
@@ -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
View File
@@ -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;
}
}
+212
View File
@@ -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"),
}
}
+72
View File
@@ -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
View File
@@ -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(),
});
}
}
+303
View File
@@ -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"
);
}
}
+189
View File
@@ -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;
}
}
+69
View File
@@ -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(())
}
}
+41
View File
@@ -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)
}
}
+318
View File
@@ -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
View File
@@ -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
View File
@@ -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
}
+108
View File
@@ -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
View File
@@ -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}"))
}
+109
View File
@@ -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
View File
@@ -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))
}
+9
View File
@@ -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
View File
@@ -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()
}
+313
View File
@@ -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,
&region,
&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,
&region,
&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)
}
+84
View File
@@ -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),
}
}
+16
View File
@@ -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),
)
}
+33
View File
@@ -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());
}
+125
View File
@@ -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());
}
+33
View File
@@ -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());
}
+12
View File
@@ -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),
}
}
+15
View File
@@ -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;
+29
View File
@@ -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))
}
+26
View File
@@ -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());
}
+15
View File
@@ -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"),
);
}
+118
View File
@@ -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(),
)
}
+163
View File
@@ -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))
}
+7
View File
@@ -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;
+5
View File
@@ -0,0 +1,5 @@
pub mod common;
pub mod custom;
pub use common::*;
pub use custom::*;
+99
View File
@@ -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);
}
}
+146
View File
@@ -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,
};
}
}
+137
View File
@@ -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(())
}
+31
View File
@@ -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(())
}
}
+186
View File
@@ -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;
}
_ => {}
}
}
+464
View File
@@ -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(())
}
+274
View File
@@ -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(())
}
+52
View File
@@ -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()
}
+55
View File
@@ -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(())
}
}
+40
View File
@@ -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(())
}
}
+187
View File
@@ -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;
}
_ => {}
}
}
+83
View File
@@ -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}"))
}