Skip to main content

rpi_cli/
resume_picker.rs

1//! Startup session picker used by `-r` / `--resume`.
2
3use 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
12/// Select a project session before the main harness is built.
13///
14/// Returns `Ok(None)` when the user cancels with Esc or Ctrl+C.
15pub 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}