cascade_cli/cli/commands/
tui.rs

1use crate::errors::{CascadeError, Result};
2use crate::stack::{StackManager, StackStatus};
3use crossterm::{
4    event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind},
5    execute,
6    terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
7};
8use ratatui::{
9    backend::CrosstermBackend,
10    layout::{Alignment, Constraint, Direction, Layout, Rect},
11    style::{Color, Modifier, Style},
12    text::{Line, Span},
13    widgets::{
14        Block, Borders, Cell, Clear, List, ListItem, ListState, Paragraph, Row, Table, Tabs, Wrap,
15    },
16    Frame, Terminal,
17};
18use std::env;
19use std::io;
20use std::time::{Duration, Instant};
21
22/// TUI Application state
23pub struct TuiApp {
24    should_quit: bool,
25    stack_manager: StackManager,
26    stacks: Vec<crate::stack::Stack>,
27    selected_stack: usize,
28    selected_tab: usize,
29    stack_list_state: ListState,
30    last_refresh: Instant,
31    refresh_interval: Duration,
32    show_help: bool,
33    show_details: bool,
34    error_message: Option<String>,
35}
36
37impl TuiApp {
38    pub fn new() -> Result<Self> {
39        let current_dir = env::current_dir()
40            .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
41
42        let stack_manager = StackManager::new(&current_dir)?;
43        let stacks = stack_manager.get_all_stacks_objects()?;
44
45        let mut stack_list_state = ListState::default();
46        if !stacks.is_empty() {
47            stack_list_state.select(Some(0));
48        }
49
50        Ok(TuiApp {
51            should_quit: false,
52            stack_manager,
53            stacks,
54            selected_stack: 0,
55            selected_tab: 0,
56            stack_list_state,
57            last_refresh: Instant::now(),
58            refresh_interval: Duration::from_secs(10),
59            show_help: false,
60            show_details: false,
61            error_message: None,
62        })
63    }
64
65    pub fn run(&mut self) -> Result<()> {
66        // Setup terminal
67        enable_raw_mode()
68            .map_err(|e| CascadeError::config(format!("Failed to enable raw mode: {e}")))?;
69        let mut stdout = io::stdout();
70        execute!(stdout, EnterAlternateScreen, EnableMouseCapture)
71            .map_err(|e| CascadeError::config(format!("Failed to setup terminal: {e}")))?;
72        let backend = CrosstermBackend::new(stdout);
73        let mut terminal = Terminal::new(backend)
74            .map_err(|e| CascadeError::config(format!("Failed to create terminal: {e}")))?;
75
76        // Main loop
77        let result = self.run_app(&mut terminal);
78
79        // Restore terminal
80        disable_raw_mode()
81            .map_err(|e| CascadeError::config(format!("Failed to disable raw mode: {e}")))?;
82        execute!(
83            terminal.backend_mut(),
84            LeaveAlternateScreen,
85            DisableMouseCapture
86        )
87        .map_err(|e| CascadeError::config(format!("Failed to restore terminal: {e}")))?;
88        terminal
89            .show_cursor()
90            .map_err(|e| CascadeError::config(format!("Failed to show cursor: {e}")))?;
91
92        result
93    }
94
95    fn run_app<B: ratatui::backend::Backend>(&mut self, terminal: &mut Terminal<B>) -> Result<()> {
96        loop {
97            terminal
98                .draw(|f| self.draw(f))
99                .map_err(|e| CascadeError::config(format!("Failed to draw: {e}")))?;
100
101            // Handle events with timeout for refresh
102            let timeout = Duration::from_millis(100);
103            if crossterm::event::poll(timeout)
104                .map_err(|e| CascadeError::config(format!("Event poll failed: {e}")))?
105            {
106                if let Event::Key(key) = event::read()
107                    .map_err(|e| CascadeError::config(format!("Failed to read event: {e}")))?
108                {
109                    if key.kind == KeyEventKind::Press {
110                        self.handle_key_event(key.code)?;
111                    }
112                }
113            }
114
115            // Auto-refresh data
116            if self.last_refresh.elapsed() >= self.refresh_interval {
117                self.refresh_data()?;
118            }
119
120            if self.should_quit {
121                break;
122            }
123        }
124        Ok(())
125    }
126
127    fn handle_key_event(&mut self, key: KeyCode) -> Result<()> {
128        if self.show_help {
129            match key {
130                KeyCode::Char('h') | KeyCode::Char('?') | KeyCode::Esc => {
131                    self.show_help = false;
132                }
133                _ => {}
134            }
135            return Ok(());
136        }
137
138        match key {
139            KeyCode::Char('q') | KeyCode::Esc => {
140                self.should_quit = true;
141            }
142            KeyCode::Char('h') | KeyCode::Char('?') => {
143                self.show_help = true;
144            }
145            KeyCode::Char('r') => {
146                self.refresh_data()?;
147            }
148            KeyCode::Char('d') => {
149                self.show_details = !self.show_details;
150            }
151            KeyCode::Tab => {
152                self.selected_tab = (self.selected_tab + 1) % 3; // 3 tabs: Stacks, Details, Actions
153            }
154            KeyCode::Up => {
155                self.previous_stack();
156            }
157            KeyCode::Down => {
158                self.next_stack();
159            }
160            KeyCode::Enter => {
161                self.activate_selected_stack()?;
162            }
163            KeyCode::Char('c') => {
164                // Create new stack (placeholder)
165                self.error_message = Some("Create stack: Not implemented yet".to_string());
166            }
167            KeyCode::Char('s') => {
168                // Submit selected entry (placeholder)
169                self.error_message = Some("Submit entry: Not implemented yet".to_string());
170            }
171            KeyCode::Char('p') => {
172                // Push to stack (placeholder)
173                self.error_message = Some("Push to stack: Not implemented yet".to_string());
174            }
175            _ => {}
176        }
177        Ok(())
178    }
179
180    fn refresh_data(&mut self) -> Result<()> {
181        self.stacks = self.stack_manager.get_all_stacks_objects()?;
182        self.last_refresh = Instant::now();
183        self.error_message = None;
184        Ok(())
185    }
186
187    fn next_stack(&mut self) {
188        if !self.stacks.is_empty() {
189            let i = match self.stack_list_state.selected() {
190                Some(i) => {
191                    if i >= self.stacks.len() - 1 {
192                        0
193                    } else {
194                        i + 1
195                    }
196                }
197                None => 0,
198            };
199            self.stack_list_state.select(Some(i));
200            self.selected_stack = i;
201        }
202    }
203
204    fn previous_stack(&mut self) {
205        if !self.stacks.is_empty() {
206            let i = match self.stack_list_state.selected() {
207                Some(i) => {
208                    if i == 0 {
209                        self.stacks.len() - 1
210                    } else {
211                        i - 1
212                    }
213                }
214                None => 0,
215            };
216            self.stack_list_state.select(Some(i));
217            self.selected_stack = i;
218        }
219    }
220
221    fn activate_selected_stack(&mut self) -> Result<()> {
222        if let Some(stack) = self.stacks.get(self.selected_stack) {
223            self.stack_manager.set_active_stack(Some(stack.id))?;
224            self.error_message = Some(format!("Activated stack: {}", stack.name));
225        }
226        Ok(())
227    }
228
229    fn draw(&mut self, f: &mut Frame) {
230        let size = f.size();
231
232        // Main layout
233        let chunks = Layout::default()
234            .direction(Direction::Vertical)
235            .margin(1)
236            .constraints([
237                Constraint::Length(3), // Header
238                Constraint::Min(0),    // Body
239                Constraint::Length(3), // Footer
240            ])
241            .split(size);
242
243        self.draw_header(f, chunks[0]);
244        self.draw_body(f, chunks[1]);
245        self.draw_footer(f, chunks[2]);
246
247        // Overlays
248        if self.show_help {
249            self.draw_help_popup(f, size);
250        }
251
252        if let Some(ref msg) = self.error_message {
253            self.draw_status_popup(f, size, msg);
254        }
255    }
256
257    fn draw_header(&self, f: &mut Frame, area: Rect) {
258        let title = Paragraph::new("🌊 Cascade CLI - Interactive Stack Manager")
259            .style(
260                Style::default()
261                    .fg(Color::Cyan)
262                    .add_modifier(Modifier::BOLD),
263            )
264            .alignment(Alignment::Center)
265            .block(Block::default().borders(Borders::ALL));
266        f.render_widget(title, area);
267    }
268
269    fn draw_body(&mut self, f: &mut Frame, area: Rect) {
270        let tabs = ["📚 Stacks", "🔍 Details", "⚡ Actions"];
271        let tab_titles = tabs.iter().cloned().map(Line::from).collect();
272        let tabs_widget = Tabs::new(tab_titles)
273            .block(Block::default().borders(Borders::ALL).title("Navigation"))
274            .style(Style::default().fg(Color::White))
275            .highlight_style(
276                Style::default()
277                    .fg(Color::Yellow)
278                    .add_modifier(Modifier::BOLD),
279            )
280            .select(self.selected_tab);
281
282        let body_chunks = Layout::default()
283            .direction(Direction::Vertical)
284            .constraints([Constraint::Length(3), Constraint::Min(0)])
285            .split(area);
286
287        f.render_widget(tabs_widget, body_chunks[0]);
288
289        match self.selected_tab {
290            0 => self.draw_stacks_tab(f, body_chunks[1]),
291            1 => self.draw_details_tab(f, body_chunks[1]),
292            2 => self.draw_actions_tab(f, body_chunks[1]),
293            _ => {}
294        }
295    }
296
297    fn draw_stacks_tab(&mut self, f: &mut Frame, area: Rect) {
298        let chunks = Layout::default()
299            .direction(Direction::Horizontal)
300            .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
301            .split(area);
302
303        // Stack list
304        let items: Vec<ListItem> = self
305            .stacks
306            .iter()
307            .enumerate()
308            .map(|(i, stack)| {
309                let status_icon = match stack.status {
310                    StackStatus::Clean => "✅",
311                    StackStatus::Dirty => "🔄",
312                    StackStatus::OutOfSync => "⚠️",
313                    StackStatus::Conflicted => "❌",
314                    StackStatus::Rebasing => "🔀",
315                    StackStatus::NeedsSync => "🔄",
316                    StackStatus::Corrupted => "💥",
317                };
318
319                let active_marker = if stack.is_active { "👉 " } else { "   " };
320
321                let content = format!(
322                    "{}{} {} ({} entries)",
323                    active_marker,
324                    status_icon,
325                    stack.name,
326                    stack.entries.len()
327                );
328
329                let style = if i == self.selected_stack {
330                    Style::default()
331                        .fg(Color::Yellow)
332                        .add_modifier(Modifier::BOLD)
333                } else {
334                    Style::default()
335                };
336
337                ListItem::new(content).style(style)
338            })
339            .collect();
340
341        let stacks_list = List::new(items)
342            .block(Block::default().borders(Borders::ALL).title("🗂️ Stacks"))
343            .highlight_style(
344                Style::default()
345                    .bg(Color::DarkGray)
346                    .add_modifier(Modifier::BOLD),
347            )
348            .highlight_symbol(">> ");
349
350        f.render_stateful_widget(stacks_list, chunks[0], &mut self.stack_list_state);
351
352        // Stack summary
353        self.draw_stack_summary(f, chunks[1]);
354    }
355
356    fn draw_stack_summary(&self, f: &mut Frame, area: Rect) {
357        if let Some(stack) = self.stacks.get(self.selected_stack) {
358            let mut lines = vec![
359                Line::from(vec![
360                    Span::styled("Name: ", Style::default().fg(Color::Cyan)),
361                    Span::raw(&stack.name),
362                ]),
363                Line::from(vec![
364                    Span::styled("Base: ", Style::default().fg(Color::Cyan)),
365                    Span::raw(&stack.base_branch),
366                ]),
367                Line::from(vec![
368                    Span::styled("Entries: ", Style::default().fg(Color::Cyan)),
369                    Span::raw(format!("{}", stack.entries.len())),
370                ]),
371                Line::from(vec![
372                    Span::styled("Status: ", Style::default().fg(Color::Cyan)),
373                    Span::raw(format!("{:?}", stack.status)),
374                ]),
375                Line::from(""),
376            ];
377
378            if let Some(desc) = &stack.description {
379                lines.push(Line::from(vec![Span::styled(
380                    "Description: ",
381                    Style::default().fg(Color::Cyan),
382                )]));
383                lines.push(Line::from(desc.clone()));
384                lines.push(Line::from(""));
385            }
386
387            // Recent entries
388            if !stack.entries.is_empty() {
389                lines.push(Line::from(vec![Span::styled(
390                    "Recent Commits:",
391                    Style::default()
392                        .fg(Color::Green)
393                        .add_modifier(Modifier::BOLD),
394                )]));
395
396                for (i, entry) in stack.entries.iter().rev().take(5).enumerate() {
397                    lines.push(Line::from(format!(
398                        "  {} {} - {}",
399                        i + 1,
400                        entry.short_hash(),
401                        entry.short_message(40)
402                    )));
403                }
404            }
405
406            let summary = Paragraph::new(lines)
407                .block(
408                    Block::default()
409                        .borders(Borders::ALL)
410                        .title("📊 Stack Info"),
411                )
412                .wrap(Wrap { trim: true });
413
414            f.render_widget(summary, area);
415        } else {
416            let empty = Paragraph::new("No stacks available.\n\nPress 'c' to create a new stack.")
417                .block(
418                    Block::default()
419                        .borders(Borders::ALL)
420                        .title("📊 Stack Info"),
421                )
422                .alignment(Alignment::Center);
423            f.render_widget(empty, area);
424        }
425    }
426
427    fn draw_details_tab(&self, f: &mut Frame, area: Rect) {
428        if let Some(stack) = self.stacks.get(self.selected_stack) {
429            if stack.entries.is_empty() {
430                let empty = Paragraph::new(
431                    "No commits in this stack.\n\nUse 'cc stack push' to add commits.",
432                )
433                .block(
434                    Block::default()
435                        .borders(Borders::ALL)
436                        .title("📋 Stack Details"),
437                )
438                .alignment(Alignment::Center);
439                f.render_widget(empty, area);
440                return;
441            }
442
443            let header = vec!["#", "Commit", "Branch", "Message", "Status"];
444            let rows = stack.entries.iter().enumerate().map(|(i, entry)| {
445                let status = if entry.pull_request_id.is_some() {
446                    "📤 Submitted"
447                } else {
448                    "⏳ Pending"
449                };
450
451                Row::new(vec![
452                    Cell::from((i + 1).to_string()),
453                    Cell::from(entry.short_hash()),
454                    Cell::from(entry.branch.clone()),
455                    Cell::from(entry.short_message(30)),
456                    Cell::from(status),
457                ])
458            });
459
460            let table = Table::new(
461                rows,
462                [
463                    Constraint::Length(3),
464                    Constraint::Length(8),
465                    Constraint::Length(20),
466                    Constraint::Length(35),
467                    Constraint::Length(12),
468                ],
469            )
470            .header(
471                Row::new(header)
472                    .style(
473                        Style::default()
474                            .fg(Color::Yellow)
475                            .add_modifier(Modifier::BOLD),
476                    )
477                    .bottom_margin(1),
478            )
479            .block(
480                Block::default()
481                    .borders(Borders::ALL)
482                    .title("📋 Stack Details"),
483            );
484
485            f.render_widget(table, area);
486        } else {
487            let empty = Paragraph::new("No stack selected")
488                .block(
489                    Block::default()
490                        .borders(Borders::ALL)
491                        .title("📋 Stack Details"),
492                )
493                .alignment(Alignment::Center);
494            f.render_widget(empty, area);
495        }
496    }
497
498    fn draw_actions_tab(&self, f: &mut Frame, area: Rect) {
499        let actions = [
500            "📌 Enter - Activate selected stack",
501            "📝 c - Create new stack",
502            "🚀 p - Push current commit to stack",
503            "📤 s - Submit entry for review",
504            "🔄 r - Refresh data",
505            "🔍 d - Toggle details view",
506            "❓ h/? - Show help",
507            "🚪 q/Esc - Quit",
508        ];
509
510        let lines: Vec<Line> = actions.iter().map(|&action| Line::from(action)).collect();
511
512        let paragraph = Paragraph::new(lines)
513            .block(
514                Block::default()
515                    .borders(Borders::ALL)
516                    .title("⚡ Quick Actions"),
517            )
518            .wrap(Wrap { trim: true });
519
520        f.render_widget(paragraph, area);
521    }
522
523    fn draw_footer(&self, f: &mut Frame, area: Rect) {
524        let last_refresh = format!("Last refresh: {:?} ago", self.last_refresh.elapsed());
525        let key_hints = " h:Help │ q:Quit │ r:Refresh │ Tab:Navigate │ ↑↓:Select │ Enter:Activate ";
526
527        let footer_text = format!("{last_refresh} │ {key_hints}");
528
529        let footer = Paragraph::new(footer_text)
530            .style(Style::default().fg(Color::Gray))
531            .alignment(Alignment::Center)
532            .block(Block::default().borders(Borders::ALL));
533
534        f.render_widget(footer, area);
535    }
536
537    fn draw_help_popup(&self, f: &mut Frame, area: Rect) {
538        let popup_area = self.centered_rect(80, 70, area);
539
540        let help_text = vec![
541            Line::from(vec![Span::styled(
542                "🌊 Cascade CLI - Interactive Stack Manager",
543                Style::default()
544                    .fg(Color::Cyan)
545                    .add_modifier(Modifier::BOLD),
546            )]),
547            Line::from(""),
548            Line::from(vec![Span::styled(
549                "📍 Navigation:",
550                Style::default()
551                    .fg(Color::Yellow)
552                    .add_modifier(Modifier::BOLD),
553            )]),
554            Line::from("  ↑↓ - Navigate stacks"),
555            Line::from("  Tab - Switch between tabs"),
556            Line::from("  Enter - Activate selected stack"),
557            Line::from(""),
558            Line::from(vec![Span::styled(
559                "⚡ Actions:",
560                Style::default()
561                    .fg(Color::Green)
562                    .add_modifier(Modifier::BOLD),
563            )]),
564            Line::from("  c - Create new stack"),
565            Line::from("  p - Push commit to active stack"),
566            Line::from("  s - Submit entry for review"),
567            Line::from("  r - Refresh data"),
568            Line::from("  d - Toggle details view"),
569            Line::from(""),
570            Line::from(vec![Span::styled(
571                "🎛️ Controls:",
572                Style::default()
573                    .fg(Color::Magenta)
574                    .add_modifier(Modifier::BOLD),
575            )]),
576            Line::from("  h/? - Show this help"),
577            Line::from("  q/Esc - Quit"),
578            Line::from(""),
579            Line::from(vec![Span::styled(
580                "💡 Tips:",
581                Style::default()
582                    .fg(Color::Blue)
583                    .add_modifier(Modifier::BOLD),
584            )]),
585            Line::from("  • Data refreshes automatically every 10 seconds"),
586            Line::from("  • Use CLI commands for complex operations"),
587            Line::from("  • Active stack is marked with 👉"),
588            Line::from(""),
589            Line::from("Press any key to close this help..."),
590        ];
591
592        let help_paragraph = Paragraph::new(help_text)
593            .block(
594                Block::default()
595                    .borders(Borders::ALL)
596                    .title("❓ Help")
597                    .style(Style::default().fg(Color::White)),
598            )
599            .wrap(Wrap { trim: true });
600
601        f.render_widget(Clear, popup_area);
602        f.render_widget(help_paragraph, popup_area);
603    }
604
605    fn draw_status_popup(&self, f: &mut Frame, area: Rect, message: &str) {
606        let popup_area = self.centered_rect(60, 20, area);
607
608        let status_paragraph = Paragraph::new(message)
609            .block(
610                Block::default()
611                    .borders(Borders::ALL)
612                    .title("💬 Status")
613                    .style(Style::default().fg(Color::Yellow)),
614            )
615            .alignment(Alignment::Center)
616            .wrap(Wrap { trim: true });
617
618        f.render_widget(Clear, popup_area);
619        f.render_widget(status_paragraph, popup_area);
620    }
621
622    fn centered_rect(&self, percent_x: u16, percent_y: u16, r: Rect) -> Rect {
623        let popup_layout = Layout::default()
624            .direction(Direction::Vertical)
625            .constraints([
626                Constraint::Percentage((100 - percent_y) / 2),
627                Constraint::Percentage(percent_y),
628                Constraint::Percentage((100 - percent_y) / 2),
629            ])
630            .split(r);
631
632        Layout::default()
633            .direction(Direction::Horizontal)
634            .constraints([
635                Constraint::Percentage((100 - percent_x) / 2),
636                Constraint::Percentage(percent_x),
637                Constraint::Percentage((100 - percent_x) / 2),
638            ])
639            .split(popup_layout[1])[1]
640    }
641}
642
643/// Run the TUI application
644pub async fn run() -> Result<()> {
645    let mut app = TuiApp::new()?;
646    app.run()
647}