use percent_encoding::{AsciiSet, CONTROLS, percent_decode_str, utf8_percent_encode};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
pub(crate) const FILENAME_ENCODE_SET: &AsciiSet =
&CONTROLS.add(b'/').add(b'\\').add(b':').add(b'%');
use crate::multiplexer::types::{AgentPane, AgentStatus};
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Hash)]
pub struct PaneKey {
pub backend: String,
pub instance: String,
pub pane_id: String,
}
impl PaneKey {
pub fn to_filename(&self) -> String {
let safe_instance = utf8_percent_encode(&self.instance, FILENAME_ENCODE_SET).to_string();
let safe_pane_id = utf8_percent_encode(&self.pane_id, FILENAME_ENCODE_SET).to_string();
format!("{}__{}__{}.json", self.backend, safe_instance, safe_pane_id)
}
#[allow(dead_code)] pub fn from_filename(filename: &str) -> Option<Self> {
let stem = filename.strip_suffix(".json")?;
let parts: Vec<&str> = stem.splitn(3, "__").collect();
if parts.len() == 3 {
Some(PaneKey {
backend: parts[0].to_string(),
instance: percent_decode_str(parts[1])
.decode_utf8_lossy()
.into_owned(),
pane_id: percent_decode_str(parts[2])
.decode_utf8_lossy()
.into_owned(),
})
} else {
None
}
}
}
#[derive(Debug, Serialize, Deserialize)]
pub struct AgentState {
pub pane_key: PaneKey,
pub workdir: PathBuf,
pub status: Option<AgentStatus>,
pub status_ts: Option<u64>,
pub pane_title: Option<String>,
pub pane_pid: u32,
pub command: String,
pub updated_ts: u64,
#[serde(default)]
pub window_name: Option<String>,
#[serde(default)]
pub session_name: Option<String>,
#[serde(default)]
pub boot_id: Option<String>,
}
impl AgentState {
pub fn to_agent_pane(&self, session: String, window_name: String) -> AgentPane {
AgentPane {
session,
window_name,
pane_id: self.pane_key.pane_id.clone(),
window_id: String::new(),
path: self.workdir.clone(),
pane_title: self.pane_title.clone(),
status: self.status,
status_ts: self.status_ts,
updated_ts: Some(self.updated_ts),
}
}
}
#[derive(Debug, Serialize, Deserialize, Default)]
pub struct GlobalSettings {
pub sort_mode: String,
pub hide_stale: bool,
pub preview_size: Option<u8>,
pub last_pane_id: Option<String>,
#[serde(default)]
pub dashboard_scope: Option<String>,
#[serde(default)]
pub worktree_sort_mode: Option<String>,
#[serde(default)]
pub last_done_cycle: Option<LastDoneCycleState>,
#[serde(default)]
pub sidebar_layout: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
pub struct LastDoneCycleState {
pub target: PaneKey,
pub head_ts: Option<u64>,
}
#[derive(Debug, Serialize, Deserialize, Default)]
pub struct RuntimeState {
#[serde(default)]
pub interrupted_pane_ids: std::collections::HashSet<String>,
pub updated_ts: u64,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_pane_key_to_filename() {
let key = PaneKey {
backend: "tmux".to_string(),
instance: "default".to_string(),
pane_id: "%1".to_string(),
};
assert_eq!(key.to_filename(), "tmux__default__%251.json");
}
#[test]
fn test_pane_key_from_filename() {
let key = PaneKey::from_filename("tmux__default__%251.json").unwrap();
assert_eq!(key.backend, "tmux");
assert_eq!(key.instance, "default");
assert_eq!(key.pane_id, "%1");
}
#[test]
fn test_pane_key_roundtrip() {
let original = PaneKey {
backend: "wezterm".to_string(),
instance: "mux-123".to_string(),
pane_id: "tab_5".to_string(),
};
let filename = original.to_filename();
let parsed = PaneKey::from_filename(&filename).unwrap();
assert_eq!(original, parsed);
}
#[test]
fn test_pane_key_from_invalid_filename() {
assert!(PaneKey::from_filename("invalid.json").is_none());
assert!(PaneKey::from_filename("only__two.json").is_none());
assert!(PaneKey::from_filename("no_extension").is_none());
}
#[test]
fn test_pane_key_with_underscores_in_pane_id() {
let key = PaneKey {
backend: "tmux".to_string(),
instance: "default".to_string(),
pane_id: "pane_with_underscores".to_string(),
};
let filename = key.to_filename();
let parsed = PaneKey::from_filename(&filename).unwrap();
assert_eq!(parsed.pane_id, "pane_with_underscores");
}
#[test]
fn test_pane_key_with_socket_path() {
let key = PaneKey {
backend: "tmux".to_string(),
instance: "/private/tmp/tmux-501/default".to_string(),
pane_id: "%79".to_string(),
};
let filename = key.to_filename();
assert!(!filename.contains('/'));
let parsed = PaneKey::from_filename(&filename).unwrap();
assert_eq!(parsed.instance, "/private/tmp/tmux-501/default");
assert_eq!(parsed.pane_id, "%79");
}
}