use ratatui::layout::Constraint::{Length, Min};
use ratatui::prelude::*;
use ratatui::widgets::{Clear, List, ListItem, Paragraph};
use shuvarie_core::SessionSummary;
use termina::event::{KeyCode, KeyEvent};
use crate::tui::utils::ctrl;
use super::add_provider::centered_rect;
use super::list::{render_list_item, render_list_item_dim, scroll_offset_for};
use super::theme;
pub enum SessionPickerMessage {
Next,
Prev,
Select,
ToggleDelete,
CancelDelete,
NewSession,
Close,
Resize { viewport_height: u16 },
}
pub enum SessionPickerEffect {
LoadSession { id: uuid::Uuid },
DeleteSession { id: uuid::Uuid },
NewSession,
Close,
}
pub struct SessionPicker {
pub open: bool,
pub sessions: Vec<SessionSummary>,
pub selected: usize,
pub offset: usize,
viewport_height: u16,
pub confirm_delete: bool,
pub active_id: Option<uuid::Uuid>,
pub loading: bool,
}
impl SessionPicker {
pub fn new() -> Self {
Self {
open: false,
sessions: Vec::new(),
selected: 0,
offset: 0,
viewport_height: 0,
confirm_delete: false,
active_id: None,
loading: false,
}
}
pub fn open(&mut self, active_id: Option<uuid::Uuid>) {
self.open = true;
self.sessions.clear();
self.selected = 0;
self.offset = 0;
self.confirm_delete = false;
self.active_id = active_id;
self.loading = true;
}
pub fn close(&mut self) {
self.open = false;
self.confirm_delete = false;
self.loading = false;
}
pub fn set_sessions(&mut self, sessions: Vec<SessionSummary>) {
self.sessions = sessions;
self.loading = false;
self.selected = self.selected.min(self.sessions.len().saturating_sub(1));
self.recompute_offset();
}
pub fn session_after(&self, id: uuid::Uuid) -> Option<uuid::Uuid> {
let idx = self.sessions.iter().position(|s| s.id == id)?;
let next = idx + 1;
if next < self.sessions.len() {
Some(self.sessions[next].id)
} else if idx > 0 {
Some(self.sessions[idx - 1].id)
} else {
None
}
}
fn next(&mut self) {
if !self.sessions.is_empty() {
self.selected = (self.selected + 1).min(self.sessions.len() - 1);
self.recompute_offset();
}
}
fn prev(&mut self) {
if !self.sessions.is_empty() {
self.selected = self.selected.saturating_sub(1);
self.recompute_offset();
}
}
fn recompute_offset(&mut self) {
let vh = self.viewport_height as usize;
let len = self.sessions.len();
self.offset = scroll_offset_for(self.selected, self.offset, vh, len);
}
pub fn map_event(&self, key: &KeyEvent) -> Option<SessionPickerMessage> {
if self.confirm_delete {
return match key.code {
KeyCode::Char('d') if ctrl(key) => Some(SessionPickerMessage::ToggleDelete),
KeyCode::Escape => Some(SessionPickerMessage::CancelDelete),
_ => None,
};
}
if ctrl(key) {
return match key.code {
KeyCode::Char('n') => Some(SessionPickerMessage::Next),
KeyCode::Char('p') => Some(SessionPickerMessage::Prev),
KeyCode::Char('d') => Some(SessionPickerMessage::ToggleDelete),
_ => None,
};
}
match key.code {
KeyCode::Escape => Some(SessionPickerMessage::Close),
KeyCode::Down => Some(SessionPickerMessage::Next),
KeyCode::Up => Some(SessionPickerMessage::Prev),
KeyCode::Enter => Some(SessionPickerMessage::Select),
KeyCode::Char('n') => Some(SessionPickerMessage::NewSession),
_ => None,
}
}
pub fn update(&mut self, msg: SessionPickerMessage) -> Option<SessionPickerEffect> {
if !self.open && !matches!(msg, SessionPickerMessage::Close) {
return None;
}
match msg {
SessionPickerMessage::Close => {
self.close();
Some(SessionPickerEffect::Close)
}
SessionPickerMessage::Next => {
self.next();
None
}
SessionPickerMessage::Prev => {
self.prev();
None
}
SessionPickerMessage::Select => {
if let Some(s) = self.sessions.get(self.selected) {
if s.in_use {
return None;
}
let id = s.id;
self.close();
Some(SessionPickerEffect::LoadSession { id })
} else {
None
}
}
SessionPickerMessage::ToggleDelete => {
if self.sessions.is_empty() {
return None;
}
if self.sessions[self.selected].in_use {
return None;
}
if self.confirm_delete {
let id = self.sessions[self.selected].id;
self.confirm_delete = false;
Some(SessionPickerEffect::DeleteSession { id })
} else {
self.confirm_delete = true;
None
}
}
SessionPickerMessage::CancelDelete => {
self.confirm_delete = false;
None
}
SessionPickerMessage::NewSession => {
self.close();
Some(SessionPickerEffect::NewSession)
}
SessionPickerMessage::Resize { viewport_height } => {
if self.viewport_height != viewport_height {
self.viewport_height = viewport_height;
self.recompute_offset();
}
None
}
}
}
pub fn view(&self, frame: &mut Frame<'_>, area: Rect) {
if !self.open {
return;
}
let popup = centered_rect(64, 36, area);
frame.render_widget(Clear, popup);
let block = theme::overlay_block("Sessions");
let inner = block.inner(popup);
frame.render_widget(block, popup);
let [list_area, hint_area] = Layout::vertical([Min(0), Length(1)]).areas(inner);
if self.loading && self.sessions.is_empty() {
frame.render_widget(
Paragraph::new(Line::from(vec![
super::spinner::spinner(),
Span::raw(" Loading sessions...").fg(theme::text_muted()),
])),
list_area,
);
} else {
let offset = scroll_offset_for(
self.selected,
self.offset,
list_area.height as usize,
self.sessions.len(),
);
let visible: Vec<ListItem> = self
.sessions
.iter()
.enumerate()
.skip(offset)
.take(list_area.height as usize)
.map(|(idx, s)| {
let marker = theme::active_marker(Some(s.id) == self.active_id);
if s.in_use {
render_list_item_dim(
format!(
"{marker}{} · {} msgs · used elsewhere",
s.title, s.message_count
),
idx == self.selected,
)
} else {
render_list_item(
format!("{marker}{} · {} msgs", s.title, s.message_count),
idx == self.selected,
)
}
})
.collect();
frame.render_widget(List::new(visible), list_area);
}
let locked_selected = self.sessions.get(self.selected).is_some_and(|s| s.in_use);
let hint = if self.confirm_delete {
theme::help_line(&[("Ctrl+D", "confirm delete"), ("Esc", "cancel")])
} else if self.sessions.is_empty() {
theme::help_line(&[("N", "new session"), ("Esc", "close")])
} else if locked_selected {
theme::help_line(&[("N", "new"), ("Esc", "close")])
} else {
theme::help_line(&[
("Enter", "resume"),
("Ctrl+D", "delete"),
("N", "new"),
("Esc", "close"),
])
};
frame.render_widget(Paragraph::new(hint).fg(theme::text_muted()), hint_area);
}
}
impl Default for SessionPicker {
fn default() -> Self {
Self::new()
}
}