use crate::picker::engine::{self, PickerOpts};
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{}\t{}",
self.sid,
self.mtime,
self.human_ts,
self.short_id(),
self.mode,
display_label
)
}
pub fn short_id(&self) -> String {
if self.sid == SENTINEL_NEW || self.sid == SENTINEL_CONTINUE {
String::new()
} else {
self.sid.chars().take(8).collect()
}
}
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),
Cancel,
}
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);
}
let lines = self.build_picker_input(newest_live_label);
match engine::run_picker(&lines, &Self::picker_opts()) {
engine::PickerOutcome::Selected(col1) => Some(Self::resolve(&col1)),
engine::PickerOutcome::Cancelled => Some(PickedSession::Cancel),
engine::PickerOutcome::Unavailable | engine::PickerOutcome::SelectedMulti(_) => None,
}
}
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_picker_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 picker_opts() -> PickerOpts {
PickerOpts {
prompt: "session > ".to_string(),
display_from: 3,
delimiter: '\t',
}
}
}
#[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 label_col = tsv.split('\t').nth(5).unwrap();
assert!(
label_col.starts_with("● live in another pane · "),
"got: {label_col}"
);
assert!(label_col.contains("My session"));
row.is_live = false;
let tsv2 = row.to_tsv();
let label_col_2 = tsv2.split('\t').nth(5).unwrap();
assert!(!label_col_2.starts_with("●"), "got: {label_col_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 cols: Vec<&str> = tsv.split('\t').collect();
assert_eq!(cols[0], "deadbeef-0000-0000-0000-000000000000");
assert_eq!(cols[3], "deadbeef");
assert!(
!cols[2..].iter().any(|c| c.contains("0000-0000")),
"full UUID leaked into a displayed column: {cols:?}"
);
}
#[test]
fn short_id_is_first_8_chars_and_empty_for_sentinels() {
let row = SessionRow {
sid: "abcd1234-5678-90ab-cdef-000000000000".to_string(),
mtime: 1,
human_ts: String::new(),
mode: String::new(),
label: "x".to_string(),
is_live: false,
};
assert_eq!(row.short_id(), "abcd1234");
assert_eq!(SessionRow::new_session_sentinel().short_id(), "");
assert_eq!(SessionRow::continue_sentinel(None).short_id(), "");
}
#[test]
fn build_picker_input_sentinel_order() {
let picker = SessionPicker::new(vec![]);
let lines = picker.build_picker_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_picker_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_picker_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 picker_opts_session_picker() {
let opts = SessionPicker::picker_opts();
assert_eq!(opts.prompt, "session > ");
assert_eq!(opts.display_from, 3);
assert_eq!(opts.delimiter, '\t');
}
#[test]
fn resolve_never_yields_cancel_and_cancel_is_distinct_from_fresh() {
assert_eq!(SessionPicker::resolve(SENTINEL_NEW), PickedSession::Fresh);
assert_eq!(
SessionPicker::resolve(SENTINEL_CONTINUE),
PickedSession::Continue
);
assert_eq!(
SessionPicker::resolve("dead-beef"),
PickedSession::Resume("dead-beef".to_string())
);
assert_ne!(PickedSession::Cancel, PickedSession::Fresh);
}
#[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);
}
}