use std::path::PathBuf;
use crate::fs_util::atomic_write;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SourceKind {
Host,
Tunnel,
Container,
Snippet,
Action,
}
impl SourceKind {
pub fn section_label(self) -> &'static str {
match self {
Self::Host => "HOSTS",
Self::Tunnel => "TUNNELS",
Self::Container => "CONTAINERS",
Self::Snippet => "SNIPPETS",
Self::Action => "ACTIONS",
}
}
pub fn render_order() -> [Self; 5] {
[
Self::Host,
Self::Tunnel,
Self::Container,
Self::Snippet,
Self::Action,
]
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum JumpHit {
Action(JumpAction),
Host(HostHit),
Tunnel(TunnelHit),
Container(ContainerHit),
Snippet(SnippetHit),
}
impl JumpHit {
pub fn kind(&self) -> SourceKind {
match self {
Self::Action(_) => SourceKind::Action,
Self::Host(_) => SourceKind::Host,
Self::Tunnel(_) => SourceKind::Tunnel,
Self::Container(_) => SourceKind::Container,
Self::Snippet(_) => SourceKind::Snippet,
}
}
pub fn haystacks(&self) -> Vec<&str> {
match self {
Self::Action(a) => {
let mut v = Vec::with_capacity(2 + a.aliases.len());
v.push(a.label);
v.push(a.key_str);
for alias in a.aliases {
v.push(*alias);
}
v
}
Self::Host(h) => {
let mut v = Vec::with_capacity(7 + h.tags.len());
v.push(h.alias.as_str());
v.push(h.hostname.as_str());
if let Some(p) = &h.provider {
v.push(p.as_str());
}
for t in &h.tags {
v.push(t.as_str());
}
if !h.user.is_empty() {
v.push(h.user.as_str());
}
if !h.identity_file.is_empty() {
v.push(h.identity_file.as_str());
}
if !h.proxy_jump.is_empty() {
v.push(h.proxy_jump.as_str());
}
if let Some(role) = &h.vault_ssh {
v.push(role.as_str());
}
v
}
Self::Tunnel(t) => vec![t.alias.as_str(), t.destination.as_str(), &t.bind_port_str],
Self::Container(c) => vec![
c.container_name.as_str(),
c.alias.as_str(),
c.container_id.as_str(),
],
Self::Snippet(s) => vec![s.name.as_str(), s.command_preview.as_str()],
}
}
pub fn identity(&self) -> RecentRef {
match self {
Self::Action(a) => RecentRef::new(SourceKind::Action, a.key.to_string()),
Self::Host(h) => RecentRef::new(SourceKind::Host, h.alias.clone()),
Self::Tunnel(t) => {
RecentRef::new(SourceKind::Tunnel, format!("{}:{}", t.alias, t.bind_port))
}
Self::Container(c) => RecentRef::new(
SourceKind::Container,
format!("{}/{}", c.alias, c.container_name),
),
Self::Snippet(s) => RecentRef::new(SourceKind::Snippet, s.name.clone()),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct JumpAction {
pub key: char,
pub key_str: &'static str,
pub label: &'static str,
pub aliases: &'static [&'static str],
pub target: JumpActionTarget,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum JumpActionTarget {
Hosts,
Tunnels,
Containers,
Keys,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HostHit {
pub alias: String,
pub hostname: String,
pub tags: Vec<String>,
pub provider: Option<String>,
pub user: String,
pub identity_file: String,
pub proxy_jump: String,
pub vault_ssh: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TunnelHit {
pub alias: String,
pub bind_port: u16,
pub bind_port_str: String,
pub destination: String,
pub active: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ContainerHit {
pub alias: String,
pub container_name: String,
pub container_id: String,
pub state: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SnippetHit {
pub name: String,
pub command_preview: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
pub struct RecentRef {
pub kind: SourceKind,
pub key: String,
}
impl RecentRef {
pub fn new(kind: SourceKind, key: String) -> Self {
Self { kind, key }
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct RecentEntry {
#[serde(flatten)]
pub target: RecentRef,
pub last_used_unix: i64,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct RecentsFile {
pub version: u32,
pub entries: Vec<RecentEntry>,
}
impl Default for RecentsFile {
fn default() -> Self {
Self {
version: 1,
entries: Vec::new(),
}
}
}
const RECENTS_VERSION: u32 = 1;
const RECENTS_CAP: usize = 50;
pub fn recents_path() -> Option<PathBuf> {
if let Some(p) = recents_path_override() {
return Some(p);
}
let home = dirs::home_dir()?;
Some(home.join(".purple").join("recents.json"))
}
#[cfg(test)]
pub mod test_path {
use std::cell::RefCell;
use std::path::PathBuf;
thread_local! {
static OVERRIDE: RefCell<Option<PathBuf>> = const { RefCell::new(None) };
}
pub fn set(path: PathBuf) {
OVERRIDE.with(|cell| *cell.borrow_mut() = Some(path));
}
pub fn clear() {
OVERRIDE.with(|cell| *cell.borrow_mut() = None);
}
pub fn get() -> Option<PathBuf> {
OVERRIDE.with(|cell| cell.borrow().clone())
}
}
#[cfg(test)]
fn recents_path_override() -> Option<PathBuf> {
test_path::get()
}
#[cfg(not(test))]
fn recents_path_override() -> Option<PathBuf> {
None
}
pub fn load_recents() -> RecentsFile {
#[cfg(test)]
{
if test_path::get().is_none() {
return RecentsFile::default();
}
}
let Some(path) = recents_path() else {
return RecentsFile::default();
};
let bytes = match std::fs::read(&path) {
Ok(b) => b,
Err(_) => return RecentsFile::default(),
};
serde_json::from_slice(&bytes).unwrap_or_default()
}
pub fn save_recents(file: &RecentsFile) -> std::io::Result<()> {
#[cfg(test)]
{
if test_path::get().is_none() {
return Ok(());
}
}
let Some(path) = recents_path() else {
return Ok(());
};
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let bytes = serde_json::to_vec_pretty(file).map_err(std::io::Error::other)?;
atomic_write(&path, &bytes)
}
pub fn rename_host_recent(file: &mut RecentsFile, old_alias: &str, new_alias: &str) -> bool {
if old_alias == new_alias {
return false;
}
let old_idx = file
.entries
.iter()
.position(|e| e.target.kind == SourceKind::Host && e.target.key == old_alias);
let Some(old_idx) = old_idx else {
return false;
};
let new_idx = file
.entries
.iter()
.position(|e| e.target.kind == SourceKind::Host && e.target.key == new_alias);
if let Some(new_idx) = new_idx {
let drop_idx =
if file.entries[old_idx].last_used_unix >= file.entries[new_idx].last_used_unix {
new_idx
} else {
old_idx
};
let keep_idx = if drop_idx == new_idx {
old_idx
} else {
new_idx
};
file.entries[keep_idx].target.key = new_alias.to_string();
file.entries.remove(drop_idx);
} else {
file.entries[old_idx].target.key = new_alias.to_string();
}
file.version = RECENTS_VERSION;
true
}
pub fn touch_recent(file: &mut RecentsFile, target: RecentRef) {
file.version = RECENTS_VERSION;
file.entries.retain(|e| e.target != target);
let now = current_unix_ts();
file.entries.insert(
0,
RecentEntry {
target,
last_used_unix: now,
},
);
if file.entries.len() > RECENTS_CAP {
file.entries.truncate(RECENTS_CAP);
}
}
fn current_unix_ts() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum JumpMode {
#[default]
Hosts,
Tunnels,
Containers,
Keys,
}
pub const JUMP_EMPTY_STATE_ACTIONS_CAP: usize = 6;
const EMPTY_STATE_TAB_BIAS: usize = 3;
const CATEGORY_PRIORITY: &[&str] = &[
"Hosts",
"Tunnels",
"Containers",
"Files",
"Vault",
"Keys",
"Providers",
"Snippets",
"Clipboard",
"Settings",
"Help",
];
pub(crate) const PALETTE_ACTION_FLOOR: u32 = 30;
fn round_robin_actions_by_category(actions: impl Iterator<Item = JumpAction>) -> Vec<JumpHit> {
let mut buckets: Vec<(String, Vec<JumpAction>)> = Vec::new();
for action in actions {
let category = action
.label
.split_once(':')
.map(|(c, _)| c.trim().to_string())
.unwrap_or_else(|| "Other".to_string());
if let Some(slot) = buckets.iter_mut().find(|(c, _)| c == &category) {
slot.1.push(action);
} else {
buckets.push((category, vec![action]));
}
}
let priority_index = |cat: &str| -> usize {
CATEGORY_PRIORITY
.iter()
.position(|p| *p == cat)
.unwrap_or(usize::MAX)
};
buckets.sort_by_key(|(c, _)| priority_index(c));
let mut out: Vec<JumpHit> = Vec::new();
let mut depth = 0usize;
let max_depth = buckets.iter().map(|(_, v)| v.len()).max().unwrap_or(0);
while depth < max_depth {
for (_, bucket) in &buckets {
if let Some(action) = bucket.get(depth) {
out.push(JumpHit::Action(*action));
}
}
depth += 1;
}
out
}
fn round_robin_actions_with_bias(
actions: impl Iterator<Item = JumpAction>,
preferred: JumpActionTarget,
bump: usize,
) -> Vec<JumpHit> {
let collected: Vec<JumpAction> = actions.collect();
let biased: Vec<JumpAction> = collected
.iter()
.filter(|a| a.target == preferred)
.take(bump)
.copied()
.collect();
let biased_keys: std::collections::HashSet<char> = biased.iter().map(|a| a.key).collect();
let rest: Vec<JumpAction> = collected
.into_iter()
.filter(|a| !(biased_keys.contains(&a.key) && a.target == preferred))
.collect();
let mut out: Vec<JumpHit> = biased.into_iter().map(JumpHit::Action).collect();
out.extend(round_robin_actions_by_category(rest.into_iter()));
out
}
#[derive(Debug, Default)]
pub struct JumpState {
pub query: String,
pub selected: usize,
pub mode: JumpMode,
pub hits: Vec<JumpHit>,
pub recents: Vec<JumpHit>,
pub cursor_revealed: bool,
pub matcher: Option<nucleo_matcher::Matcher>,
}
impl Clone for JumpState {
fn clone(&self) -> Self {
Self {
query: self.query.clone(),
selected: self.selected,
mode: self.mode,
hits: self.hits.clone(),
recents: self.recents.clone(),
cursor_revealed: self.cursor_revealed,
matcher: None,
}
}
}
impl JumpState {
pub fn for_mode(mode: JumpMode) -> Self {
Self {
mode,
..Self::default()
}
}
pub fn push_query(&mut self, c: char) {
if self.query.len() < 64 {
self.query.push(c);
}
}
pub fn pop_query(&mut self) {
self.query.pop();
}
pub fn visible_hits(&self) -> Vec<JumpHit> {
if self.query.is_empty() {
let mut out: Vec<JumpHit> = self.recents.clone();
out.extend(self.empty_state_actions());
out
} else {
self.hits.clone()
}
}
fn filtered_actions_for_empty_state(&self) -> Vec<JumpAction> {
let recent_keys: std::collections::HashSet<RecentRef> =
self.recents.iter().map(|h| h.identity()).collect();
JumpAction::for_mode(self.mode)
.iter()
.filter(|a| {
let id = RecentRef::new(SourceKind::Action, a.key.to_string());
!recent_keys.contains(&id)
})
.copied()
.collect()
}
fn empty_state_actions(&self) -> Vec<JumpHit> {
let filtered = self.filtered_actions_for_empty_state();
let preferred_target = match self.mode {
JumpMode::Hosts => None,
JumpMode::Tunnels => Some(JumpActionTarget::Tunnels),
JumpMode::Containers => Some(JumpActionTarget::Containers),
JumpMode::Keys => Some(JumpActionTarget::Keys),
};
let actions = match preferred_target {
Some(t) => round_robin_actions_with_bias(filtered.into_iter(), t, EMPTY_STATE_TAB_BIAS),
None => round_robin_actions_by_category(filtered.into_iter()),
};
actions
.into_iter()
.take(JUMP_EMPTY_STATE_ACTIONS_CAP)
.collect()
}
pub fn empty_state_actions_total(&self) -> usize {
self.filtered_actions_for_empty_state().len()
}
pub fn grouped_hits(&self) -> Vec<(SourceKind, Vec<JumpHit>)> {
let visible = self.visible_hits();
let mut out = Vec::with_capacity(SourceKind::render_order().len());
for kind in SourceKind::render_order() {
let group: Vec<JumpHit> = visible
.iter()
.filter(|h| h.kind() == kind)
.cloned()
.collect();
if !group.is_empty() {
out.push((kind, group));
}
}
out
}
pub fn empty_state_groups(&self) -> Vec<(&'static str, Vec<JumpHit>)> {
let mut out: Vec<(&'static str, Vec<JumpHit>)> = Vec::new();
if !self.recents.is_empty() {
out.push(("RECENT", self.recents.clone()));
}
let actions = self.empty_state_actions();
if !actions.is_empty() {
out.push(("ACTIONS", actions));
}
out
}
pub fn selected_section(&self) -> Option<SourceKind> {
self.visible_hits().get(self.selected).map(|h| h.kind())
}
#[cfg(test)]
pub fn filtered_commands(&self) -> Vec<JumpAction> {
let all = JumpAction::for_mode(self.mode);
if self.query.is_empty() {
return all.to_vec();
}
let q = self.query.to_lowercase();
all.iter()
.filter(|cmd| {
cmd.label.to_lowercase().contains(&q)
|| cmd.aliases.iter().any(|a| a.to_lowercase().contains(&q))
})
.copied()
.collect()
}
pub fn jump_next_section(&mut self) {
let visible = self.visible_hits();
if visible.is_empty() {
return;
}
if self.query.is_empty() {
let n_recent = self.recents.len();
if n_recent == 0 || n_recent >= visible.len() {
return;
}
if self.selected < n_recent {
self.selected = n_recent; } else {
self.selected = 0; }
return;
}
let groups = self.grouped_hits();
if groups.len() < 2 {
return;
}
let cur_kind = match self.selected_section() {
Some(k) => k,
None => {
self.selected = 0;
return;
}
};
let cur_idx = groups.iter().position(|(k, _)| *k == cur_kind).unwrap_or(0);
let next_idx = (cur_idx + 1) % groups.len();
let next_kind = groups[next_idx].0;
if let Some(pos) = visible.iter().position(|h| h.kind() == next_kind) {
self.selected = pos;
}
}
}
#[cfg(test)]
pub mod tests {
use super::*;
use std::sync::Mutex;
pub(crate) static ENV_LOCK: Mutex<()> = Mutex::new(());
fn with_temp<F: FnOnce(&std::path::Path)>(f: F) {
let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("recents.json");
test_path::set(path.clone());
f(&path);
test_path::clear();
}
#[test]
fn section_labels_are_uppercase() {
for k in SourceKind::render_order() {
let label = k.section_label();
assert_eq!(label, label.to_uppercase(), "{:?} not uppercase", k);
}
}
#[test]
fn render_order_starts_with_hosts() {
assert_eq!(SourceKind::render_order()[0], SourceKind::Host);
assert_eq!(SourceKind::render_order()[4], SourceKind::Action);
}
#[test]
fn touch_moves_existing_to_front_and_caps() {
let mut f = RecentsFile::default();
for i in 0..(RECENTS_CAP + 5) {
touch_recent(&mut f, RecentRef::new(SourceKind::Host, format!("h{i}")));
}
assert_eq!(f.entries.len(), RECENTS_CAP);
let target = RecentRef::new(SourceKind::Host, format!("h{}", RECENTS_CAP + 2));
touch_recent(&mut f, target.clone());
assert_eq!(f.entries[0].target, target);
assert_eq!(f.entries.len(), RECENTS_CAP);
}
#[test]
fn save_then_load_roundtrip() {
with_temp(|_path| {
let mut f = RecentsFile::default();
touch_recent(&mut f, RecentRef::new(SourceKind::Action, "F".into()));
touch_recent(&mut f, RecentRef::new(SourceKind::Host, "web-01".into()));
save_recents(&f).expect("save");
let loaded = load_recents();
assert_eq!(loaded.version, RECENTS_VERSION);
assert_eq!(loaded.entries.len(), 2);
assert_eq!(loaded.entries[0].target.key, "web-01");
assert_eq!(loaded.entries[1].target.key, "F");
});
}
#[test]
fn missing_file_loads_empty() {
with_temp(|_path| {
let loaded = load_recents();
assert!(loaded.entries.is_empty());
});
}
#[test]
fn corrupt_file_loads_empty() {
with_temp(|path| {
std::fs::write(path, b"not json").unwrap();
let loaded = load_recents();
assert!(loaded.entries.is_empty());
});
}
fn host_entry(alias: &str, ts: i64) -> RecentEntry {
RecentEntry {
target: RecentRef::new(SourceKind::Host, alias.to_string()),
last_used_unix: ts,
}
}
#[test]
fn rename_host_recent_rewrites_key() {
let mut file = RecentsFile::default();
file.entries.push(host_entry("web-old", 100));
file.entries.push(RecentEntry {
target: RecentRef::new(SourceKind::Tunnel, "web-old:5432".to_string()),
last_used_unix: 90,
});
assert!(rename_host_recent(&mut file, "web-old", "web-new"));
assert_eq!(file.entries[0].target.kind, SourceKind::Host);
assert_eq!(file.entries[0].target.key, "web-new");
assert_eq!(file.entries[1].target.kind, SourceKind::Tunnel);
assert_eq!(file.entries[1].target.key, "web-old:5432");
}
#[test]
fn rename_host_recent_dedups_on_collision_keeping_most_recent() {
let mut file = RecentsFile::default();
file.entries.push(host_entry("a", 200));
file.entries.push(host_entry("b", 100));
assert!(rename_host_recent(&mut file, "a", "b"));
assert_eq!(file.entries.len(), 1);
assert_eq!(file.entries[0].target.key, "b");
assert_eq!(file.entries[0].last_used_unix, 200);
}
#[test]
fn rename_host_recent_dedups_when_new_key_is_newer() {
let mut file = RecentsFile::default();
file.entries.push(host_entry("a", 100));
file.entries.push(host_entry("b", 200));
assert!(rename_host_recent(&mut file, "a", "b"));
assert_eq!(file.entries.len(), 1);
assert_eq!(file.entries[0].target.key, "b");
assert_eq!(file.entries[0].last_used_unix, 200);
}
#[test]
fn rename_host_recent_noop_when_same() {
let mut file = RecentsFile::default();
file.entries.push(host_entry("a", 10));
assert!(!rename_host_recent(&mut file, "a", "a"));
assert_eq!(file.entries.len(), 1);
}
#[test]
fn rename_host_recent_noop_when_absent() {
let mut file = RecentsFile::default();
assert!(!rename_host_recent(&mut file, "ghost", "phantom"));
assert!(file.entries.is_empty());
}
}