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            KeyCode::Char('c') => {
168                // Create new stack (placeholder)
169                self.error_message = Some("Create stack: Not implemented yet".to_string());
170            }
171            KeyCode::Char('s') => {
172                // Submit selected entry (placeholder)
173                self.error_message = Some("Submit entry: Not implemented yet".to_string());
174            }
175            KeyCode::Char('p') => {
176                // Push to stack (placeholder)
177                self.error_message = Some("Push to stack: Not implemented yet".to_string());
178            }
179            _ => {}
180        }
181        Ok(())
182    }
183
184    fn refresh_data(&mut self) -> Result<()> {
185        self.stacks = self.stack_manager.get_all_stacks_objects()?;
186        self.last_refresh = Instant::now();
187        self.error_message = None;
188        Ok(())
189    }
190
191    fn next_stack(&mut self) {
192        if !self.stacks.is_empty() {
193            let i = match self.stack_list_state.selected() {
194                Some(i) => {
195                    if i >= self.stacks.len() - 1 {
196                        0
197                    } else {
198                        i + 1
199                    }
200                }
201                None => 0,
202            };
203            self.stack_list_state.select(Some(i));
204            self.selected_stack = i;
205        }
206    }
207
208    fn previous_stack(&mut self) {
209        if !self.stacks.is_empty() {
210            let i = match self.stack_list_state.selected() {
211                Some(i) => {
212                    if i == 0 {
213                        self.stacks.len() - 1
214                    } else {
215                        i - 1
216                    }
217                }
218                None => 0,
219            };
220            self.stack_list_state.select(Some(i));
221            self.selected_stack = i;
222        }
223    }
224
225    fn activate_selected_stack(&mut self) -> Result<()> {
226        if let Some(stack) = self.stacks.get(self.selected_stack) {
227            self.stack_manager.set_active_stack(Some(stack.id))?;
228            self.error_message = Some(format!("Activated stack: {}", stack.name));
229        }
230        Ok(())
231    }
232
233    fn draw(&mut self, f: &mut Frame) {
234        let size = f.size();
235
236        // Main layout
237        let chunks = Layout::default()
238            .direction(Direction::Vertical)
239            .margin(1)
240            .constraints([
241                Constraint::Length(3), // Header
242                Constraint::Min(0),    // Body
243                Constraint::Length(3), // Footer
244            ])
245            .split(size);
246
247        self.draw_header(f, chunks[0]);
248        self.draw_body(f, chunks[1]);
249        self.draw_footer(f, chunks[2]);
250
251        // Overlays
252        if self.show_help {
253            self.draw_help_popup(f, size);
254        }
255
256        if let Some(ref msg) = self.error_message {
257            self.draw_status_popup(f, size, msg);
258        }
259    }
260
261    fn draw_header(&self, f: &mut Frame, area: Rect) {
262        let title = Paragraph::new("🌊 Cascade CLI - Interactive Stack Manager")
263            .style(
264                Style::default()
265                    .fg(Color::Cyan)
266                    .add_modifier(Modifier::BOLD),
267            )
268            .alignment(Alignment::Center)
269            .block(Block::default().borders(Borders::ALL));
270        f.render_widget(title, area);
271    }
272
273    fn draw_body(&mut self, f: &mut Frame, area: Rect) {
274        let tabs = ["📚 Stacks", "🔍 Details", "⚡ Actions"];
275        let tab_titles = tabs.iter().cloned().map(Line::from).collect();
276        let tabs_widget = Tabs::new(tab_titles)
277            .block(Block::default().borders(Borders::ALL).title("Navigation"))
278            .style(Style::default().fg(Color::White))
279            .highlight_style(
280                Style::default()
281                    .fg(Color::Yellow)
282                    .add_modifier(Modifier::BOLD),
283            )
284            .select(self.selected_tab);
285
286        let body_chunks = Layout::default()
287            .direction(Direction::Vertical)
288            .constraints([Constraint::Length(3), Constraint::Min(0)])
289            .split(area);
290
291        f.render_widget(tabs_widget, body_chunks[0]);
292
293        match self.selected_tab {
294            0 => self.draw_stacks_tab(f, body_chunks[1]),
295            1 => self.draw_details_tab(f, body_chunks[1]),
296            2 => self.draw_actions_tab(f, body_chunks[1]),
297            _ => {}
298        }
299    }
300
301    fn draw_stacks_tab(&mut self, f: &mut Frame, area: Rect) {
302        let chunks = Layout::default()
303            .direction(Direction::Horizontal)
304            .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
305            .split(area);
306
307        // Stack list
308        let items: Vec<ListItem> = self
309            .stacks
310            .iter()
311            .enumerate()
312            .map(|(i, stack)| {
313                let status_icon = match stack.status {
314                    StackStatus::Clean => "✅",
315                    StackStatus::Dirty => "🔄",
316                    StackStatus::OutOfSync => "⚠️",
317                    StackStatus::Conflicted => "❌",
318                    StackStatus::Rebasing => "🔀",
319                    StackStatus::NeedsSync => "🔄",
320                    StackStatus::Corrupted => "💥",
321                };
322
323                let active_marker = if stack.is_active { "👉 " } else { "   " };
324
325                let content = format!(
326                    "{}{} {} ({} entries)",
327                    active_marker,
328                    status_icon,
329                    stack.name,
330                    stack.entries.len()
331                );
332
333                let style = if i == self.selected_stack {
334                    Style::default()
335                        .fg(Color::Yellow)
336                        .add_modifier(Modifier::BOLD)
337                } else {
338                    Style::default()
339                };
340
341                ListItem::new(content).style(style)
342            })
343            .collect();
344
345        let stacks_list = List::new(items)
346            .block(Block::default().borders(Borders::ALL).title("🗂️ Stacks"))
347            .highlight_style(
348                Style::default()
349                    .bg(Color::DarkGray)
350                    .add_modifier(Modifier::BOLD),
351            )
352            .highlight_symbol(">> ");
353
354        f.render_stateful_widget(stacks_list, chunks[0], &mut self.stack_list_state);
355
356        // Stack summary
357        self.draw_stack_summary(f, chunks[1]);
358    }
359
360    fn draw_stack_summary(&self, f: &mut Frame, area: Rect) {
361        if let Some(stack) = self.stacks.get(self.selected_stack) {
362            let mut lines = vec![
363                Line::from(vec![
364                    Span::styled("Name: ", Style::default().fg(Color::Cyan)),
365                    Span::raw(&stack.name),
366                ]),
367                Line::from(vec![
368                    Span::styled("Base: ", Style::default().fg(Color::Cyan)),
369                    Span::raw(&stack.base_branch),
370                ]),
371                Line::from(vec![
372                    Span::styled("Entries: ", Style::default().fg(Color::Cyan)),
373                    Span::raw(format!("{}", stack.entries.len())),
374                ]),
375                Line::from(vec![
376                    Span::styled("Status: ", Style::default().fg(Color::Cyan)),
377                    Span::raw(format!("{:?}", stack.status)),
378                ]),
379                Line::from(""),
380            ];
381
382            if let Some(desc) = &stack.description {
383                lines.push(Line::from(vec![Span::styled(
384                    "Description: ",
385                    Style::default().fg(Color::Cyan),
386                )]));
387                lines.push(Line::from(desc.clone()));
388                lines.push(Line::from(""));
389            }
390
391            // Recent entries
392            if !stack.entries.is_empty() {
393                lines.push(Line::from(vec![Span::styled(
394                    "Recent Commits:",
395                    Style::default()
396                        .fg(Color::Green)
397                        .add_modifier(Modifier::BOLD),
398                )]));
399
400                for (i, entry) in stack.entries.iter().rev().take(5).enumerate() {
401                    lines.push(Line::from(format!(
402                        "  {} {} - {}",
403                        i + 1,
404                        entry.short_hash(),
405                        entry.short_message(40)
406                    )));
407                }
408            }
409
410            let summary = Paragraph::new(lines)
411                .block(
412                    Block::default()
413                        .borders(Borders::ALL)
414                        .title("📊 Stack Info"),
415                )
416                .wrap(Wrap { trim: true });
417
418            f.render_widget(summary, area);
419        } else {
420            let empty = Paragraph::new("No stacks available.\n\nPress 'c' to create a new stack.")
421                .block(
422                    Block::default()
423                        .borders(Borders::ALL)
424                        .title("📊 Stack Info"),
425                )
426                .alignment(Alignment::Center);
427            f.render_widget(empty, area);
428        }
429    }
430
431    fn draw_details_tab(&self, f: &mut Frame, area: Rect) {
432        if let Some(stack) = self.stacks.get(self.selected_stack) {
433            if stack.entries.is_empty() {
434                let empty = Paragraph::new(
435                    "No commits in this stack.\n\nUse 'ca stack push' to add commits.",
436                )
437                .block(
438                    Block::default()
439                        .borders(Borders::ALL)
440                        .title("📋 Stack Details"),
441                )
442                .alignment(Alignment::Center);
443                f.render_widget(empty, area);
444                return;
445            }
446
447            let header = vec!["#", "Commit", "Branch", "Message", "Status"];
448            let rows = stack.entries.iter().enumerate().map(|(i, entry)| {
449                let status = if entry.pull_request_id.is_some() {
450                    "📤 Submitted"
451                } else {
452                    "⏳ Pending"
453                };
454
455                Row::new(vec![
456                    Cell::from((i + 1).to_string()),
457                    Cell::from(entry.short_hash()),
458                    Cell::from(entry.branch.clone()),
459                    Cell::from(entry.short_message(30)),
460                    Cell::from(status),
461                ])
462            });
463
464            let table = Table::new(
465                rows,
466                [
467                    Constraint::Length(3),
468                    Constraint::Length(8),
469                    Constraint::Length(20),
470                    Constraint::Length(35),
471                    Constraint::Length(12),
472                ],
473            )
474            .header(
475                Row::new(header)
476                    .style(
477                        Style::default()
478                            .fg(Color::Yellow)
479                            .add_modifier(Modifier::BOLD),
480                    )
481                    .bottom_margin(1),
482            )
483            .block(
484                Block::default()
485                    .borders(Borders::ALL)
486                    .title("📋 Stack Details"),
487            );
488
489            f.render_widget(table, area);
490        } else {
491            let empty = Paragraph::new("No stack selected")
492                .block(
493                    Block::default()
494                        .borders(Borders::ALL)
495                        .title("📋 Stack Details"),
496                )
497                .alignment(Alignment::Center);
498            f.render_widget(empty, area);
499        }
500    }
501
502    fn draw_actions_tab(&self, f: &mut Frame, area: Rect) {
503        let actions = [
504            "📌 Enter - Activate selected stack",
505            "📝 c - Create new stack",
506            "🚀 p - Push current commit to stack",
507            "📤 s - Submit entry for review",
508            "🔄 r - Refresh data",
509            "🔍 d - Toggle details view",
510            "❓ h/? - Show help",
511            "🚪 q/Esc - Quit",
512        ];
513
514        let lines: Vec<Line> = actions.iter().map(|&action| Line::from(action)).collect();
515
516        let paragraph = Paragraph::new(lines)
517            .block(
518                Block::default()
519                    .borders(Borders::ALL)
520                    .title("⚡ Quick Actions"),
521            )
522            .wrap(Wrap { trim: true });
523
524        f.render_widget(paragraph, area);
525    }
526
527    fn draw_footer(&self, f: &mut Frame, area: Rect) {
528        let last_refresh = format!("Last refresh: {:?} ago", self.last_refresh.elapsed());
529        let key_hints = " h:Help │ q:Quit │ r:Refresh │ Tab:Navigate │ ↑↓:Select │ Enter:Activate ";
530
531        let footer_text = format!("{last_refresh} │ {key_hints}");
532
533        let footer = Paragraph::new(footer_text)
534            .style(Style::default().fg(Color::Gray))
535            .alignment(Alignment::Center)
536            .block(Block::default().borders(Borders::ALL));
537
538        f.render_widget(footer, area);
539    }
540
541    fn draw_help_popup(&self, f: &mut Frame, area: Rect) {
542        let popup_area = self.centered_rect(80, 70, area);
543
544        let help_text = vec![
545            Line::from(vec![Span::styled(
546                "🌊 Cascade CLI - Interactive Stack Manager",
547                Style::default()
548                    .fg(Color::Cyan)
549                    .add_modifier(Modifier::BOLD),
550            )]),
551            Line::from(""),
552            Line::from(vec![Span::styled(
553                "📍 Navigation:",
554                Style::default()
555                    .fg(Color::Yellow)
556                    .add_modifier(Modifier::BOLD),
557            )]),
558            Line::from("  ↑↓ - Navigate stacks"),
559            Line::from("  Tab - Switch between tabs"),
560            Line::from("  Enter - Activate selected stack"),
561            Line::from(""),
562            Line::from(vec![Span::styled(
563                "⚡ Actions:",
564                Style::default()
565                    .fg(Color::Green)
566                    .add_modifier(Modifier::BOLD),
567            )]),
568            Line::from("  c - Create new stack"),
569            Line::from("  p - Push commit to active stack"),
570            Line::from("  s - Submit entry for review"),
571            Line::from("  r - Refresh data"),
572            Line::from("  d - Toggle details view"),
573            Line::from(""),
574            Line::from(vec![Span::styled(
575                "🎛️ Controls:",
576                Style::default()
577                    .fg(Color::Magenta)
578                    .add_modifier(Modifier::BOLD),
579            )]),
580            Line::from("  h/? - Show this help"),
581            Line::from("  q/Esc - Quit"),
582            Line::from(""),
583            Line::from(vec![Span::styled(
584                "💡 Tips:",
585                Style::default()
586                    .fg(Color::Blue)
587                    .add_modifier(Modifier::BOLD),
588            )]),
589            Line::from("  • Data refreshes automatically every 10 seconds"),
590            Line::from("  • Use CLI commands for complex operations"),
591            Line::from("  • Active stack is marked with 👉"),
592            Line::from(""),
593            Line::from("Press any key to close this help..."),
594        ];
595
596        let help_paragraph = Paragraph::new(help_text)
597            .block(
598                Block::default()
599                    .borders(Borders::ALL)
600                    .title("❓ Help")
601                    .style(Style::default().fg(Color::White)),
602            )
603            .wrap(Wrap { trim: true });
604
605        f.render_widget(Clear, popup_area);
606        f.render_widget(help_paragraph, popup_area);
607    }
608
609    fn draw_status_popup(&self, f: &mut Frame, area: Rect, message: &str) {
610        let popup_area = self.centered_rect(60, 20, area);
611
612        let status_paragraph = Paragraph::new(message)
613            .block(
614                Block::default()
615                    .borders(Borders::ALL)
616                    .title("💬 Status")
617                    .style(Style::default().fg(Color::Yellow)),
618            )
619            .alignment(Alignment::Center)
620            .wrap(Wrap { trim: true });
621
622        f.render_widget(Clear, popup_area);
623        f.render_widget(status_paragraph, popup_area);
624    }
625
626    fn centered_rect(&self, percent_x: u16, percent_y: u16, r: Rect) -> Rect {
627        let popup_layout = Layout::default()
628            .direction(Direction::Vertical)
629            .constraints([
630                Constraint::Percentage((100 - percent_y) / 2),
631                Constraint::Percentage(percent_y),
632                Constraint::Percentage((100 - percent_y) / 2),
633            ])
634            .split(r);
635
636        Layout::default()
637            .direction(Direction::Horizontal)
638            .constraints([
639                Constraint::Percentage((100 - percent_x) / 2),
640                Constraint::Percentage(percent_x),
641                Constraint::Percentage((100 - percent_x) / 2),
642            ])
643            .split(popup_layout[1])[1]
644    }
645}
646
647/// Run the TUI application
648pub async fn run() -> Result<()> {
649    let mut app = TuiApp::new()?;
650    app.run()
651}