agcodex_tui/dialogs/
save_session.rs

1//! Enhanced save session dialog for AGCodex TUI
2//! Provides popup dialog with session naming, progress bar, and auto-save integration
3
4use chrono::Local;
5use ratatui::buffer::Buffer;
6use ratatui::crossterm::event::KeyCode;
7use ratatui::crossterm::event::KeyEvent;
8use ratatui::layout::Alignment;
9use ratatui::layout::Constraint;
10use ratatui::layout::Direction;
11use ratatui::layout::Layout;
12use ratatui::layout::Rect;
13use ratatui::style::Color;
14use ratatui::style::Modifier;
15use ratatui::style::Style;
16use ratatui::text::Line;
17use ratatui::text::Span;
18use ratatui::widgets::Block;
19use ratatui::widgets::BorderType;
20use ratatui::widgets::Borders;
21use ratatui::widgets::Clear;
22use ratatui::widgets::Gauge;
23use ratatui::widgets::Paragraph;
24use ratatui::widgets::Widget;
25use ratatui::widgets::WidgetRef;
26use ratatui::widgets::Wrap;
27use std::path::PathBuf;
28use std::time::Duration;
29use std::time::Instant;
30use uuid::Uuid;
31
32/// State for the save session dialog
33#[derive(Debug, Clone)]
34pub struct SaveSessionState {
35    /// Session name input
36    pub session_name: String,
37    /// Optional description input
38    pub description: String,
39    /// Currently focused field (0: name, 1: description, 2: save button, 3: cancel button)
40    pub focused_field: usize,
41    /// Cursor position in the currently focused text field
42    pub cursor_pos: usize,
43    /// Save location path (~/.agcodex/history/)
44    pub save_location: PathBuf,
45    /// Whether to show validation errors
46    pub show_error: Option<String>,
47    /// Save progress (0.0 to 1.0)
48    pub save_progress: f64,
49    /// Whether save operation is in progress
50    pub saving: bool,
51    /// Start time of save operation for progress estimation
52    pub save_start_time: Option<Instant>,
53    /// Session ID being saved
54    pub session_id: Option<Uuid>,
55    /// Auto-generated timestamp name
56    pub default_name: String,
57    /// Show success message
58    pub show_success: bool,
59    /// Estimated time remaining
60    pub estimated_time: Option<Duration>,
61}
62
63impl SaveSessionState {
64    pub fn new() -> Self {
65        let save_location = dirs::home_dir()
66            .map(|p| p.join(".agcodex/history"))
67            .unwrap_or_else(|| PathBuf::from(".agcodex/history"));
68
69        // Generate default timestamp name
70        let default_name = Local::now().format("%Y-%m-%d_%H-%M").to_string();
71
72        Self {
73            session_name: default_name.clone(),
74            description: String::new(),
75            focused_field: 0,
76            cursor_pos: default_name.len(),
77            save_location,
78            show_error: None,
79            save_progress: 0.0,
80            saving: false,
81            save_start_time: None,
82            session_id: None,
83            default_name,
84            show_success: false,
85            estimated_time: None,
86        }
87    }
88
89    /// Handle key input for the dialog
90    pub fn handle_key_event(&mut self, key: KeyEvent) -> SaveSessionAction {
91        if self.saving {
92            // Only allow Esc during save
93            if key.code == KeyCode::Esc {
94                return SaveSessionAction::Cancel;
95            }
96            return SaveSessionAction::None;
97        }
98
99        if self.show_success {
100            // Any key closes success dialog
101            return SaveSessionAction::Close;
102        }
103
104        match key.code {
105            KeyCode::Esc => SaveSessionAction::Cancel,
106            KeyCode::Enter => {
107                match self.focused_field {
108                    0 | 1 => {
109                        // Enter in text fields moves to next field or saves
110                        if self.focused_field == 0 && !self.session_name.trim().is_empty() {
111                            self.focused_field = 1;
112                            self.cursor_pos = self.description.len();
113                        } else if self.focused_field == 1 {
114                            self.focused_field = 2; // Move to save button
115                        }
116                        SaveSessionAction::None
117                    }
118                    2 => {
119                        // Save button
120                        if self.validate() {
121                            SaveSessionAction::Save
122                        } else {
123                            SaveSessionAction::None
124                        }
125                    }
126                    3 => SaveSessionAction::Cancel, // Cancel button
127                    _ => SaveSessionAction::None,
128                }
129            }
130            KeyCode::Tab => {
131                self.next_field();
132                SaveSessionAction::None
133            }
134            KeyCode::BackTab => {
135                self.prev_field();
136                SaveSessionAction::None
137            }
138            KeyCode::Char(c) => {
139                if self.focused_field <= 1 {
140                    // Limit input length
141                    let current_text = if self.focused_field == 0 {
142                        &self.session_name
143                    } else {
144                        &self.description
145                    };
146
147                    if current_text.len() < 100 {
148                        self.insert_char(c);
149                    }
150                }
151                SaveSessionAction::None
152            }
153            KeyCode::Backspace => {
154                if self.focused_field <= 1 {
155                    self.delete_char();
156                }
157                SaveSessionAction::None
158            }
159            KeyCode::Delete => {
160                if self.focused_field <= 1 {
161                    self.delete_char_forward();
162                }
163                SaveSessionAction::None
164            }
165            KeyCode::Left => {
166                if self.focused_field <= 1 {
167                    self.move_cursor_left();
168                }
169                SaveSessionAction::None
170            }
171            KeyCode::Right => {
172                if self.focused_field <= 1 {
173                    self.move_cursor_right();
174                }
175                SaveSessionAction::None
176            }
177            KeyCode::Home => {
178                if self.focused_field <= 1 {
179                    self.cursor_pos = 0;
180                }
181                SaveSessionAction::None
182            }
183            KeyCode::End => {
184                if self.focused_field <= 1 {
185                    self.cursor_pos = self.current_field_text().len();
186                }
187                SaveSessionAction::None
188            }
189            _ => SaveSessionAction::None,
190        }
191    }
192
193    fn next_field(&mut self) {
194        self.focused_field = (self.focused_field + 1) % 4;
195        if self.focused_field <= 1 {
196            self.cursor_pos = self.current_field_text().len();
197        }
198    }
199
200    fn prev_field(&mut self) {
201        self.focused_field = if self.focused_field == 0 {
202            3
203        } else {
204            self.focused_field - 1
205        };
206        if self.focused_field <= 1 {
207            self.cursor_pos = self.current_field_text().len();
208        }
209    }
210
211    fn current_field_text(&self) -> &str {
212        match self.focused_field {
213            0 => &self.session_name,
214            1 => &self.description,
215            _ => "",
216        }
217    }
218
219    fn current_field_text_mut(&mut self) -> &mut String {
220        match self.focused_field {
221            0 => &mut self.session_name,
222            1 => &mut self.description,
223            _ => panic!("Invalid field for text mutation"),
224        }
225    }
226
227    fn insert_char(&mut self, c: char) {
228        if self.focused_field <= 1 {
229            let cursor_pos = self.cursor_pos;
230            let text = self.current_field_text_mut();
231            text.insert(cursor_pos, c);
232            self.cursor_pos = cursor_pos + c.len_utf8();
233            self.show_error = None;
234        }
235    }
236
237    fn delete_char(&mut self) {
238        if self.focused_field <= 1 && self.cursor_pos > 0 {
239            let cursor_pos = self.cursor_pos;
240            let text = self.current_field_text_mut();
241            let char_boundary = text
242                .char_indices()
243                .rev()
244                .find(|(idx, _)| *idx < cursor_pos)
245                .map(|(idx, _)| idx)
246                .unwrap_or(0);
247            text.drain(char_boundary..cursor_pos);
248            self.cursor_pos = char_boundary;
249            self.show_error = None;
250        }
251    }
252
253    fn delete_char_forward(&mut self) {
254        if self.focused_field <= 1 {
255            let cursor_pos = self.cursor_pos;
256            let text = self.current_field_text_mut();
257            if cursor_pos < text.len() {
258                let next_char_boundary = text
259                    .char_indices()
260                    .find(|(idx, _)| *idx > cursor_pos)
261                    .map(|(idx, _)| idx)
262                    .unwrap_or(text.len());
263                text.drain(cursor_pos..next_char_boundary);
264            }
265        }
266    }
267
268    fn move_cursor_left(&mut self) {
269        if self.focused_field <= 1 && self.cursor_pos > 0 {
270            let text = self.current_field_text();
271            self.cursor_pos = text
272                .char_indices()
273                .rev()
274                .find(|(idx, _)| *idx < self.cursor_pos)
275                .map(|(idx, _)| idx)
276                .unwrap_or(0);
277        }
278    }
279
280    fn move_cursor_right(&mut self) {
281        if self.focused_field <= 1 {
282            let text = self.current_field_text();
283            if self.cursor_pos < text.len() {
284                self.cursor_pos = text
285                    .char_indices()
286                    .find(|(idx, _)| *idx > self.cursor_pos)
287                    .map(|(idx, _)| idx)
288                    .unwrap_or(text.len());
289            }
290        }
291    }
292
293    /// Validate the dialog input
294    pub fn validate(&mut self) -> bool {
295        let name = self.session_name.trim();
296        if name.is_empty() {
297            self.show_error = Some("Session name cannot be empty".to_string());
298            self.focused_field = 0;
299            return false;
300        }
301
302        // Check for invalid characters
303        if name
304            .chars()
305            .any(|c| matches!(c, '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|'))
306        {
307            self.show_error = Some("Session name contains invalid characters".to_string());
308            self.focused_field = 0;
309            return false;
310        }
311
312        // Check length
313        if name.len() > 100 {
314            self.show_error = Some("Session name too long (max 100 characters)".to_string());
315            self.focused_field = 0;
316            return false;
317        }
318
319        self.show_error = None;
320        true
321    }
322
323    /// Start save operation
324    pub fn start_save(&mut self, session_id: Uuid) {
325        self.saving = true;
326        self.save_progress = 0.0;
327        self.save_start_time = Some(Instant::now());
328        self.session_id = Some(session_id);
329        self.show_error = None;
330    }
331
332    /// Update save progress
333    pub fn update_progress(&mut self, progress: f64) {
334        self.save_progress = progress.clamp(0.0, 1.0);
335
336        // Estimate time remaining based on elapsed time
337        if let Some(start_time) = self.save_start_time {
338            let elapsed = start_time.elapsed();
339            if progress > 0.0 && progress < 1.0 {
340                let total_estimated = elapsed.as_secs_f64() / progress;
341                let remaining = total_estimated - elapsed.as_secs_f64();
342                self.estimated_time = Some(Duration::from_secs_f64(remaining));
343            }
344        }
345    }
346
347    /// Complete save operation
348    pub const fn complete_save(&mut self) {
349        self.saving = false;
350        self.save_progress = 1.0;
351        self.show_success = true;
352        self.estimated_time = None;
353    }
354
355    /// Set error message
356    pub fn set_error(&mut self, error: String) {
357        self.show_error = Some(error);
358        self.saving = false;
359        self.save_progress = 0.0;
360        self.estimated_time = None;
361    }
362
363    /// Reset to initial state
364    pub fn reset(&mut self) {
365        *self = Self::new();
366    }
367}
368
369impl Default for SaveSessionState {
370    fn default() -> Self {
371        Self::new()
372    }
373}
374
375/// Actions that can be triggered by the save dialog
376#[derive(Debug, Clone, PartialEq)]
377pub enum SaveSessionAction {
378    None,
379    Save,
380    Cancel,
381    Close,
382}
383
384/// Enhanced Save Session Dialog widget with progress bar
385pub struct SaveSessionDialog<'a> {
386    state: &'a SaveSessionState,
387}
388
389impl<'a> SaveSessionDialog<'a> {
390    pub const fn new(state: &'a SaveSessionState) -> Self {
391        Self { state }
392    }
393
394    fn render_input_field(
395        &self,
396        area: Rect,
397        buf: &mut Buffer,
398        title: &str,
399        text: &str,
400        placeholder: &str,
401        focused: bool,
402        cursor_pos: usize,
403    ) {
404        let block = Block::default()
405            .title(title)
406            .borders(Borders::ALL)
407            .border_type(BorderType::Rounded)
408            .border_style(if focused {
409                Style::default()
410                    .fg(Color::Cyan)
411                    .add_modifier(Modifier::BOLD)
412            } else {
413                Style::default().fg(Color::Gray)
414            });
415
416        let inner = block.inner(area);
417        block.render(area, buf);
418
419        // Render text or placeholder
420        let content = if text.is_empty() && !focused {
421            Span::styled(
422                placeholder,
423                Style::default()
424                    .fg(Color::DarkGray)
425                    .add_modifier(Modifier::ITALIC),
426            )
427        } else {
428            Span::raw(text)
429        };
430
431        let paragraph = Paragraph::new(Line::from(content));
432        paragraph.render(inner, buf);
433
434        // Render cursor if focused
435        if focused && inner.width > 0 {
436            let visible_cursor = cursor_pos.min(text.len());
437            let cursor_x = inner.x + (visible_cursor as u16).min(inner.width - 1);
438            if cursor_x < inner.right()
439                && let Some(cell) = buf.cell_mut((cursor_x, inner.y))
440            {
441                cell.set_style(Style::default().bg(Color::White).fg(Color::Black));
442            }
443        }
444    }
445
446    fn render_button(
447        &self,
448        area: Rect,
449        buf: &mut Buffer,
450        text: &str,
451        focused: bool,
452        disabled: bool,
453    ) {
454        let style = if disabled {
455            Style::default().fg(Color::DarkGray)
456        } else if focused {
457            Style::default()
458                .bg(Color::Cyan)
459                .fg(Color::Black)
460                .add_modifier(Modifier::BOLD)
461        } else {
462            Style::default().fg(Color::White)
463        };
464
465        let block = Block::default()
466            .borders(Borders::ALL)
467            .border_type(BorderType::Rounded)
468            .border_style(if focused && !disabled {
469                Style::default().fg(Color::Cyan)
470            } else {
471                Style::default().fg(Color::DarkGray)
472            });
473
474        let paragraph = Paragraph::new(text)
475            .alignment(Alignment::Center)
476            .style(style);
477
478        let inner_area = block.inner(area);
479        block.render(area, buf);
480        paragraph.render(inner_area, buf);
481    }
482
483    fn render_progress_bar(&self, area: Rect, buf: &mut Buffer) {
484        let progress_text = if let Some(duration) = self.state.estimated_time {
485            format!(
486                "Saving... {:.0}% - {}s remaining",
487                self.state.save_progress * 100.0,
488                duration.as_secs()
489            )
490        } else {
491            format!("Saving... {:.0}%", self.state.save_progress * 100.0)
492        };
493
494        let gauge = Gauge::default()
495            .block(
496                Block::default()
497                    .borders(Borders::ALL)
498                    .border_type(BorderType::Rounded)
499                    .title(" Progress "),
500            )
501            .gauge_style(Style::default().fg(Color::Cyan).bg(Color::Black))
502            .percent((self.state.save_progress * 100.0) as u16)
503            .label(progress_text);
504
505        gauge.render(area, buf);
506    }
507
508    fn render_success_message(&self, area: Rect, buf: &mut Buffer) {
509        let block = Block::default()
510            .borders(Borders::ALL)
511            .border_type(BorderType::Double)
512            .border_style(Style::default().fg(Color::Green))
513            .title(" Success ");
514
515        let inner = block.inner(area);
516        block.render(area, buf);
517
518        let success_text = vec![
519            Line::from(""),
520            Line::from(vec![
521                Span::raw("✓ "),
522                Span::styled(
523                    "Session saved successfully!",
524                    Style::default()
525                        .fg(Color::Green)
526                        .add_modifier(Modifier::BOLD),
527                ),
528            ]),
529            Line::from(""),
530            Line::from(vec![
531                Span::raw("Name: "),
532                Span::styled(&self.state.session_name, Style::default().fg(Color::White)),
533            ]),
534            Line::from(vec![
535                Span::raw("Location: "),
536                Span::styled(
537                    self.state.save_location.display().to_string(),
538                    Style::default().fg(Color::DarkGray),
539                ),
540            ]),
541            Line::from(""),
542            Line::from(Span::styled(
543                "Press any key to continue",
544                Style::default()
545                    .fg(Color::DarkGray)
546                    .add_modifier(Modifier::ITALIC),
547            )),
548        ];
549
550        let paragraph = Paragraph::new(success_text)
551            .alignment(Alignment::Center)
552            .wrap(Wrap { trim: true });
553
554        paragraph.render(inner, buf);
555    }
556}
557
558impl<'a> WidgetRef for SaveSessionDialog<'a> {
559    fn render_ref(&self, area: Rect, buf: &mut Buffer) {
560        // Clear the background
561        Clear.render(area, buf);
562
563        // Calculate dialog dimensions
564        let dialog_width = 70;
565        let dialog_height = if self.state.show_success {
566            12
567        } else if self.state.saving {
568            10
569        } else {
570            18
571        };
572
573        let x = area.width.saturating_sub(dialog_width) / 2;
574        let y = area.height.saturating_sub(dialog_height) / 2;
575        let dialog_area = Rect::new(x, y, dialog_width, dialog_height);
576
577        // Show success message if save completed
578        if self.state.show_success {
579            self.render_success_message(dialog_area, buf);
580            return;
581        }
582
583        // Main dialog block
584        let block = Block::default()
585            .title(" Save Session ")
586            .borders(Borders::ALL)
587            .border_type(BorderType::Double)
588            .border_style(Style::default().fg(Color::Blue));
589
590        let inner = block.inner(dialog_area);
591        block.render(dialog_area, buf);
592
593        if self.state.saving {
594            // Show progress bar during save
595            let layout = Layout::default()
596                .direction(Direction::Vertical)
597                .constraints([
598                    Constraint::Length(2), // Info text
599                    Constraint::Length(1), // Spacing
600                    Constraint::Length(3), // Progress bar
601                    Constraint::Length(1), // Spacing
602                    Constraint::Length(1), // Cancel hint
603                ])
604                .split(inner);
605
606            let info_text = format!("Saving session: {}", self.state.session_name);
607            let info = Paragraph::new(info_text)
608                .alignment(Alignment::Center)
609                .style(Style::default().fg(Color::White));
610            info.render(layout[0], buf);
611
612            self.render_progress_bar(layout[2], buf);
613
614            let cancel_hint = Paragraph::new("Press Esc to cancel")
615                .alignment(Alignment::Center)
616                .style(Style::default().fg(Color::DarkGray));
617            cancel_hint.render(layout[4], buf);
618        } else {
619            // Normal input mode
620            let layout = Layout::default()
621                .direction(Direction::Vertical)
622                .constraints([
623                    Constraint::Length(2), // Save location
624                    Constraint::Length(1), // Spacing
625                    Constraint::Length(3), // Session name
626                    Constraint::Length(3), // Description
627                    Constraint::Length(1), // Spacing
628                    Constraint::Length(2), // Error message
629                    Constraint::Length(1), // Spacing
630                    Constraint::Length(3), // Buttons
631                ])
632                .split(inner);
633
634            // Save location info
635            let location_text = format!("📁 {}", self.state.save_location.display());
636            let location = Paragraph::new(location_text)
637                .style(Style::default().fg(Color::DarkGray))
638                .alignment(Alignment::Center);
639            location.render(layout[0], buf);
640
641            // Session name input
642            self.render_input_field(
643                layout[2],
644                buf,
645                "Session Name",
646                &self.state.session_name,
647                &format!("e.g., {}", self.state.default_name),
648                self.state.focused_field == 0,
649                if self.state.focused_field == 0 {
650                    self.state.cursor_pos
651                } else {
652                    0
653                },
654            );
655
656            // Description input
657            self.render_input_field(
658                layout[3],
659                buf,
660                "Description (optional)",
661                &self.state.description,
662                "Brief description of this session...",
663                self.state.focused_field == 1,
664                if self.state.focused_field == 1 {
665                    self.state.cursor_pos
666                } else {
667                    0
668                },
669            );
670
671            // Error message
672            if let Some(ref error) = self.state.show_error {
673                let error_text = format!("⚠ {}", error);
674                let error_paragraph = Paragraph::new(error_text)
675                    .style(Style::default().fg(Color::Red))
676                    .alignment(Alignment::Center)
677                    .wrap(Wrap { trim: true });
678                error_paragraph.render(layout[5], buf);
679            }
680
681            // Buttons
682            let button_layout = Layout::default()
683                .direction(Direction::Horizontal)
684                .constraints([
685                    Constraint::Percentage(20),
686                    Constraint::Percentage(25),
687                    Constraint::Percentage(10),
688                    Constraint::Percentage(25),
689                    Constraint::Percentage(20),
690                ])
691                .split(layout[7]);
692
693            self.render_button(
694                button_layout[1],
695                buf,
696                "💾 Save",
697                self.state.focused_field == 2,
698                self.state.session_name.trim().is_empty(),
699            );
700
701            self.render_button(
702                button_layout[3],
703                buf,
704                "Cancel",
705                self.state.focused_field == 3,
706                false,
707            );
708        }
709
710        // Help text at the bottom
711        let help_text = if self.state.saving {
712            "Saving session... Please wait"
713        } else if self.state.show_success {
714            "Session saved! Press any key to continue"
715        } else {
716            "Tab: Navigate • Enter: Confirm • Esc: Cancel • Ctrl+S: Quick Save"
717        };
718
719        let help_area = Rect::new(
720            dialog_area.x,
721            dialog_area.y + dialog_area.height,
722            dialog_area.width,
723            1,
724        );
725
726        if help_area.y < area.height {
727            let help = Paragraph::new(help_text)
728                .style(Style::default().fg(Color::DarkGray))
729                .alignment(Alignment::Center);
730            help.render(help_area, buf);
731        }
732    }
733}