Skip to main content

oino_view/
sessions.rs

1//! Session resume picker model.
2//!
3//! Scope (current folder / all), sort (threaded / recent / fuzzy), name filter (all /
4//! named), path display, search and delete confirmation. Threaded order nests forks under
5//! their parent session (`parentSessionPath`).
6
7use std::collections::HashMap;
8
9/// One saved session.
10#[derive(Clone, Debug, Default, PartialEq, Eq)]
11pub struct SessionItem {
12    /// File path.
13    pub path: String,
14    /// Session id.
15    pub id: String,
16    /// Working directory.
17    pub cwd: String,
18    /// Display name.
19    pub name: Option<String>,
20    /// Parent session file.
21    pub parent_path: Option<String>,
22    /// Last activity (ms since epoch).
23    pub modified_ms: i64,
24    /// Message entries.
25    pub message_count: usize,
26    /// First user message.
27    pub first_message: String,
28    /// Search corpus.
29    pub all_text: String,
30}
31
32/// Which sessions are listed.
33#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
34pub enum Scope {
35    /// Sessions of the current cwd.
36    #[default]
37    Current,
38    /// Every session.
39    All,
40}
41
42/// Sort mode.
43#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
44pub enum SortMode {
45    /// Forks nested under parents, newest first.
46    #[default]
47    Threaded,
48    /// Newest first, flat.
49    Recent,
50    /// Fuzzy relevance (when searching).
51    Fuzzy,
52}
53
54impl SortMode {
55    /// Header label.
56    #[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/// A visible row.
75#[derive(Clone, Debug, PartialEq, Eq)]
76pub struct SessionRow {
77    /// Index into the item list.
78    pub index: usize,
79    /// Tree prefix (`│  ├─ `).
80    pub prefix: String,
81}
82
83/// The picker model.
84#[derive(Clone, Debug, Default)]
85pub struct SessionListModel {
86    items: Vec<SessionItem>,
87    rows: Vec<SessionRow>,
88    /// Scope.
89    pub scope: Scope,
90    /// Sort mode.
91    pub sort: SortMode,
92    /// Named-only filter.
93    pub named_only: bool,
94    /// Show file paths.
95    pub show_path: bool,
96    /// Search query.
97    pub query: String,
98    /// Path awaiting delete confirmation.
99    pub confirm_delete: Option<String>,
100    /// Currently open session file.
101    pub current_path: Option<String>,
102    /// Items are loading.
103    pub loading: bool,
104    selected: usize,
105}
106
107impl SessionListModel {
108    /// A loading model.
109    #[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    /// Replaces the items (newest first expected).
120    pub fn set_items(&mut self, items: Vec<SessionItem>) {
121        self.items = items;
122        self.loading = false;
123        self.refresh();
124    }
125
126    /// Item by index.
127    #[must_use]
128    pub fn item(&self, index: usize) -> Option<&SessionItem> {
129        self.items.get(index)
130    }
131
132    /// Visible rows.
133    #[must_use]
134    pub fn rows(&self) -> &[SessionRow] {
135        &self.rows
136    }
137
138    /// Selected row index.
139    #[must_use]
140    pub const fn selected(&self) -> usize {
141        self.selected
142    }
143
144    /// Selected session.
145    #[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    /// Moves the selection (wrapping).
153    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    /// Sets the query (re-filters).
164    pub fn set_query(&mut self, query: impl Into<String>) {
165        self.query = query.into();
166        self.refresh();
167    }
168
169    /// `app.session.toggleSort`.
170    pub fn toggle_sort(&mut self) {
171        self.sort = self.sort.next();
172        self.refresh();
173    }
174
175    /// `app.session.toggleNamedFilter`.
176    pub fn toggle_named(&mut self) {
177        self.named_only = !self.named_only;
178        self.refresh();
179    }
180
181    /// Removes a deleted session.
182    pub fn remove(&mut self, path: &str) {
183        self.items.retain(|i| i.path != path);
184        self.refresh();
185    }
186
187    /// Updates a renamed session.
188    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    /// Header title.
305    #[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}