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, SelectListLayoutOptions, Spacer, Text,
10 TuiAltScreen, TUI,
11};
12
13pub async fn select(cwd: &Path) -> Result<Option<String>, String> {
17 let cwd_text = cwd.to_string_lossy().into_owned();
18 let metadata = crate::session::list_session_metadata(&cwd_text)
19 .await
20 .map_err(|e| e.to_string())?;
21 if metadata.is_empty() {
22 return Err(format!(
23 "no sessions found in {}",
24 crate::session::default_session_dir(cwd).display()
25 ));
26 }
27
28 let metas_for_summary = metadata.clone();
32 let summaries = tokio::task::spawn_blocking(move || {
33 metas_for_summary
34 .iter()
35 .map(|meta| crate::session::summarize_session_file(Path::new(&meta.path)))
36 .collect::<Vec<_>>()
37 })
38 .await
39 .map_err(|e| format!("session summary task failed: {e}"))?;
40
41 let now_ms = SystemTime::now()
42 .duration_since(UNIX_EPOCH)
43 .unwrap_or_default()
44 .as_millis()
45 .min(i64::MAX as u128) as i64;
46 let mut items: Vec<SelectItem> = Vec::new();
47 for (meta, summary) in metadata.iter().zip(summaries.iter()) {
48 if summary.empty {
52 continue;
53 }
54 let file_name = Path::new(&meta.path)
55 .file_name()
56 .and_then(|name| name.to_str())
57 .unwrap_or(&meta.id);
58 let label = crate::session::session_display_label(summary);
59 let short_id = crate::session::short_session_id(&meta.id);
60 let description = format!(
61 "{} · {} · {}",
62 format_modified_age(now_ms, meta.modified_at),
63 crate::session::format_session_bytes(summary.bytes),
64 short_id,
65 );
66 items.push(
67 SelectItem::new(&meta.id, &label)
68 .with_description(&description)
69 .with_search_text(&format!("{label} {short_id} {file_name}")),
70 );
71 }
72 if items.is_empty() {
73 return Err(format!(
74 "no resumable sessions with messages in {}",
75 crate::session::default_session_dir(cwd).display()
76 ));
77 }
78
79 let mut list = SelectList::new(items, 12);
83 list.set_layout(SelectListLayoutOptions {
84 min_primary_column_width: Some(56),
85 max_primary_column_width: Some(72),
86 truncate_primary: None,
87 });
88 let list = Arc::new(list);
89 let search = Arc::new(Text::new(" Filter: ", 0, 0));
90 let root = Arc::new(Container::new());
91 root.add_child(Arc::new(Text::new("Resume session", 1, 1)));
92 root.add_child(Arc::new(Text::new(
93 " Type to filter, Enter to resume, Esc to cancel",
94 0,
95 0,
96 )));
97 root.add_child(Arc::new(Spacer::new(1)));
98 root.add_child(search.clone());
99 root.add_child(Arc::new(Spacer::new(1)));
100 root.add_child(list.clone());
101
102 let tui = Arc::new(TuiAltScreen::new(
103 Box::new(ProcessTerminal::new()),
104 false,
105 None,
106 ));
107 tui.set_layout_root(Some(root));
108 tui.start_render_scheduler();
111 tui.start_readerless();
112
113 let input_tui = tui.clone();
114 let input_list = list.clone();
115 let input_search = search.clone();
116 let task = tokio::task::spawn_blocking(move || -> Result<Option<String>, String> {
117 let mut query = String::new();
118 loop {
119 match crossterm::event::poll(Duration::from_millis(100)) {
120 Ok(true) => {}
121 Ok(false) => continue,
122 Err(e) => return Err(format!("could not read terminal input: {e}")),
123 }
124 let event = crossterm::event::read()
125 .map_err(|e| format!("could not read terminal input: {e}"))?;
126 match event {
127 Event::Resize(_, _) => {
128 input_tui.refresh_size();
129 }
130 Event::Mouse(mouse) => {
131 let code = match mouse.kind {
132 MouseEventKind::ScrollUp => Some(KeyCode::Up),
133 MouseEventKind::ScrollDown => Some(KeyCode::Down),
134 _ => None,
135 };
136 if let Some(code) = code {
137 input_list.handle_key(KeyEvent::new(code, KeyModifiers::NONE));
138 input_tui.request_render(false);
139 }
140 }
141 Event::Key(key) if key.kind != KeyEventKind::Release => {
142 if key.modifiers.contains(KeyModifiers::CONTROL)
143 && key.code == KeyCode::Char('c')
144 {
145 return Ok(None);
146 }
147 match key.code {
148 KeyCode::Enter => {
149 return Ok(input_list.get_selected_item().map(|item| item.value));
150 }
151 KeyCode::Esc if query.is_empty() => return Ok(None),
152 KeyCode::Esc => {
153 query.clear();
154 input_list.set_filter("");
155 }
156 KeyCode::Backspace => {
157 query.pop();
158 input_list.set_filter(&query);
159 }
160 KeyCode::Char(ch)
161 if !key
162 .modifiers
163 .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) =>
164 {
165 query.push(ch);
166 input_list.set_filter(&query);
167 }
168 _ => input_list.handle_key(key),
169 }
170 input_search.set_text(format!(" Filter: {query}"));
171 input_tui.request_render(false);
172 }
173 _ => {}
174 }
175 }
176 });
177
178 let result = task
179 .await
180 .map_err(|e| format!("session picker task failed: {e}"));
181 tui.stop(Default::default());
182 result?
183}
184
185fn format_modified_age(now_ms: i64, modified_ms: i64) -> String {
186 let seconds = now_ms.saturating_sub(modified_ms).max(0) / 1000;
187 match seconds {
188 0..=59 => "just now".to_string(),
189 60..=3_599 => format!("{}m ago", seconds / 60),
190 3_600..=86_399 => format!("{}h ago", seconds / 3_600),
191 _ => format!("{}d ago", seconds / 86_400),
192 }
193}
194
195#[cfg(test)]
196mod tests {
197 use super::*;
198
199 #[test]
200 fn modified_age_is_compact_and_stable() {
201 let now = 10 * 86_400_000;
202 assert_eq!(format_modified_age(now, now - 20_000), "just now");
203 assert_eq!(format_modified_age(now, now - 5 * 60_000), "5m ago");
204 assert_eq!(format_modified_age(now, now - 3 * 3_600_000), "3h ago");
205 assert_eq!(format_modified_age(now, now - 2 * 86_400_000), "2d ago");
206 }
207
208 #[test]
209 fn label_falls_back_from_name_to_preview_to_marker() {
210 use crate::session::{session_display_label, SessionSummary};
211 let named = SessionSummary {
212 name: Some("fix resume picker".into()),
213 preview: Some("a different prompt".into()),
214 ..Default::default()
215 };
216 assert_eq!(session_display_label(&named), "fix resume picker");
217
218 let preview = SessionSummary {
219 name: Some(" ".into()),
220 preview: Some("读取下当前 rpi -r 命令".into()),
221 ..Default::default()
222 };
223 assert_eq!(session_display_label(&preview), "读取下当前 rpi -r 命令");
224
225 let bare = SessionSummary::default();
226 assert_eq!(session_display_label(&bare), "(no messages)");
227 }
228
229 #[test]
230 fn byte_and_id_formatting_is_compact() {
231 use crate::session::{format_session_bytes, short_session_id};
232 assert_eq!(format_session_bytes(512), "512B");
233 assert_eq!(format_session_bytes(34 * 1024), "34KB");
234 assert_eq!(format_session_bytes(1_258_291), "1.2MB");
235 assert_eq!(
236 short_session_id("01a0d919-6742-70db-a206-9f5ed4cf665a"),
237 "01a0d919"
238 );
239 assert_eq!(short_session_id("abc"), "abc");
240 }
241}