use std::collections::HashMap;
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct SessionItem {
pub path: String,
pub id: String,
pub cwd: String,
pub name: Option<String>,
pub parent_path: Option<String>,
pub modified_ms: i64,
pub message_count: usize,
pub first_message: String,
pub all_text: String,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Scope {
#[default]
Current,
All,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum SortMode {
#[default]
Threaded,
Recent,
Fuzzy,
}
impl SortMode {
#[must_use]
pub const fn label(self) -> &'static str {
match self {
Self::Threaded => "Threaded",
Self::Recent => "Recent",
Self::Fuzzy => "Fuzzy",
}
}
const fn next(self) -> Self {
match self {
Self::Threaded => Self::Recent,
Self::Recent => Self::Fuzzy,
Self::Fuzzy => Self::Threaded,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SessionRow {
pub index: usize,
pub prefix: String,
}
#[derive(Clone, Debug, Default)]
pub struct SessionListModel {
items: Vec<SessionItem>,
rows: Vec<SessionRow>,
pub scope: Scope,
pub sort: SortMode,
pub named_only: bool,
pub show_path: bool,
pub query: String,
pub confirm_delete: Option<String>,
pub current_path: Option<String>,
pub loading: bool,
selected: usize,
}
impl SessionListModel {
#[must_use]
pub fn new(scope: Scope, current_path: Option<String>) -> Self {
Self {
scope,
current_path,
loading: true,
..Self::default()
}
}
pub fn set_items(&mut self, items: Vec<SessionItem>) {
self.items = items;
self.loading = false;
self.refresh();
}
#[must_use]
pub fn item(&self, index: usize) -> Option<&SessionItem> {
self.items.get(index)
}
#[must_use]
pub fn rows(&self) -> &[SessionRow] {
&self.rows
}
#[must_use]
pub const fn selected(&self) -> usize {
self.selected
}
#[must_use]
pub fn selected_item(&self) -> Option<&SessionItem> {
self.rows
.get(self.selected)
.and_then(|r| self.items.get(r.index))
}
pub fn move_by(&mut self, delta: isize) {
let len = self.rows.len();
if len == 0 {
return;
}
let len_i = isize::try_from(len).unwrap_or(isize::MAX);
let next = (isize::try_from(self.selected).unwrap_or(0) + delta).rem_euclid(len_i);
self.selected = usize::try_from(next).unwrap_or(0);
}
pub fn set_query(&mut self, query: impl Into<String>) {
self.query = query.into();
self.refresh();
}
pub fn toggle_sort(&mut self) {
self.sort = self.sort.next();
self.refresh();
}
pub fn toggle_named(&mut self) {
self.named_only = !self.named_only;
self.refresh();
}
pub fn remove(&mut self, path: &str) {
self.items.retain(|i| i.path != path);
self.refresh();
}
pub fn rename(&mut self, path: &str, name: Option<String>) {
if let Some(item) = self.items.iter_mut().find(|i| i.path == path) {
item.name = name;
}
self.refresh();
}
fn refresh(&mut self) {
let candidates: Vec<usize> = (0..self.items.len())
.filter(|&i| !self.named_only || self.items[i].name.is_some())
.collect();
let query = self.query.trim();
self.rows = if self.sort == SortMode::Threaded && query.is_empty() {
self.threaded(&candidates)
} else if query.is_empty() {
let mut order = candidates;
order.sort_by_key(|&i| std::cmp::Reverse(self.items[i].modified_ms));
order
.into_iter()
.map(|index| SessionRow {
index,
prefix: String::new(),
})
.collect()
} else {
let texts: Vec<String> = candidates
.iter()
.map(|&i| {
let item = &self.items[i];
format!(
"{} {} {} {}",
item.name.as_deref().unwrap_or(""),
item.first_message,
item.all_text,
item.cwd
)
})
.collect();
let mut order = crate::fuzzy::filter(&texts, query, Clone::clone);
if self.sort != SortMode::Fuzzy {
order.sort_by_key(|&o| std::cmp::Reverse(self.items[candidates[o]].modified_ms));
}
order
.into_iter()
.map(|o| SessionRow {
index: candidates[o],
prefix: String::new(),
})
.collect()
};
self.selected = self.selected.min(self.rows.len().saturating_sub(1));
}
fn threaded(&self, candidates: &[usize]) -> Vec<SessionRow> {
let by_path: HashMap<&str, usize> = candidates
.iter()
.map(|&i| (self.items[i].path.as_str(), i))
.collect();
let mut children: HashMap<usize, Vec<usize>> = HashMap::new();
let mut roots = Vec::new();
for &i in candidates {
match self.items[i]
.parent_path
.as_deref()
.and_then(|p| by_path.get(p))
{
Some(&parent) if parent != i => children.entry(parent).or_default().push(i),
_ => roots.push(i),
}
}
let newest =
|v: &mut Vec<usize>| v.sort_by_key(|&i| std::cmp::Reverse(self.items[i].modified_ms));
newest(&mut roots);
for list in children.values_mut() {
newest(list);
}
let mut rows = Vec::new();
let mut stack: Vec<(usize, usize, Vec<bool>, bool)> = roots
.iter()
.rev()
.map(|&r| (r, 0, Vec::new(), true))
.collect();
let mut seen = std::collections::HashSet::new();
while let Some((index, depth, ancestors, is_last)) = stack.pop() {
if !seen.insert(index) {
continue;
}
let prefix = if depth == 0 {
String::new()
} else {
let mut p: String = ancestors
.iter()
.map(|&continues| if continues { "│ " } else { " " })
.collect();
p.push_str(if is_last { "└─ " } else { "├─ " });
p
};
rows.push(SessionRow { index, prefix });
if let Some(kids) = children.get(&index) {
let mut next_ancestors = ancestors.clone();
if depth > 0 {
next_ancestors.push(!is_last);
}
for (k, &child) in kids.iter().enumerate().rev() {
stack.push((
child,
depth + 1,
next_ancestors.clone(),
k == kids.len() - 1,
));
}
}
}
rows
}
#[must_use]
pub const fn title(&self) -> &'static str {
match self.scope {
Scope::Current => "Resume Session (Current Folder)",
Scope::All => "Resume Session (All)",
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn item(path: &str, parent: Option<&str>, modified: i64, name: Option<&str>) -> SessionItem {
SessionItem {
path: path.into(),
id: path.into(),
parent_path: parent.map(str::to_owned),
modified_ms: modified,
name: name.map(str::to_owned),
first_message: format!("msg {path}"),
..SessionItem::default()
}
}
#[test]
fn threaded_named_and_search() {
let mut model = SessionListModel::new(Scope::Current, None);
model.set_items(vec![
item("a", None, 10, None),
item("b", Some("a"), 30, Some("fork")),
item("c", None, 20, None),
]);
let order: Vec<(&str, &str)> = model
.rows()
.iter()
.map(|r| {
(
model.item(r.index).unwrap().path.as_str(),
r.prefix.as_str(),
)
})
.collect();
assert_eq!(order, vec![("c", ""), ("a", ""), ("b", "└─ ")]);
model.toggle_named();
assert_eq!(model.rows().len(), 1);
model.toggle_named();
model.toggle_sort();
assert_eq!(model.sort, SortMode::Recent);
assert_eq!(model.selected_item().unwrap().path, "b");
model.set_query("msg c");
assert_eq!(model.rows().len(), 1);
}
}