1use crate::session::{Session, delete_session, human_time_ago};
2
3#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4pub enum ProviderFilter {
5 All,
6 Copilot,
7 Claude,
8}
9
10impl ProviderFilter {
11 pub fn label(self) -> &'static str {
12 match self {
13 Self::All => "All",
14 Self::Copilot => "Copilot",
15 Self::Claude => "Claude Code",
16 }
17 }
18
19 fn matches(self, provider: &str) -> bool {
20 match self {
21 Self::All => true,
22 Self::Copilot => provider == "Copilot",
23 Self::Claude => provider == "Claude Code",
24 }
25 }
26
27 fn next(self) -> Self {
28 match self {
29 Self::All => Self::Copilot,
30 Self::Copilot => Self::Claude,
31 Self::Claude => Self::All,
32 }
33 }
34}
35
36pub struct App {
37 pub sessions: Vec<Session>,
38 pub filtered: Vec<usize>,
39 pub selected: usize,
40 pub preview_scroll: usize,
41 pub search_query: String,
42 pub search_active: bool,
43 pub mode: Mode,
44 pub provider_filter: ProviderFilter,
45 pub confirm_delete: bool,
46 pub should_quit: bool,
47 pub resume_session: Option<String>,
48}
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub enum Mode {
52 Browse,
53 Search,
54}
55
56impl App {
57 pub fn new(sessions: Vec<Session>) -> Self {
58 let filtered: Vec<usize> = (0..sessions.len()).collect();
59 Self {
60 sessions,
61 filtered,
62 selected: 0,
63 preview_scroll: 0,
64 search_query: String::new(),
65 search_active: false,
66 mode: Mode::Browse,
67 provider_filter: ProviderFilter::All,
68 confirm_delete: false,
69 should_quit: false,
70 resume_session: None,
71 }
72 }
73
74 pub fn selected_session(&self) -> Option<&Session> {
75 self.filtered
76 .get(self.selected)
77 .and_then(|&idx| self.sessions.get(idx))
78 }
79
80 pub fn move_up(&mut self) {
81 if self.selected > 0 {
82 self.selected -= 1;
83 self.preview_scroll = 0;
84 self.confirm_delete = false;
85 }
86 }
87
88 pub fn move_down(&mut self) {
89 if self.selected + 1 < self.filtered.len() {
90 self.selected += 1;
91 self.preview_scroll = 0;
92 self.confirm_delete = false;
93 }
94 }
95
96 pub fn scroll_preview_up(&mut self) {
97 self.preview_scroll = self.preview_scroll.saturating_sub(3);
98 }
99
100 pub fn scroll_preview_down(&mut self) {
101 self.preview_scroll += 3;
102 }
103
104 pub fn resume_selected(&mut self) {
105 if let Some(session) = self.selected_session() {
106 self.resume_session = Some(session.id.clone());
107 self.should_quit = true;
108 }
109 }
110
111 pub fn delete_selected(&mut self) {
112 if !self.confirm_delete {
113 self.confirm_delete = true;
114 return;
115 }
116
117 let Some(&idx) = self.filtered.get(self.selected) else {
118 return;
119 };
120
121 if delete_session(&self.sessions[idx]).is_ok() {
122 self.sessions.remove(idx);
123 self.apply_filter();
124 if self.selected >= self.filtered.len() && self.selected > 0 {
125 self.selected -= 1;
126 }
127 }
128 self.confirm_delete = false;
129 }
130
131 pub fn enter_search(&mut self) {
132 self.mode = Mode::Search;
133 self.search_active = true;
134 }
135
136 pub fn exit_search(&mut self) {
137 self.mode = Mode::Browse;
138 self.search_active = false;
139 }
140
141 pub fn clear_search(&mut self) {
142 self.search_query.clear();
143 self.apply_filter();
144 self.exit_search();
145 }
146
147 pub fn search_input(&mut self, c: char) {
148 self.search_query.push(c);
149 self.apply_filter();
150 }
151
152 pub fn search_backspace(&mut self) {
153 self.search_query.pop();
154 self.apply_filter();
155 }
156
157 pub fn cycle_provider_filter(&mut self) {
158 self.provider_filter = self.provider_filter.next();
159 self.apply_filter();
160 }
161
162 pub fn apply_filter(&mut self) {
163 let query = self.search_query.to_lowercase();
164 self.filtered = self
165 .sessions
166 .iter()
167 .enumerate()
168 .filter(|(_, s)| self.provider_filter.matches(&s.provider))
169 .filter(|(_, s)| {
170 query.is_empty()
171 || s.summary.to_lowercase().contains(&query)
172 || s.cwd.to_lowercase().contains(&query)
173 || s.user_messages
174 .iter()
175 .any(|m| m.to_lowercase().contains(&query))
176 })
177 .map(|(i, _)| i)
178 .collect();
179
180 if self.selected >= self.filtered.len() {
181 self.selected = self.filtered.len().saturating_sub(1);
182 }
183 self.preview_scroll = 0;
184 }
185
186 pub fn build_preview_lines(&self) -> Vec<PreviewLine> {
187 let Some(session) = self.selected_session() else {
188 return vec![PreviewLine::dim("No session selected".into())];
189 };
190
191 let mut lines = Vec::new();
192
193 lines.push(PreviewLine::header(session.summary.clone()));
194 lines.push(PreviewLine::empty());
195
196 let created = session.created_at.format("%b %d, %Y %H:%M").to_string();
197 let updated = human_time_ago(&session.updated_at);
198 lines.push(PreviewLine::label_value("Provider", &session.provider));
199 lines.push(PreviewLine::label_value("Created", &created));
200 lines.push(PreviewLine::label_value("Updated", &updated));
201 lines.push(PreviewLine::label_value("Directory", &session.cwd));
202 lines.push(PreviewLine::label_value("Session ID", &session.id));
203
204 if !session.checkpoints.is_empty() {
205 lines.push(PreviewLine::empty());
206 lines.push(PreviewLine::section(format!(
207 "── Checkpoints ({}) ──",
208 session.checkpoints.len()
209 )));
210 for (i, cp) in session.checkpoints.iter().enumerate() {
211 lines.push(PreviewLine::normal(format!(" {}. {}", i + 1, cp.title)));
212 }
213 }
214
215 if !session.user_messages.is_empty() {
216 lines.push(PreviewLine::empty());
217 lines.push(PreviewLine::section(format!(
218 "── Messages ({}) ──",
219 session.user_messages.len()
220 )));
221 for msg in session.user_messages.iter().take(10) {
222 lines.push(PreviewLine::dim(format!(" > {msg}")));
223 }
224 if session.user_messages.len() > 10 {
225 lines.push(PreviewLine::dim(format!(
226 " ... and {} more",
227 session.user_messages.len() - 10
228 )));
229 }
230 }
231
232 if !session.task_summaries.is_empty() {
233 lines.push(PreviewLine::empty());
234 lines.push(PreviewLine::section("── Completed Tasks ──".into()));
235 for summary in &session.task_summaries {
236 lines.push(PreviewLine::normal(format!(" ✓ {summary}")));
237 }
238 }
239
240 lines
241 }
242}
243
244#[derive(Debug, Clone)]
245pub struct PreviewLine {
246 pub text: String,
247 pub style: LineStyle,
248}
249
250#[derive(Debug, Clone, Copy, PartialEq, Eq)]
251pub enum LineStyle {
252 Header,
253 Section,
254 Label,
255 Normal,
256 Dim,
257 Empty,
258}
259
260impl PreviewLine {
261 pub fn header(text: String) -> Self {
262 Self {
263 text,
264 style: LineStyle::Header,
265 }
266 }
267
268 pub fn section(text: String) -> Self {
269 Self {
270 text,
271 style: LineStyle::Section,
272 }
273 }
274
275 pub fn label_value(label: &str, value: &str) -> Self {
276 Self {
277 text: format!("{label}: {value}"),
278 style: LineStyle::Label,
279 }
280 }
281
282 pub fn normal(text: String) -> Self {
283 Self {
284 text,
285 style: LineStyle::Normal,
286 }
287 }
288
289 pub fn dim(text: String) -> Self {
290 Self {
291 text,
292 style: LineStyle::Dim,
293 }
294 }
295
296 pub fn empty() -> Self {
297 Self {
298 text: String::new(),
299 style: LineStyle::Empty,
300 }
301 }
302}