refactor: reorganize modules into directories, consolidate views
This commit is contained in:
+118
@@ -107,3 +107,121 @@ impl TextEditing for CredFormState {
|
||||
self.cursor = pos;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Operations ──────────────────────────────────────────────────
|
||||
|
||||
use crate::config::CredentialEntry;
|
||||
use anyhow::{Result, bail};
|
||||
|
||||
use super::{App, Mode};
|
||||
|
||||
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 = 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 = 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(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
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(())
|
||||
}
|
||||
}
|
||||
+219
@@ -252,3 +252,222 @@ impl TextEditing for FormState {
|
||||
self.cursor = pos;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Operations ──────────────────────────────────────────────────
|
||||
|
||||
use crate::config::ConnectionSource;
|
||||
use anyhow::{Result, bail};
|
||||
|
||||
use super::profile_ext::{non_empty, resolve_secret, shell_name_for_command, split_args};
|
||||
use super::{App, Mode};
|
||||
|
||||
impl App {
|
||||
pub fn new_form(&mut self) {
|
||||
self.session.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 = 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");
|
||||
}
|
||||
let name = if self.session.form.is_shell && !name.starts_with('$') {
|
||||
format!("${name}")
|
||||
} else {
|
||||
name
|
||||
};
|
||||
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"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,217 +0,0 @@
|
||||
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");
|
||||
}
|
||||
let name = if self.session.form.is_shell && !name.starts_with('$') {
|
||||
format!("${name}")
|
||||
} else {
|
||||
name
|
||||
};
|
||||
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"),
|
||||
}
|
||||
}
|
||||
+6
-6
@@ -65,9 +65,9 @@ impl App {
|
||||
|
||||
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()),
|
||||
SyncBackend::Gist => crate::sync::gist::push(&mut self.config).map(|id| format!("pushed ({id})")),
|
||||
SyncBackend::Webdav => crate::sync::webdav::push(&mut self.config).map(|_| "pushed".to_string()),
|
||||
SyncBackend::S3 => crate::sync::s3::push(&mut self.config).map(|_| "pushed".to_string()),
|
||||
};
|
||||
match result {
|
||||
Ok(msg) => self.toast(msg, true),
|
||||
@@ -77,9 +77,9 @@ impl App {
|
||||
|
||||
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),
|
||||
SyncBackend::Gist => crate::sync::gist::pull_with_strategy(&mut self.config, crate::sync::gist::PullStrategy::Merge),
|
||||
SyncBackend::Webdav => crate::sync::webdav::pull_with_strategy(&mut self.config, crate::sync::gist::PullStrategy::Merge),
|
||||
SyncBackend::S3 => crate::sync::s3::pull_with_strategy(&mut self.config, crate::sync::gist::PullStrategy::Merge),
|
||||
};
|
||||
match result {
|
||||
Ok(count) => self.toast(format!("pulled {count} items"), true),
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
mod cred_ops;
|
||||
mod form_ops;
|
||||
mod home_ops;
|
||||
mod profile_ext;
|
||||
mod settings_ops;
|
||||
mod types;
|
||||
|
||||
pub mod cred;
|
||||
|
||||
@@ -187,3 +187,74 @@ impl TextEditing for SettingsState {
|
||||
self.cursor = pos;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Operations ──────────────────────────────────────────────────
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
use super::{App, Mode, 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(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
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(())
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -1,5 +1,6 @@
|
||||
use crate::config::{ConnectionType, CredentialEntry, SshellConfig, SyncBackend, config_path, find_binary};
|
||||
use crate::{connection, gist, import, s3, ui, webdav};
|
||||
use crate::sync::{gist, s3, webdav};
|
||||
use crate::{connection, import, ui};
|
||||
use anyhow::{Context, Result, bail};
|
||||
use clap::{Parser, Subcommand};
|
||||
use std::path::Path;
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
use super::{ConnectionType, CredentialEntry};
|
||||
use std::fs;
|
||||
|
||||
impl super::SshellConfig {
|
||||
pub(super) 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 = super::expand_user_path(&p);
|
||||
if let Ok(content) = fs::read_to_string(&expanded) {
|
||||
*value = Some(content);
|
||||
}
|
||||
}
|
||||
*path = None;
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn migrate_shell_prefix(&mut self) {
|
||||
let keys: Vec<String> = self
|
||||
.connections
|
||||
.iter()
|
||||
.filter(|(key, profile)| {
|
||||
matches!(&profile.kind, ConnectionType::Shell { .. }) && !key.starts_with('$')
|
||||
})
|
||||
.map(|(key, _)| key.clone())
|
||||
.collect();
|
||||
for key in keys {
|
||||
self.connections.shift_remove(&key);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
use super::{ConnectionProfile, ConnectionSource, ConnectionType, ShellCandidate, ShellScanConflict};
|
||||
use anyhow::{Result, bail};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
impl super::SshellConfig {
|
||||
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(&format!("${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<()> {
|
||||
let key = format!("${}", candidate.name);
|
||||
if candidate.conflict.is_some() || self.connections.contains_key(&key) {
|
||||
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(
|
||||
key,
|
||||
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(())
|
||||
}
|
||||
}
|
||||
|
||||
#[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) = super::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))
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
use anyhow::{Context, Result, bail};
|
||||
use anyhow::{Context, Result};
|
||||
use indexmap::IndexMap;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::path::PathBuf;
|
||||
|
||||
mod config_migrate;
|
||||
mod config_shell;
|
||||
|
||||
const CONFIG_VERSION: u32 = 2;
|
||||
|
||||
@@ -195,223 +198,6 @@ impl SshellConfig {
|
||||
.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(&format!("${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<()> {
|
||||
let key = format!("${}", candidate.name);
|
||||
if candidate.conflict.is_some() || self.connections.contains_key(&key) {
|
||||
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(
|
||||
key,
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
fn migrate_shell_prefix(&mut self) {
|
||||
let keys: Vec<String> = self
|
||||
.connections
|
||||
.iter()
|
||||
.filter(|(key, profile)| {
|
||||
matches!(&profile.kind, ConnectionType::Shell { .. }) && !key.starts_with('$')
|
||||
})
|
||||
.map(|(key, _)| key.clone())
|
||||
.collect();
|
||||
for key in keys {
|
||||
self.connections.shift_remove(&key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[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 {
|
||||
+1
-3
@@ -2,8 +2,6 @@ pub mod app;
|
||||
pub mod cli;
|
||||
pub mod config;
|
||||
pub mod connection;
|
||||
pub mod gist;
|
||||
pub mod import;
|
||||
pub mod s3;
|
||||
pub mod sync;
|
||||
pub mod ui;
|
||||
pub mod webdav;
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod gist;
|
||||
pub mod s3;
|
||||
pub mod webdav;
|
||||
@@ -1,5 +1,5 @@
|
||||
use crate::config::SshellConfig;
|
||||
use crate::gist::{PullStrategy, build_sync_payload, merge_remote};
|
||||
use super::gist::{PullStrategy, build_sync_payload, merge_remote};
|
||||
use anyhow::{Context, Result, bail};
|
||||
use hmac::{Hmac, Mac};
|
||||
use reqwest::blocking::Client;
|
||||
@@ -1,5 +1,5 @@
|
||||
use crate::config::SshellConfig;
|
||||
use crate::gist::{PullStrategy, build_sync_payload, merge_remote};
|
||||
use super::gist::{PullStrategy, build_sync_payload, merge_remote};
|
||||
use anyhow::{Context, Result, bail};
|
||||
use reqwest::blocking::Client;
|
||||
use reqwest::header::{ACCEPT, CONTENT_TYPE};
|
||||
@@ -1,31 +0,0 @@
|
||||
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(())
|
||||
}
|
||||
}
|
||||
@@ -458,3 +458,111 @@ fn handle_home(app: &mut App, key: KeyEvent) -> Result<()> {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── Search View ──────────────────────────────────────────────────
|
||||
|
||||
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(())
|
||||
}
|
||||
}
|
||||
|
||||
// ── Quick Select View ────────────────────────────────────────────
|
||||
|
||||
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(())
|
||||
}
|
||||
}
|
||||
|
||||
// ── Delete Confirm View ──────────────────────────────────────────
|
||||
|
||||
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(())
|
||||
}
|
||||
}
|
||||
|
||||
+1
-7
@@ -1,12 +1,9 @@
|
||||
mod action_menu;
|
||||
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;
|
||||
@@ -27,12 +24,9 @@ pub trait View {
|
||||
pub use action_menu::ActionMenuView;
|
||||
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 home_list::{DeleteConfirmView, HomeListView, QuickSelectView, SearchView};
|
||||
pub use import::ImportView;
|
||||
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.
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
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(())
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
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(())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user