1use 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#[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 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 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 pub fn clear_match(&mut self) {
106 self.match_score = None;
107 self.match_indices.clear();
108 }
109}
110
111pub struct LoadSessionState {
113 pub search_query: String,
115 pub search_cursor: usize,
117 pub all_sessions: Vec<SessionItem>,
119 pub filtered_sessions: Vec<SessionItem>,
121 pub selected_index: usize,
123 pub scroll_offset: usize,
125 pub focus: LoadFocus,
127 pub loading: bool,
129 pub error: Option<String>,
131 pub preview_expanded: bool,
133 pub sort_by: SortOrder,
135 pub mode_filter: Option<OperatingMode>,
137 pub favorites: HashMap<Uuid, bool>,
139 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 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 pub fn set_loading(&mut self, loading: bool) {
208 self.loading = loading;
209 if loading {
210 self.error = None;
211 }
212 }
213
214 pub fn set_error(&mut self, error: String) {
216 self.error = Some(error);
217 self.loading = false;
218 }
219
220 pub fn apply_filters(&mut self) {
222 let mut filtered = self.all_sessions.clone();
223
224 if let Some(mode) = self.mode_filter {
226 filtered.retain(|s| s.metadata.current_mode == mode);
227 }
228
229 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 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 for session in &mut filtered {
261 session.clear_match();
262 }
263
264 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 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 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 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 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 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 self.preview_expanded = !self.preview_expanded;
445 LoadSessionAction::None
446 }
447 KeyCode::Delete | KeyCode::Char('d') if key.modifiers == KeyModifiers::CONTROL => {
448 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 pub fn get_selected_session(&self) -> Option<&SessionItem> {
480 self.filtered_sessions.get(self.selected_index)
481 }
482
483 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#[derive(Debug, Clone, PartialEq)]
497pub enum LoadSessionAction {
498 None,
499 Load(Uuid),
500 Delete(Uuid),
501 Cancel,
502}
503
504pub 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 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 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 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 if is_favorite {
630 spans.push(Span::styled("⭐ ", Style::default().fg(Color::Yellow)));
631 } else {
632 spans.push(Span::raw(" "));
633 }
634
635 spans.push(Span::styled(
637 &session.mode_indicator,
638 Style::default().fg(session.mode_color),
639 ));
640 spans.push(Span::raw(" "));
641
642 if !session.match_indices.is_empty() && !self.state.search_query.is_empty() {
644 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 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 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 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 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.render(area, buf);
763
764 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 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 let layout = if self.state.preview_expanded {
783 Layout::default()
784 .direction(Direction::Vertical)
785 .constraints([
786 Constraint::Length(3), Constraint::Percentage(50), Constraint::Percentage(30), Constraint::Length(1), ])
791 .split(inner)
792 } else {
793 Layout::default()
794 .direction(Direction::Vertical)
795 .constraints([
796 Constraint::Length(3), Constraint::Min(5), Constraint::Length(1), ])
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
815fn 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}