agcodex_tui/dialogs/
load_session.rs

1//! Enhanced load session browser for AGCodex TUI
2//! Provides a rich interface for browsing, searching, and loading saved sessions
3
4use agcodex_persistence::types::OperatingMode;
5use agcodex_persistence::types::SessionMetadata;
6use chrono::DateTime;
7use chrono::Datelike;
8use chrono::Local;
9use fuzzy_matcher::FuzzyMatcher;
10use fuzzy_matcher::skim::SkimMatcherV2;
11use ratatui::buffer::Buffer;
12use ratatui::crossterm::event::KeyCode;
13use ratatui::crossterm::event::KeyEvent;
14use ratatui::crossterm::event::KeyModifiers;
15use ratatui::layout::Alignment;
16use ratatui::layout::Constraint;
17use ratatui::layout::Direction;
18use ratatui::layout::Layout;
19use ratatui::layout::Rect;
20use ratatui::style::Color;
21use ratatui::style::Modifier;
22use ratatui::style::Style;
23use ratatui::text::Line;
24use ratatui::text::Span;
25use ratatui::widgets::Block;
26use ratatui::widgets::BorderType;
27use ratatui::widgets::Borders;
28use ratatui::widgets::Clear;
29use ratatui::widgets::List;
30use ratatui::widgets::ListItem;
31use ratatui::widgets::Paragraph;
32use ratatui::widgets::Scrollbar;
33use ratatui::widgets::ScrollbarOrientation;
34use ratatui::widgets::ScrollbarState;
35use ratatui::widgets::StatefulWidget;
36use ratatui::widgets::Widget;
37use ratatui::widgets::WidgetRef;
38use ratatui::widgets::Wrap;
39use std::collections::HashMap;
40use uuid::Uuid;
41
42/// Session item for display in the load browser
43#[derive(Debug, Clone)]
44pub struct SessionItem {
45    pub metadata: SessionMetadata,
46    pub display_name: String,
47    pub formatted_date: String,
48    pub mode_indicator: String,
49    pub mode_color: Color,
50    pub preview_lines: Vec<String>,
51    pub match_score: Option<i64>,
52    pub match_indices: Vec<usize>,
53}
54
55impl SessionItem {
56    pub fn new(metadata: SessionMetadata) -> Self {
57        let local_time: DateTime<Local> = metadata.updated_at.into();
58        let formatted_date = format_date(&local_time);
59
60        let (mode_indicator, mode_color) = match metadata.current_mode {
61            OperatingMode::Plan => ("📋 Plan", Color::Blue),
62            OperatingMode::Build => ("🔨 Build", Color::Green),
63            OperatingMode::Review => ("🔍 Review", Color::Yellow),
64        };
65
66        let display_name = if metadata.title.is_empty() {
67            format!("Session {}", &metadata.id.to_string()[0..8])
68        } else {
69            metadata.title.clone()
70        };
71
72        // Generate preview lines
73        let preview_lines = vec![
74            format!("Model: {}", metadata.model),
75            format!(
76                "Messages: {} • Turns: {}",
77                metadata.message_count, metadata.turn_count
78            ),
79            format!(
80                "Size: {} • Compression: {:.0}%",
81                format_file_size(metadata.file_size),
82                metadata.compression_ratio * 100.0
83            ),
84        ];
85
86        Self {
87            metadata,
88            display_name,
89            formatted_date,
90            mode_indicator: mode_indicator.to_string(),
91            mode_color,
92            preview_lines,
93            match_score: None,
94            match_indices: Vec::new(),
95        }
96    }
97
98    /// Update match score and indices for fuzzy search
99    pub fn update_match(&mut self, score: i64, indices: Vec<usize>) {
100        self.match_score = Some(score);
101        self.match_indices = indices;
102    }
103
104    /// Clear match information
105    pub fn clear_match(&mut self) {
106        self.match_score = None;
107        self.match_indices.clear();
108    }
109}
110
111/// State for the load session browser
112pub struct LoadSessionState {
113    /// Search query for filtering
114    pub search_query: String,
115    /// Cursor position in search field
116    pub search_cursor: usize,
117    /// All available sessions
118    pub all_sessions: Vec<SessionItem>,
119    /// Filtered and sorted sessions
120    pub filtered_sessions: Vec<SessionItem>,
121    /// Currently selected index
122    pub selected_index: usize,
123    /// Scroll offset for the list
124    pub scroll_offset: usize,
125    /// Focus state (0: search, 1: list)
126    pub focus: LoadFocus,
127    /// Loading state
128    pub loading: bool,
129    /// Error message
130    pub error: Option<String>,
131    /// Preview expanded
132    pub preview_expanded: bool,
133    /// Sort order
134    pub sort_by: SortOrder,
135    /// Filter by mode
136    pub mode_filter: Option<OperatingMode>,
137    /// Favorite sessions
138    pub favorites: HashMap<Uuid, bool>,
139    /// Fuzzy matcher for search
140    fuzzy_matcher: SkimMatcherV2,
141}
142
143impl std::fmt::Debug for LoadSessionState {
144    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
145        f.debug_struct("LoadSessionState")
146            .field("search_query", &self.search_query)
147            .field("search_cursor", &self.search_cursor)
148            .field("all_sessions", &self.all_sessions)
149            .field("filtered_sessions", &self.filtered_sessions)
150            .field("selected_index", &self.selected_index)
151            .field("scroll_offset", &self.scroll_offset)
152            .field("focus", &self.focus)
153            .field("loading", &self.loading)
154            .field("error", &self.error)
155            .field("preview_expanded", &self.preview_expanded)
156            .field("sort_by", &self.sort_by)
157            .field("mode_filter", &self.mode_filter)
158            .field("favorites", &self.favorites)
159            .field("fuzzy_matcher", &"<SkimMatcherV2>")
160            .finish()
161    }
162}
163
164#[derive(Debug, Clone, Copy, PartialEq)]
165pub enum LoadFocus {
166    Search,
167    List,
168}
169
170#[derive(Debug, Clone, Copy, PartialEq)]
171pub enum SortOrder {
172    Recent,
173    Name,
174    Size,
175    Messages,
176}
177
178impl LoadSessionState {
179    pub fn new() -> Self {
180        Self {
181            search_query: String::new(),
182            search_cursor: 0,
183            all_sessions: Vec::new(),
184            filtered_sessions: Vec::new(),
185            selected_index: 0,
186            scroll_offset: 0,
187            focus: LoadFocus::List,
188            loading: false,
189            error: None,
190            preview_expanded: true,
191            sort_by: SortOrder::Recent,
192            mode_filter: None,
193            favorites: HashMap::new(),
194            fuzzy_matcher: SkimMatcherV2::default(),
195        }
196    }
197
198    /// Set sessions from the manager
199    pub fn set_sessions(&mut self, sessions: Vec<SessionMetadata>) {
200        self.all_sessions = sessions.into_iter().map(SessionItem::new).collect();
201        self.loading = false;
202        self.error = None;
203        self.apply_filters();
204    }
205
206    /// Set loading state
207    pub fn set_loading(&mut self, loading: bool) {
208        self.loading = loading;
209        if loading {
210            self.error = None;
211        }
212    }
213
214    /// Set error message
215    pub fn set_error(&mut self, error: String) {
216        self.error = Some(error);
217        self.loading = false;
218    }
219
220    /// Apply search and filters
221    pub fn apply_filters(&mut self) {
222        let mut filtered = self.all_sessions.clone();
223
224        // Apply mode filter
225        if let Some(mode) = self.mode_filter {
226            filtered.retain(|s| s.metadata.current_mode == mode);
227        }
228
229        // Apply fuzzy search
230        if !self.search_query.is_empty() {
231            for session in &mut filtered {
232                let search_text = format!(
233                    "{} {} {} {}",
234                    session.display_name,
235                    session.metadata.model,
236                    session.formatted_date,
237                    session.preview_lines.join(" ")
238                );
239
240                if let Some(result) = self
241                    .fuzzy_matcher
242                    .fuzzy_match(&search_text, &self.search_query)
243                {
244                    let indices = self
245                        .fuzzy_matcher
246                        .fuzzy_indices(&search_text, &self.search_query)
247                        .map(|(_, indices)| indices)
248                        .unwrap_or_default();
249                    session.update_match(result, indices);
250                } else {
251                    session.clear_match();
252                }
253            }
254
255            // Filter out non-matches and sort by score
256            filtered.retain(|s| s.match_score.is_some());
257            filtered.sort_by(|a, b| b.match_score.unwrap_or(0).cmp(&a.match_score.unwrap_or(0)));
258        } else {
259            // Clear match scores
260            for session in &mut filtered {
261                session.clear_match();
262            }
263
264            // Apply sort order
265            match self.sort_by {
266                SortOrder::Recent => {
267                    filtered.sort_by(|a, b| b.metadata.updated_at.cmp(&a.metadata.updated_at));
268                }
269                SortOrder::Name => {
270                    filtered.sort_by(|a, b| a.display_name.cmp(&b.display_name));
271                }
272                SortOrder::Size => {
273                    filtered.sort_by(|a, b| b.metadata.file_size.cmp(&a.metadata.file_size));
274                }
275                SortOrder::Messages => {
276                    filtered
277                        .sort_by(|a, b| b.metadata.message_count.cmp(&a.metadata.message_count));
278                }
279            }
280        }
281
282        // Move favorites to top
283        filtered.sort_by(|a, b| {
284            let a_fav = self.favorites.get(&a.metadata.id).copied().unwrap_or(false);
285            let b_fav = self.favorites.get(&b.metadata.id).copied().unwrap_or(false);
286            b_fav.cmp(&a_fav)
287        });
288
289        self.filtered_sessions = filtered;
290        self.selected_index = 0;
291        self.scroll_offset = 0;
292    }
293
294    /// Handle key input
295    pub fn handle_key_event(&mut self, key: KeyEvent) -> LoadSessionAction {
296        match self.focus {
297            LoadFocus::Search => self.handle_search_key(key),
298            LoadFocus::List => self.handle_list_key(key),
299        }
300    }
301
302    fn handle_search_key(&mut self, key: KeyEvent) -> LoadSessionAction {
303        match key.code {
304            KeyCode::Esc => {
305                if self.search_query.is_empty() {
306                    LoadSessionAction::Cancel
307                } else {
308                    self.search_query.clear();
309                    self.search_cursor = 0;
310                    self.apply_filters();
311                    LoadSessionAction::None
312                }
313            }
314            KeyCode::Enter | KeyCode::Down | KeyCode::Tab => {
315                self.focus = LoadFocus::List;
316                LoadSessionAction::None
317            }
318            KeyCode::Char(c) => {
319                if self.search_query.len() < 100 {
320                    self.search_query.insert(self.search_cursor, c);
321                    self.search_cursor += 1;
322                    self.apply_filters();
323                }
324                LoadSessionAction::None
325            }
326            KeyCode::Backspace => {
327                if self.search_cursor > 0 {
328                    self.search_cursor -= 1;
329                    self.search_query.remove(self.search_cursor);
330                    self.apply_filters();
331                }
332                LoadSessionAction::None
333            }
334            KeyCode::Delete => {
335                if self.search_cursor < self.search_query.len() {
336                    self.search_query.remove(self.search_cursor);
337                    self.apply_filters();
338                }
339                LoadSessionAction::None
340            }
341            KeyCode::Left => {
342                if self.search_cursor > 0 {
343                    self.search_cursor -= 1;
344                }
345                LoadSessionAction::None
346            }
347            KeyCode::Right => {
348                if self.search_cursor < self.search_query.len() {
349                    self.search_cursor += 1;
350                }
351                LoadSessionAction::None
352            }
353            KeyCode::Home => {
354                self.search_cursor = 0;
355                LoadSessionAction::None
356            }
357            KeyCode::End => {
358                self.search_cursor = self.search_query.len();
359                LoadSessionAction::None
360            }
361            _ => LoadSessionAction::None,
362        }
363    }
364
365    fn handle_list_key(&mut self, key: KeyEvent) -> LoadSessionAction {
366        match key.code {
367            KeyCode::Esc => LoadSessionAction::Cancel,
368            KeyCode::Enter => {
369                if let Some(session) = self.get_selected_session() {
370                    LoadSessionAction::Load(session.metadata.id)
371                } else {
372                    LoadSessionAction::None
373                }
374            }
375            KeyCode::Tab | KeyCode::Char('/') => {
376                self.focus = LoadFocus::Search;
377                LoadSessionAction::None
378            }
379            KeyCode::Up | KeyCode::Char('k') if key.modifiers.is_empty() => {
380                self.move_selection(-1);
381                LoadSessionAction::None
382            }
383            KeyCode::Down | KeyCode::Char('j') if key.modifiers.is_empty() => {
384                self.move_selection(1);
385                LoadSessionAction::None
386            }
387            KeyCode::PageUp => {
388                self.move_selection(-10);
389                LoadSessionAction::None
390            }
391            KeyCode::PageDown => {
392                self.move_selection(10);
393                LoadSessionAction::None
394            }
395            KeyCode::Home => {
396                self.selected_index = 0;
397                self.scroll_offset = 0;
398                LoadSessionAction::None
399            }
400            KeyCode::End => {
401                if !self.filtered_sessions.is_empty() {
402                    self.selected_index = self.filtered_sessions.len() - 1;
403                    self.update_scroll(10);
404                }
405                LoadSessionAction::None
406            }
407            KeyCode::Char('f') if key.modifiers == KeyModifiers::CONTROL => {
408                // Toggle favorite
409                if let Some(session) = self.get_selected_session() {
410                    let is_fav = self
411                        .favorites
412                        .get(&session.metadata.id)
413                        .copied()
414                        .unwrap_or(false);
415                    self.favorites.insert(session.metadata.id, !is_fav);
416                    self.apply_filters();
417                }
418                LoadSessionAction::None
419            }
420            KeyCode::Char('s') if key.modifiers == KeyModifiers::CONTROL => {
421                // Cycle sort order
422                self.sort_by = match self.sort_by {
423                    SortOrder::Recent => SortOrder::Name,
424                    SortOrder::Name => SortOrder::Size,
425                    SortOrder::Size => SortOrder::Messages,
426                    SortOrder::Messages => SortOrder::Recent,
427                };
428                self.apply_filters();
429                LoadSessionAction::None
430            }
431            KeyCode::Char('m') if key.modifiers == KeyModifiers::CONTROL => {
432                // Cycle mode filter
433                self.mode_filter = match self.mode_filter {
434                    None => Some(OperatingMode::Plan),
435                    Some(OperatingMode::Plan) => Some(OperatingMode::Build),
436                    Some(OperatingMode::Build) => Some(OperatingMode::Review),
437                    Some(OperatingMode::Review) => None,
438                };
439                self.apply_filters();
440                LoadSessionAction::None
441            }
442            KeyCode::Char('p') if key.modifiers == KeyModifiers::CONTROL => {
443                // Toggle preview
444                self.preview_expanded = !self.preview_expanded;
445                LoadSessionAction::None
446            }
447            KeyCode::Delete | KeyCode::Char('d') if key.modifiers == KeyModifiers::CONTROL => {
448                // Delete session
449                if let Some(session) = self.get_selected_session() {
450                    LoadSessionAction::Delete(session.metadata.id)
451                } else {
452                    LoadSessionAction::None
453                }
454            }
455            _ => LoadSessionAction::None,
456        }
457    }
458
459    fn move_selection(&mut self, delta: i32) {
460        if self.filtered_sessions.is_empty() {
461            return;
462        }
463
464        let len = self.filtered_sessions.len() as i32;
465        let new_index = (self.selected_index as i32 + delta).clamp(0, len - 1) as usize;
466        self.selected_index = new_index;
467        self.update_scroll(10);
468    }
469
470    const fn update_scroll(&mut self, visible_items: usize) {
471        if self.selected_index < self.scroll_offset {
472            self.scroll_offset = self.selected_index;
473        } else if self.selected_index >= self.scroll_offset + visible_items {
474            self.scroll_offset = self.selected_index.saturating_sub(visible_items - 1);
475        }
476    }
477
478    /// Get the currently selected session
479    pub fn get_selected_session(&self) -> Option<&SessionItem> {
480        self.filtered_sessions.get(self.selected_index)
481    }
482
483    /// Get selected session ID
484    pub fn get_selected_id(&self) -> Option<Uuid> {
485        self.get_selected_session().map(|s| s.metadata.id)
486    }
487}
488
489impl Default for LoadSessionState {
490    fn default() -> Self {
491        Self::new()
492    }
493}
494
495/// Actions from the load session browser
496#[derive(Debug, Clone, PartialEq)]
497pub enum LoadSessionAction {
498    None,
499    Load(Uuid),
500    Delete(Uuid),
501    Cancel,
502}
503
504/// Enhanced Load Session Browser widget
505pub struct LoadSessionBrowser<'a> {
506    state: &'a LoadSessionState,
507}
508
509impl<'a> LoadSessionBrowser<'a> {
510    pub const fn new(state: &'a LoadSessionState) -> Self {
511        Self { state }
512    }
513
514    fn render_search_bar(&self, area: Rect, buf: &mut Buffer) {
515        let search_block = Block::default()
516            .borders(Borders::ALL)
517            .border_type(BorderType::Rounded)
518            .border_style(if self.state.focus == LoadFocus::Search {
519                Style::default().fg(Color::Cyan)
520            } else {
521                Style::default().fg(Color::DarkGray)
522            })
523            .title(" Search ");
524
525        let inner = search_block.inner(area);
526        search_block.render(area, buf);
527
528        let search_text = if self.state.search_query.is_empty() {
529            Span::styled(
530                "Type to search sessions...",
531                Style::default()
532                    .fg(Color::DarkGray)
533                    .add_modifier(Modifier::ITALIC),
534            )
535        } else {
536            Span::raw(&self.state.search_query)
537        };
538
539        let paragraph = Paragraph::new(Line::from(search_text));
540        paragraph.render(inner, buf);
541
542        // Render cursor in search mode
543        if self.state.focus == LoadFocus::Search && inner.width > 0 {
544            let cursor_x = inner.x + (self.state.search_cursor as u16).min(inner.width - 1);
545            if cursor_x < inner.right()
546                && let Some(cell) = buf.cell_mut((cursor_x, inner.y))
547            {
548                cell.set_style(Style::default().bg(Color::White).fg(Color::Black));
549            }
550        }
551    }
552
553    fn render_session_list(&self, area: Rect, buf: &mut Buffer) {
554        let list_block = Block::default()
555            .borders(Borders::ALL)
556            .border_type(BorderType::Rounded)
557            .border_style(if self.state.focus == LoadFocus::List {
558                Style::default().fg(Color::Cyan)
559            } else {
560                Style::default().fg(Color::DarkGray)
561            })
562            .title(format!(
563                " Sessions ({}) - Sort: {:?} {}",
564                self.state.filtered_sessions.len(),
565                self.state.sort_by,
566                if let Some(mode) = self.state.mode_filter {
567                    format!("- Filter: {:?}", mode)
568                } else {
569                    String::new()
570                }
571            ));
572
573        let inner = list_block.inner(area);
574        list_block.render(area, buf);
575
576        if self.state.loading {
577            let loading = Paragraph::new("Loading sessions...")
578                .style(Style::default().fg(Color::Yellow))
579                .alignment(Alignment::Center);
580            loading.render(inner, buf);
581            return;
582        }
583
584        if let Some(ref error) = self.state.error {
585            let error_text = format!("Error: {}", error);
586            let error_paragraph = Paragraph::new(error_text)
587                .style(Style::default().fg(Color::Red))
588                .alignment(Alignment::Center)
589                .wrap(Wrap { trim: true });
590            error_paragraph.render(inner, buf);
591            return;
592        }
593
594        if self.state.filtered_sessions.is_empty() {
595            let empty_text = if self.state.all_sessions.is_empty() {
596                "No saved sessions found"
597            } else {
598                "No sessions match your search"
599            };
600            let empty = Paragraph::new(empty_text)
601                .style(Style::default().fg(Color::DarkGray))
602                .alignment(Alignment::Center);
603            empty.render(inner, buf);
604            return;
605        }
606
607        // Calculate visible range
608        let visible_height = inner.height as usize;
609        let end_index =
610            (self.state.scroll_offset + visible_height).min(self.state.filtered_sessions.len());
611
612        // Create list items
613        let items: Vec<ListItem> = self.state.filtered_sessions
614            [self.state.scroll_offset..end_index]
615            .iter()
616            .enumerate()
617            .map(|(i, session)| {
618                let is_selected = self.state.scroll_offset + i == self.state.selected_index;
619                let is_favorite = self
620                    .state
621                    .favorites
622                    .get(&session.metadata.id)
623                    .copied()
624                    .unwrap_or(false);
625
626                let mut spans = vec![];
627
628                // Favorite indicator
629                if is_favorite {
630                    spans.push(Span::styled("⭐ ", Style::default().fg(Color::Yellow)));
631                } else {
632                    spans.push(Span::raw("  "));
633                }
634
635                // Mode indicator
636                spans.push(Span::styled(
637                    &session.mode_indicator,
638                    Style::default().fg(session.mode_color),
639                ));
640                spans.push(Span::raw(" "));
641
642                // Session name
643                if !session.match_indices.is_empty() && !self.state.search_query.is_empty() {
644                    // Highlight matching characters
645                    let name_chars: Vec<char> = session.display_name.chars().collect();
646                    for (i, ch) in name_chars.iter().enumerate() {
647                        if session.match_indices.contains(&i) {
648                            spans.push(Span::styled(
649                                ch.to_string(),
650                                Style::default()
651                                    .fg(Color::Yellow)
652                                    .add_modifier(Modifier::BOLD),
653                            ));
654                        } else {
655                            spans.push(Span::raw(ch.to_string()));
656                        }
657                    }
658                } else {
659                    spans.push(Span::raw(&session.display_name));
660                }
661
662                // Date
663                spans.push(Span::raw(" - "));
664                spans.push(Span::styled(
665                    &session.formatted_date,
666                    Style::default().fg(Color::DarkGray),
667                ));
668
669                let style = if is_selected {
670                    Style::default().bg(Color::Rgb(40, 40, 40))
671                } else {
672                    Style::default()
673                };
674
675                ListItem::new(Line::from(spans)).style(style)
676            })
677            .collect();
678
679        let list = List::new(items);
680        Widget::render(list, inner, buf);
681
682        // Render scrollbar if needed
683        if self.state.filtered_sessions.len() > visible_height {
684            let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
685                .begin_symbol(Some("↑"))
686                .end_symbol(Some("↓"));
687
688            let mut scrollbar_state = ScrollbarState::new(self.state.filtered_sessions.len())
689                .position(self.state.scroll_offset);
690
691            StatefulWidget::render(scrollbar, inner, buf, &mut scrollbar_state);
692        }
693    }
694
695    fn render_preview(&self, area: Rect, buf: &mut Buffer) {
696        let preview_block = Block::default()
697            .borders(Borders::ALL)
698            .border_type(BorderType::Rounded)
699            .border_style(Style::default().fg(Color::DarkGray))
700            .title(" Preview ");
701
702        let inner = preview_block.inner(area);
703        preview_block.render(area, buf);
704
705        if let Some(session) = self.state.get_selected_session() {
706            let mut lines = vec![];
707
708            // Title
709            lines.push(Line::from(vec![Span::styled(
710                &session.display_name,
711                Style::default()
712                    .fg(Color::White)
713                    .add_modifier(Modifier::BOLD),
714            )]));
715
716            lines.push(Line::from(""));
717
718            // Metadata
719            for preview_line in &session.preview_lines {
720                lines.push(Line::from(preview_line.as_str()));
721            }
722
723            if !session.metadata.tags.is_empty() {
724                lines.push(Line::from(""));
725                lines.push(Line::from(vec![
726                    Span::raw("Tags: "),
727                    Span::styled(
728                        session.metadata.tags.join(", "),
729                        Style::default().fg(Color::Cyan),
730                    ),
731                ]));
732            }
733
734            let paragraph = Paragraph::new(lines).wrap(Wrap { trim: true });
735            paragraph.render(inner, buf);
736        } else {
737            let no_selection = Paragraph::new("Select a session to preview")
738                .style(Style::default().fg(Color::DarkGray))
739                .alignment(Alignment::Center);
740            no_selection.render(inner, buf);
741        }
742    }
743
744    fn render_help(&self, area: Rect, buf: &mut Buffer) {
745        let help_text = match self.state.focus {
746            LoadFocus::Search => "Esc: Clear/Cancel • Enter/↓: Focus List • /: Search",
747            LoadFocus::List => {
748                "↑↓: Navigate • Enter: Load • Del: Delete • Ctrl+F: Favorite • Ctrl+S: Sort • /: Search • Esc: Cancel"
749            }
750        };
751
752        let help = Paragraph::new(help_text)
753            .style(Style::default().fg(Color::DarkGray))
754            .alignment(Alignment::Center);
755        help.render(area, buf);
756    }
757}
758
759impl<'a> WidgetRef for LoadSessionBrowser<'a> {
760    fn render_ref(&self, area: Rect, buf: &mut Buffer) {
761        // Clear background
762        Clear.render(area, buf);
763
764        // Calculate dialog size
765        let width = area.width.min(100).max(60);
766        let height = area.height.min(30).max(15);
767        let x = (area.width.saturating_sub(width)) / 2;
768        let y = (area.height.saturating_sub(height)) / 2;
769        let dialog_area = Rect::new(x, y, width, height);
770
771        // Main dialog block
772        let dialog_block = Block::default()
773            .borders(Borders::ALL)
774            .border_type(BorderType::Double)
775            .border_style(Style::default().fg(Color::Blue))
776            .title(" Load Session ");
777
778        let inner = dialog_block.inner(dialog_area);
779        dialog_block.render(dialog_area, buf);
780
781        // Layout
782        let layout = if self.state.preview_expanded {
783            Layout::default()
784                .direction(Direction::Vertical)
785                .constraints([
786                    Constraint::Length(3),      // Search bar
787                    Constraint::Percentage(50), // Session list
788                    Constraint::Percentage(30), // Preview
789                    Constraint::Length(1),      // Help
790                ])
791                .split(inner)
792        } else {
793            Layout::default()
794                .direction(Direction::Vertical)
795                .constraints([
796                    Constraint::Length(3), // Search bar
797                    Constraint::Min(5),    // Session list
798                    Constraint::Length(1), // Help
799                ])
800                .split(inner)
801        };
802
803        self.render_search_bar(layout[0], buf);
804        self.render_session_list(layout[1], buf);
805
806        if self.state.preview_expanded {
807            self.render_preview(layout[2], buf);
808            self.render_help(layout[3], buf);
809        } else {
810            self.render_help(layout[2], buf);
811        }
812    }
813}
814
815// Helper functions
816fn format_date(date: &DateTime<Local>) -> String {
817    let now = Local::now();
818    let duration = now.signed_duration_since(*date);
819
820    if duration.num_seconds() < 60 {
821        "Just now".to_string()
822    } else if duration.num_minutes() < 60 {
823        format!("{} min ago", duration.num_minutes())
824    } else if duration.num_hours() < 24 {
825        format!("{} hours ago", duration.num_hours())
826    } else if duration.num_days() < 7 {
827        format!("{} days ago", duration.num_days())
828    } else if date.year() == now.year() {
829        date.format("%b %d").to_string()
830    } else {
831        date.format("%b %d, %Y").to_string()
832    }
833}
834
835fn format_file_size(bytes: u64) -> String {
836    const UNITS: &[&str] = &["B", "KB", "MB", "GB"];
837    let mut size = bytes as f64;
838    let mut unit_index = 0;
839
840    while size >= 1024.0 && unit_index < UNITS.len() - 1 {
841        size /= 1024.0;
842        unit_index += 1;
843    }
844
845    if unit_index == 0 {
846        format!("{} {}", bytes, UNITS[unit_index])
847    } else {
848        format!("{:.1} {}", size, UNITS[unit_index])
849    }
850}