1use std::collections::HashMap;
8
9#[derive(Clone, Debug, Default, PartialEq, Eq)]
11pub struct SessionItem {
12 pub path: String,
14 pub id: String,
16 pub cwd: String,
18 pub name: Option<String>,
20 pub parent_path: Option<String>,
22 pub modified_ms: i64,
24 pub message_count: usize,
26 pub first_message: String,
28 pub all_text: String,
30}
31
32#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
34pub enum Scope {
35 #[default]
37 Current,
38 All,
40}
41
42#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
44pub enum SortMode {
45 #[default]
47 Threaded,
48 Recent,
50 Fuzzy,
52}
53
54impl SortMode {
55 #[must_use]
57 pub const fn label(self) -> &'static str {
58 match self {
59 Self::Threaded => "Threaded",
60 Self::Recent => "Recent",
61 Self::Fuzzy => "Fuzzy",
62 }
63 }
64
65 const fn next(self) -> Self {
66 match self {
67 Self::Threaded => Self::Recent,
68 Self::Recent => Self::Fuzzy,
69 Self::Fuzzy => Self::Threaded,
70 }
71 }
72}
73
74#[derive(Clone, Debug, PartialEq, Eq)]
76pub struct SessionRow {
77 pub index: usize,
79 pub prefix: String,
81}
82
83#[derive(Clone, Debug, Default)]
85pub struct SessionListModel {
86 items: Vec<SessionItem>,
87 rows: Vec<SessionRow>,
88 pub scope: Scope,
90 pub sort: SortMode,
92 pub named_only: bool,
94 pub show_path: bool,
96 pub query: String,
98 pub confirm_delete: Option<String>,
100 pub current_path: Option<String>,
102 pub loading: bool,
104 selected: usize,
105}
106
107impl SessionListModel {
108 #[must_use]
110 pub fn new(scope: Scope, current_path: Option<String>) -> Self {
111 Self {
112 scope,
113 current_path,
114 loading: true,
115 ..Self::default()
116 }
117 }
118
119 pub fn set_items(&mut self, items: Vec<SessionItem>) {
121 self.items = items;
122 self.loading = false;
123 self.refresh();
124 }
125
126 #[must_use]
128 pub fn item(&self, index: usize) -> Option<&SessionItem> {
129 self.items.get(index)
130 }
131
132 #[must_use]
134 pub fn rows(&self) -> &[SessionRow] {
135 &self.rows
136 }
137
138 #[must_use]
140 pub const fn selected(&self) -> usize {
141 self.selected
142 }
143
144 #[must_use]
146 pub fn selected_item(&self) -> Option<&SessionItem> {
147 self.rows
148 .get(self.selected)
149 .and_then(|r| self.items.get(r.index))
150 }
151
152 pub fn move_by(&mut self, delta: isize) {
154 let len = self.rows.len();
155 if len == 0 {
156 return;
157 }
158 let len_i = isize::try_from(len).unwrap_or(isize::MAX);
159 let next = (isize::try_from(self.selected).unwrap_or(0) + delta).rem_euclid(len_i);
160 self.selected = usize::try_from(next).unwrap_or(0);
161 }
162
163 pub fn set_query(&mut self, query: impl Into<String>) {
165 self.query = query.into();
166 self.refresh();
167 }
168
169 pub fn toggle_sort(&mut self) {
171 self.sort = self.sort.next();
172 self.refresh();
173 }
174
175 pub fn toggle_named(&mut self) {
177 self.named_only = !self.named_only;
178 self.refresh();
179 }
180
181 pub fn remove(&mut self, path: &str) {
183 self.items.retain(|i| i.path != path);
184 self.refresh();
185 }
186
187 pub fn rename(&mut self, path: &str, name: Option<String>) {
189 if let Some(item) = self.items.iter_mut().find(|i| i.path == path) {
190 item.name = name;
191 }
192 self.refresh();
193 }
194
195 fn refresh(&mut self) {
196 let candidates: Vec<usize> = (0..self.items.len())
197 .filter(|&i| !self.named_only || self.items[i].name.is_some())
198 .collect();
199 let query = self.query.trim();
200 self.rows = if self.sort == SortMode::Threaded && query.is_empty() {
201 self.threaded(&candidates)
202 } else if query.is_empty() {
203 let mut order = candidates;
204 order.sort_by_key(|&i| std::cmp::Reverse(self.items[i].modified_ms));
205 order
206 .into_iter()
207 .map(|index| SessionRow {
208 index,
209 prefix: String::new(),
210 })
211 .collect()
212 } else {
213 let texts: Vec<String> = candidates
214 .iter()
215 .map(|&i| {
216 let item = &self.items[i];
217 format!(
218 "{} {} {} {}",
219 item.name.as_deref().unwrap_or(""),
220 item.first_message,
221 item.all_text,
222 item.cwd
223 )
224 })
225 .collect();
226 let mut order = crate::fuzzy::filter(&texts, query, Clone::clone);
227 if self.sort != SortMode::Fuzzy {
228 order.sort_by_key(|&o| std::cmp::Reverse(self.items[candidates[o]].modified_ms));
229 }
230 order
231 .into_iter()
232 .map(|o| SessionRow {
233 index: candidates[o],
234 prefix: String::new(),
235 })
236 .collect()
237 };
238 self.selected = self.selected.min(self.rows.len().saturating_sub(1));
239 }
240
241 fn threaded(&self, candidates: &[usize]) -> Vec<SessionRow> {
242 let by_path: HashMap<&str, usize> = candidates
243 .iter()
244 .map(|&i| (self.items[i].path.as_str(), i))
245 .collect();
246 let mut children: HashMap<usize, Vec<usize>> = HashMap::new();
247 let mut roots = Vec::new();
248 for &i in candidates {
249 match self.items[i]
250 .parent_path
251 .as_deref()
252 .and_then(|p| by_path.get(p))
253 {
254 Some(&parent) if parent != i => children.entry(parent).or_default().push(i),
255 _ => roots.push(i),
256 }
257 }
258 let newest =
259 |v: &mut Vec<usize>| v.sort_by_key(|&i| std::cmp::Reverse(self.items[i].modified_ms));
260 newest(&mut roots);
261 for list in children.values_mut() {
262 newest(list);
263 }
264 let mut rows = Vec::new();
265 let mut stack: Vec<(usize, usize, Vec<bool>, bool)> = roots
266 .iter()
267 .rev()
268 .map(|&r| (r, 0, Vec::new(), true))
269 .collect();
270 let mut seen = std::collections::HashSet::new();
271 while let Some((index, depth, ancestors, is_last)) = stack.pop() {
272 if !seen.insert(index) {
273 continue;
274 }
275 let prefix = if depth == 0 {
276 String::new()
277 } else {
278 let mut p: String = ancestors
279 .iter()
280 .map(|&continues| if continues { "│ " } else { " " })
281 .collect();
282 p.push_str(if is_last { "└─ " } else { "├─ " });
283 p
284 };
285 rows.push(SessionRow { index, prefix });
286 if let Some(kids) = children.get(&index) {
287 let mut next_ancestors = ancestors.clone();
288 if depth > 0 {
289 next_ancestors.push(!is_last);
290 }
291 for (k, &child) in kids.iter().enumerate().rev() {
292 stack.push((
293 child,
294 depth + 1,
295 next_ancestors.clone(),
296 k == kids.len() - 1,
297 ));
298 }
299 }
300 }
301 rows
302 }
303
304 #[must_use]
306 pub const fn title(&self) -> &'static str {
307 match self.scope {
308 Scope::Current => "Resume Session (Current Folder)",
309 Scope::All => "Resume Session (All)",
310 }
311 }
312}
313
314#[cfg(test)]
315mod tests {
316 use super::*;
317
318 fn item(path: &str, parent: Option<&str>, modified: i64, name: Option<&str>) -> SessionItem {
319 SessionItem {
320 path: path.into(),
321 id: path.into(),
322 parent_path: parent.map(str::to_owned),
323 modified_ms: modified,
324 name: name.map(str::to_owned),
325 first_message: format!("msg {path}"),
326 ..SessionItem::default()
327 }
328 }
329
330 #[test]
331 fn threaded_named_and_search() {
332 let mut model = SessionListModel::new(Scope::Current, None);
333 model.set_items(vec![
334 item("a", None, 10, None),
335 item("b", Some("a"), 30, Some("fork")),
336 item("c", None, 20, None),
337 ]);
338 let order: Vec<(&str, &str)> = model
339 .rows()
340 .iter()
341 .map(|r| {
342 (
343 model.item(r.index).unwrap().path.as_str(),
344 r.prefix.as_str(),
345 )
346 })
347 .collect();
348 assert_eq!(order, vec![("c", ""), ("a", ""), ("b", "└─ ")]);
349 model.toggle_named();
350 assert_eq!(model.rows().len(), 1);
351 model.toggle_named();
352 model.toggle_sort();
353 assert_eq!(model.sort, SortMode::Recent);
354 assert_eq!(model.selected_item().unwrap().path, "b");
355 model.set_query("msg c");
356 assert_eq!(model.rows().len(), 1);
357 }
358}