refactor: reorganize modules into directories, consolidate views

This commit is contained in:
2026-05-27 00:11:00 +08:00
parent e36b393a62
commit 61ad218414
23 changed files with 763 additions and 768 deletions
+118
View File
@@ -107,3 +107,121 @@ impl TextEditing for CredFormState {
self.cursor = pos; 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(())
}
}
-115
View File
@@ -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
View File
@@ -252,3 +252,222 @@ impl TextEditing for FormState {
self.cursor = pos; 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"),
}
}
-217
View File
@@ -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
View File
@@ -65,9 +65,9 @@ impl App {
pub fn push_sync_with_toast(&mut self) { pub fn push_sync_with_toast(&mut self) {
let result = match self.config.settings.backend { let result = match self.config.settings.backend {
SyncBackend::Gist => crate::gist::push(&mut self.config).map(|id| format!("pushed ({id})")), SyncBackend::Gist => crate::sync::gist::push(&mut self.config).map(|id| format!("pushed ({id})")),
SyncBackend::Webdav => crate::webdav::push(&mut self.config).map(|_| "pushed".to_string()), SyncBackend::Webdav => crate::sync::webdav::push(&mut self.config).map(|_| "pushed".to_string()),
SyncBackend::S3 => crate::s3::push(&mut self.config).map(|_| "pushed".to_string()), SyncBackend::S3 => crate::sync::s3::push(&mut self.config).map(|_| "pushed".to_string()),
}; };
match result { match result {
Ok(msg) => self.toast(msg, true), Ok(msg) => self.toast(msg, true),
@@ -77,9 +77,9 @@ impl App {
pub fn pull_sync_with_toast(&mut self) { pub fn pull_sync_with_toast(&mut self) {
let result = match self.config.settings.backend { let result = match self.config.settings.backend {
SyncBackend::Gist => crate::gist::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::webdav::pull_with_strategy(&mut self.config, crate::gist::PullStrategy::Merge), SyncBackend::Webdav => crate::sync::webdav::pull_with_strategy(&mut self.config, crate::sync::gist::PullStrategy::Merge),
SyncBackend::S3 => crate::s3::pull_with_strategy(&mut self.config, crate::gist::PullStrategy::Merge), SyncBackend::S3 => crate::sync::s3::pull_with_strategy(&mut self.config, crate::sync::gist::PullStrategy::Merge),
}; };
match result { match result {
Ok(count) => self.toast(format!("pulled {count} items"), true), Ok(count) => self.toast(format!("pulled {count} items"), true),
-3
View File
@@ -1,8 +1,5 @@
mod cred_ops;
mod form_ops;
mod home_ops; mod home_ops;
mod profile_ext; mod profile_ext;
mod settings_ops;
mod types; mod types;
pub mod cred; pub mod cred;
+71
View File
@@ -187,3 +187,74 @@ impl TextEditing for SettingsState {
self.cursor = pos; 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(())
}
}
-69
View File
@@ -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
View File
@@ -1,5 +1,6 @@
use crate::config::{ConnectionType, CredentialEntry, SshellConfig, SyncBackend, config_path, find_binary}; 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 anyhow::{Context, Result, bail};
use clap::{Parser, Subcommand}; use clap::{Parser, Subcommand};
use std::path::Path; use std::path::Path;
+35
View File
@@ -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);
}
}
}
+192
View File
@@ -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))
}
+5 -219
View File
@@ -1,8 +1,11 @@
use anyhow::{Context, Result, bail}; use anyhow::{Context, Result};
use indexmap::IndexMap; use indexmap::IndexMap;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::fs; use std::fs;
use std::path::{Path, PathBuf}; use std::path::PathBuf;
mod config_migrate;
mod config_shell;
const CONFIG_VERSION: u32 = 2; const CONFIG_VERSION: u32 = 2;
@@ -195,223 +198,6 @@ impl SshellConfig {
.unwrap_or(0) .unwrap_or(0)
+ 1 + 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 { impl CredentialEntry {
+1 -3
View File
@@ -2,8 +2,6 @@ pub mod app;
pub mod cli; pub mod cli;
pub mod config; pub mod config;
pub mod connection; pub mod connection;
pub mod gist;
pub mod import; pub mod import;
pub mod s3; pub mod sync;
pub mod ui; pub mod ui;
pub mod webdav;
+3
View File
@@ -0,0 +1,3 @@
pub mod gist;
pub mod s3;
pub mod webdav;
+1 -1
View File
@@ -1,5 +1,5 @@
use crate::config::SshellConfig; 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 anyhow::{Context, Result, bail};
use hmac::{Hmac, Mac}; use hmac::{Hmac, Mac};
use reqwest::blocking::Client; use reqwest::blocking::Client;
+1 -1
View File
@@ -1,5 +1,5 @@
use crate::config::SshellConfig; 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 anyhow::{Context, Result, bail};
use reqwest::blocking::Client; use reqwest::blocking::Client;
use reqwest::header::{ACCEPT, CONTENT_TYPE}; use reqwest::header::{ACCEPT, CONTENT_TYPE};
-31
View File
@@ -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(())
}
}
+108
View File
@@ -458,3 +458,111 @@ fn handle_home(app: &mut App, key: KeyEvent) -> Result<()> {
} }
Ok(()) 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
View File
@@ -1,12 +1,9 @@
mod action_menu; mod action_menu;
mod cred_form; mod cred_form;
mod cred_list; mod cred_list;
mod delete_confirm_view;
mod form; mod form;
mod home_list; mod home_list;
mod import; mod import;
mod quick_select;
mod search;
mod settings; mod settings;
use crate::app::App; use crate::app::App;
@@ -27,12 +24,9 @@ pub trait View {
pub use action_menu::ActionMenuView; pub use action_menu::ActionMenuView;
pub use cred_form::CredFormView; pub use cred_form::CredFormView;
pub use cred_list::CredListView; pub use cred_list::CredListView;
pub use delete_confirm_view::DeleteConfirmView;
pub use form::FormView; pub use form::FormView;
pub use home_list::HomeListView; pub use home_list::{DeleteConfirmView, HomeListView, QuickSelectView, SearchView};
pub use import::ImportView; pub use import::ImportView;
pub use quick_select::QuickSelectView;
pub use search::SearchView;
pub use settings::SettingsView; pub use settings::SettingsView;
/// Scroll a 1:1 row list so the selected index stays visible. /// Scroll a 1:1 row list so the selected index stays visible.
-55
View File
@@ -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(())
}
}
-40
View File
@@ -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(())
}
}