use crate::picker::fzf::FzfOpts;
pub const SENTINEL_NEW: &str = "__NEW__";
pub const SENTINEL_CONTINUE: &str = "__CONTINUE__";
#[derive(Debug, Clone)]
pub struct SessionRow {
pub sid: String,
pub mtime: u64,
pub human_ts: String,
pub mode: String,
pub label: String,
pub is_live: bool,
}
impl SessionRow {
pub fn to_tsv(&self) -> String {
let display_label = if self.is_live {
format!("● live in another pane · {}", self.label)
} else {
self.label.clone()
};
format!(
"{}\t{}\t{}\t{}\t{}",
self.sid, self.mtime, self.human_ts, self.mode, display_label
)
}
pub fn new_session_sentinel() -> Self {
Self {
sid: SENTINEL_NEW.to_string(),
mtime: 9_999_999_999,
human_ts: String::new(),
mode: String::new(),
label: "[ start a new session ]".to_string(),
is_live: false,
}
}
pub fn continue_sentinel(newest_live: Option<&str>) -> Self {
let label = match newest_live {
Some(live) => format!("[ continue · {live} ]"),
None => "[ continue newest session ]".to_string(),
};
Self {
sid: SENTINEL_CONTINUE.to_string(),
mtime: 9_999_999_998,
human_ts: String::new(),
mode: String::new(),
label,
is_live: false,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PickedSession {
Fresh,
Continue,
Resume(String),
}
pub struct SessionPicker {
rows: Vec<SessionRow>,
}
impl SessionPicker {
pub fn new(rows: Vec<SessionRow>) -> Self {
Self { rows }
}
pub fn pick(&self, newest_live_label: Option<&str>) -> Option<PickedSession> {
if self.rows.is_empty() {
return Some(PickedSession::Fresh);
}
if !crate::picker::fzf::fzf_available() {
return None;
}
let lines = self.build_fzf_input(newest_live_label);
let col1 = crate::picker::fzf::run_fzf(&lines, &Self::fzf_opts())?;
Some(Self::resolve(&col1))
}
pub fn resolve(col1: &str) -> PickedSession {
match col1 {
SENTINEL_NEW => PickedSession::Fresh,
SENTINEL_CONTINUE => PickedSession::Continue,
uuid => PickedSession::Resume(uuid.to_string()),
}
}
pub fn build_fzf_input(&self, newest_live_label: Option<&str>) -> Vec<String> {
let mut lines = Vec::with_capacity(self.rows.len() + 2);
lines.push(SessionRow::new_session_sentinel().to_tsv());
lines.push(SessionRow::continue_sentinel(newest_live_label).to_tsv());
for row in &self.rows {
lines.push(row.to_tsv());
}
lines
}
pub fn fzf_opts() -> FzfOpts {
FzfOpts {
prompt: "session > ".to_string(),
with_nth: "3..".to_string(),
delimiter: "\t".to_string(),
height: "40%".to_string(),
extra_args: vec!["--reverse".to_string(), "--no-multi".to_string()],
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sentinel_mtime_ordering() {
assert!(
SessionRow::new_session_sentinel().mtime > SessionRow::continue_sentinel(None).mtime
);
let real_mtime: u64 = 1_750_000_000;
assert!(SessionRow::continue_sentinel(None).mtime > real_mtime);
}
#[test]
fn sentinel_sid_constants() {
assert_eq!(SessionRow::new_session_sentinel().sid, SENTINEL_NEW);
assert_eq!(SessionRow::continue_sentinel(None).sid, SENTINEL_CONTINUE);
}
#[test]
fn live_annotation_prefix() {
let mut row = SessionRow {
sid: "abc".to_string(),
mtime: 1_000,
human_ts: "06-18 12:00".to_string(),
mode: "default".to_string(),
label: "My session".to_string(),
is_live: true,
};
let tsv = row.to_tsv();
let col5 = tsv.split('\t').nth(4).unwrap();
assert!(col5.starts_with("● live in another pane · "), "got: {col5}");
assert!(col5.contains("My session"));
row.is_live = false;
let tsv2 = row.to_tsv();
let col5_2 = tsv2.split('\t').nth(4).unwrap();
assert!(!col5_2.starts_with("●"), "got: {col5_2}");
}
#[test]
fn to_tsv_col1_is_sid() {
let row = SessionRow {
sid: "deadbeef-0000-0000-0000-000000000000".to_string(),
mtime: 1_700_000_000,
human_ts: "01-01 00:00".to_string(),
mode: "bypassPermissions".to_string(),
label: "label text".to_string(),
is_live: false,
};
let tsv = row.to_tsv();
let col1 = tsv.split('\t').next().unwrap();
assert_eq!(col1, "deadbeef-0000-0000-0000-000000000000");
}
#[test]
fn build_fzf_input_sentinel_order() {
let picker = SessionPicker::new(vec![]);
let lines = picker.build_fzf_input(None);
assert_eq!(lines.len(), 2);
let col1_0 = lines[0].split('\t').next().unwrap();
let col1_1 = lines[1].split('\t').next().unwrap();
assert_eq!(col1_0, SENTINEL_NEW);
assert_eq!(col1_1, SENTINEL_CONTINUE);
}
#[test]
fn build_fzf_input_rows_follow_sentinels() {
let rows = vec![
SessionRow {
sid: "aaa".to_string(),
mtime: 2,
human_ts: String::new(),
mode: String::new(),
label: "a".to_string(),
is_live: false,
},
SessionRow {
sid: "bbb".to_string(),
mtime: 1,
human_ts: String::new(),
mode: String::new(),
label: "b".to_string(),
is_live: false,
},
];
let picker = SessionPicker::new(rows);
let lines = picker.build_fzf_input(None);
assert_eq!(lines.len(), 4);
let sids: Vec<&str> = lines
.iter()
.map(|l| l.split('\t').next().unwrap())
.collect();
assert_eq!(sids[0], SENTINEL_NEW);
assert_eq!(sids[1], SENTINEL_CONTINUE);
assert_eq!(sids[2], "aaa");
assert_eq!(sids[3], "bbb");
}
#[test]
fn fzf_opts_session_picker() {
let opts = SessionPicker::fzf_opts();
assert_eq!(opts.prompt, "session > ");
assert_eq!(opts.with_nth, "3..");
assert_eq!(opts.delimiter, "\t");
}
#[test]
fn continue_sentinel_with_live_label() {
let row = SessionRow::continue_sentinel(Some("my-session-label"));
assert!(row.label.contains("my-session-label"), "got: {}", row.label);
}
}