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