1use std::path::Path;
4use std::sync::Arc;
5use std::time::{Duration, SystemTime, UNIX_EPOCH};
6
7use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseEventKind};
8use rpi_tui::{
9 Container, ProcessTerminal, SelectItem, SelectList, Spacer, Text, TuiAltScreen, TUI,
10};
11
12pub async fn select(cwd: &Path) -> Result<Option<String>, String> {
16 let cwd_text = cwd.to_string_lossy().into_owned();
17 let metadata = crate::session::list_session_metadata(&cwd_text)
18 .await
19 .map_err(|e| e.to_string())?;
20 if metadata.is_empty() {
21 return Err(format!(
22 "no sessions found in {}",
23 crate::session::default_session_dir(cwd).display()
24 ));
25 }
26
27 let now_ms = SystemTime::now()
28 .duration_since(UNIX_EPOCH)
29 .unwrap_or_default()
30 .as_millis()
31 .min(i64::MAX as u128) as i64;
32 let items = metadata
33 .iter()
34 .map(|meta| {
35 let file_name = Path::new(&meta.path)
36 .file_name()
37 .and_then(|name| name.to_str())
38 .unwrap_or(&meta.id);
39 SelectItem::new(&meta.id, file_name)
40 .with_description(&format_modified_age(now_ms, meta.modified_at))
41 })
42 .collect();
43
44 let list = Arc::new(SelectList::new(items, 12));
45 let search = Arc::new(Text::new(" Filter: ", 0, 0));
46 let root = Arc::new(Container::new());
47 root.add_child(Arc::new(Text::new("Resume session", 1, 1)));
48 root.add_child(Arc::new(Text::new(
49 " Type to filter, Enter to resume, Esc to cancel",
50 0,
51 0,
52 )));
53 root.add_child(Arc::new(Spacer::new(1)));
54 root.add_child(search.clone());
55 root.add_child(Arc::new(Spacer::new(1)));
56 root.add_child(list.clone());
57
58 let tui = Arc::new(TuiAltScreen::new(
59 Box::new(ProcessTerminal::new()),
60 false,
61 None,
62 ));
63 tui.set_layout_root(Some(root));
64 tui.start_readerless();
65
66 let input_tui = tui.clone();
67 let input_list = list.clone();
68 let input_search = search.clone();
69 let task = tokio::task::spawn_blocking(move || -> Result<Option<String>, String> {
70 let mut query = String::new();
71 loop {
72 match crossterm::event::poll(Duration::from_millis(100)) {
73 Ok(true) => {}
74 Ok(false) => continue,
75 Err(e) => return Err(format!("could not read terminal input: {e}")),
76 }
77 let event = crossterm::event::read()
78 .map_err(|e| format!("could not read terminal input: {e}"))?;
79 match event {
80 Event::Resize(_, _) => {
81 input_tui.refresh_size();
82 }
83 Event::Mouse(mouse) => {
84 let code = match mouse.kind {
85 MouseEventKind::ScrollUp => Some(KeyCode::Up),
86 MouseEventKind::ScrollDown => Some(KeyCode::Down),
87 _ => None,
88 };
89 if let Some(code) = code {
90 input_list.handle_key(KeyEvent::new(code, KeyModifiers::NONE));
91 input_tui.request_render(false);
92 }
93 }
94 Event::Key(key) if key.kind != KeyEventKind::Release => {
95 if key.modifiers.contains(KeyModifiers::CONTROL)
96 && key.code == KeyCode::Char('c')
97 {
98 return Ok(None);
99 }
100 match key.code {
101 KeyCode::Enter => {
102 return Ok(input_list.get_selected_item().map(|item| item.value));
103 }
104 KeyCode::Esc if query.is_empty() => return Ok(None),
105 KeyCode::Esc => {
106 query.clear();
107 input_list.set_filter("");
108 }
109 KeyCode::Backspace => {
110 query.pop();
111 input_list.set_filter(&query);
112 }
113 KeyCode::Char(ch)
114 if !key
115 .modifiers
116 .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) =>
117 {
118 query.push(ch);
119 input_list.set_filter(&query);
120 }
121 _ => input_list.handle_key(key),
122 }
123 input_search.set_text(format!(" Filter: {query}"));
124 input_tui.request_render(false);
125 }
126 _ => {}
127 }
128 }
129 });
130
131 let result = task
132 .await
133 .map_err(|e| format!("session picker task failed: {e}"));
134 tui.stop(Default::default());
135 result?
136}
137
138fn format_modified_age(now_ms: i64, modified_ms: i64) -> String {
139 let seconds = now_ms.saturating_sub(modified_ms).max(0) / 1000;
140 match seconds {
141 0..=59 => "just now".to_string(),
142 60..=3_599 => format!("{}m ago", seconds / 60),
143 3_600..=86_399 => format!("{}h ago", seconds / 3_600),
144 _ => format!("{}d ago", seconds / 86_400),
145 }
146}
147
148#[cfg(test)]
149mod tests {
150 use super::*;
151
152 #[test]
153 fn modified_age_is_compact_and_stable() {
154 let now = 10 * 86_400_000;
155 assert_eq!(format_modified_age(now, now - 20_000), "just now");
156 assert_eq!(format_modified_age(now, now - 5 * 60_000), "5m ago");
157 assert_eq!(format_modified_age(now, now - 3 * 3_600_000), "3h ago");
158 assert_eq!(format_modified_age(now, now - 2 * 86_400_000), "2d ago");
159 }
160}