Skip to main content

cascade_cli/cli/commands/
tui.rs

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