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
22pub 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(¤t_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 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 let result = self.run_app(&mut terminal);
78
79 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 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 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; }
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 self.error_message = Some("Create stack: Not implemented yet".to_string());
166 }
167 KeyCode::Char('s') => {
168 self.error_message = Some("Submit entry: Not implemented yet".to_string());
170 }
171 KeyCode::Char('p') => {
172 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 let chunks = Layout::default()
234 .direction(Direction::Vertical)
235 .margin(1)
236 .constraints([
237 Constraint::Length(3), Constraint::Min(0), Constraint::Length(3), ])
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 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 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 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 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
643pub async fn run() -> Result<()> {
645 let mut app = TuiApp::new()?;
646 app.run()
647}