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("No stacks available.\n\nExit (q) and use 'ca stack create' to create a stack.")
412 .block(
413 Block::default()
414 .borders(Borders::ALL)
415 .title("📊 Stack Info"),
416 )
417 .alignment(Alignment::Center);
418 f.render_widget(empty, area);
419 }
420 }
421
422 fn draw_details_tab(&self, f: &mut Frame, area: Rect) {
423 if let Some(stack) = self.stacks.get(self.selected_stack) {
424 if stack.entries.is_empty() {
425 let empty = Paragraph::new(
426 "No commits in this stack.\n\nUse 'ca stack push' to add commits.",
427 )
428 .block(
429 Block::default()
430 .borders(Borders::ALL)
431 .title("📋 Stack Details"),
432 )
433 .alignment(Alignment::Center);
434 f.render_widget(empty, area);
435 return;
436 }
437
438 let header = vec!["#", "Commit", "Branch", "Message", "Status"];
439 let rows = stack.entries.iter().enumerate().map(|(i, entry)| {
440 let status = if entry.pull_request_id.is_some() {
441 "📤 Submitted"
442 } else {
443 "⏳ Pending"
444 };
445
446 Row::new(vec![
447 Cell::from((i + 1).to_string()),
448 Cell::from(entry.short_hash()),
449 Cell::from(entry.branch.clone()),
450 Cell::from(entry.short_message(30)),
451 Cell::from(status),
452 ])
453 });
454
455 let table = Table::new(
456 rows,
457 [
458 Constraint::Length(3),
459 Constraint::Length(8),
460 Constraint::Length(20),
461 Constraint::Length(35),
462 Constraint::Length(12),
463 ],
464 )
465 .header(
466 Row::new(header)
467 .style(
468 Style::default()
469 .fg(Color::Yellow)
470 .add_modifier(Modifier::BOLD),
471 )
472 .bottom_margin(1),
473 )
474 .block(
475 Block::default()
476 .borders(Borders::ALL)
477 .title("📋 Stack Details"),
478 );
479
480 f.render_widget(table, area);
481 } else {
482 let empty = Paragraph::new("No stack selected")
483 .block(
484 Block::default()
485 .borders(Borders::ALL)
486 .title("📋 Stack Details"),
487 )
488 .alignment(Alignment::Center);
489 f.render_widget(empty, area);
490 }
491 }
492
493 fn draw_actions_tab(&self, f: &mut Frame, area: Rect) {
494 let actions = [
495 "📌 Enter - Activate selected stack",
496 "📝 c - Create new stack",
497 "🚀 p - Push current commit to stack",
498 "📤 s - Submit entry for review",
499 "🔄 r - Refresh data",
500 "🔍 d - Toggle details view",
501 "❓ h/? - Show help",
502 "🚪 q/Esc - Quit",
503 ];
504
505 let lines: Vec<Line> = actions.iter().map(|&action| Line::from(action)).collect();
506
507 let paragraph = Paragraph::new(lines)
508 .block(
509 Block::default()
510 .borders(Borders::ALL)
511 .title("⚡ Quick Actions"),
512 )
513 .wrap(Wrap { trim: true });
514
515 f.render_widget(paragraph, area);
516 }
517
518 fn draw_footer(&self, f: &mut Frame, area: Rect) {
519 let last_refresh = format!("Last refresh: {:?} ago", self.last_refresh.elapsed());
520 let key_hints = " h:Help │ q:Quit │ r:Refresh │ Tab:Navigate │ ↑↓:Select │ Enter:Activate ";
521
522 let footer_text = format!("{last_refresh} │ {key_hints}");
523
524 let footer = Paragraph::new(footer_text)
525 .style(Style::default().fg(Color::Gray))
526 .alignment(Alignment::Center)
527 .block(Block::default().borders(Borders::ALL));
528
529 f.render_widget(footer, area);
530 }
531
532 fn draw_help_popup(&self, f: &mut Frame, area: Rect) {
533 let popup_area = self.centered_rect(80, 70, area);
534
535 let help_text = vec![
536 Line::from(vec![Span::styled(
537 "🌊 Cascade CLI - Interactive Stack Manager",
538 Style::default()
539 .fg(Color::Cyan)
540 .add_modifier(Modifier::BOLD),
541 )]),
542 Line::from(""),
543 Line::from(vec![Span::styled(
544 "📍 Navigation:",
545 Style::default()
546 .fg(Color::Yellow)
547 .add_modifier(Modifier::BOLD),
548 )]),
549 Line::from(" ↑↓ - Navigate stacks"),
550 Line::from(" Tab - Switch between tabs"),
551 Line::from(" Enter - Activate selected stack"),
552 Line::from(""),
553 Line::from(vec![Span::styled(
554 "⚡ Actions:",
555 Style::default()
556 .fg(Color::Green)
557 .add_modifier(Modifier::BOLD),
558 )]),
559 Line::from(" c - Create new stack"),
560 Line::from(" p - Push commit to active stack"),
561 Line::from(" s - Submit entry for review"),
562 Line::from(" r - Refresh data"),
563 Line::from(" d - Toggle details view"),
564 Line::from(""),
565 Line::from(vec![Span::styled(
566 "🎛️ Controls:",
567 Style::default()
568 .fg(Color::Magenta)
569 .add_modifier(Modifier::BOLD),
570 )]),
571 Line::from(" h/? - Show this help"),
572 Line::from(" q/Esc - Quit"),
573 Line::from(""),
574 Line::from(vec![Span::styled(
575 "💡 Tips:",
576 Style::default()
577 .fg(Color::Blue)
578 .add_modifier(Modifier::BOLD),
579 )]),
580 Line::from(" • Data refreshes automatically every 10 seconds"),
581 Line::from(" • Use CLI commands for complex operations"),
582 Line::from(" • Active stack is marked with 👉"),
583 Line::from(""),
584 Line::from("Press any key to close this help..."),
585 ];
586
587 let help_paragraph = Paragraph::new(help_text)
588 .block(
589 Block::default()
590 .borders(Borders::ALL)
591 .title("❓ Help")
592 .style(Style::default().fg(Color::White)),
593 )
594 .wrap(Wrap { trim: true });
595
596 f.render_widget(Clear, popup_area);
597 f.render_widget(help_paragraph, popup_area);
598 }
599
600 fn draw_status_popup(&self, f: &mut Frame, area: Rect, message: &str) {
601 let popup_area = self.centered_rect(60, 20, area);
602
603 let status_paragraph = Paragraph::new(message)
604 .block(
605 Block::default()
606 .borders(Borders::ALL)
607 .title("💬 Status")
608 .style(Style::default().fg(Color::Yellow)),
609 )
610 .alignment(Alignment::Center)
611 .wrap(Wrap { trim: true });
612
613 f.render_widget(Clear, popup_area);
614 f.render_widget(status_paragraph, popup_area);
615 }
616
617 fn centered_rect(&self, percent_x: u16, percent_y: u16, r: Rect) -> Rect {
618 let popup_layout = Layout::default()
619 .direction(Direction::Vertical)
620 .constraints([
621 Constraint::Percentage((100 - percent_y) / 2),
622 Constraint::Percentage(percent_y),
623 Constraint::Percentage((100 - percent_y) / 2),
624 ])
625 .split(r);
626
627 Layout::default()
628 .direction(Direction::Horizontal)
629 .constraints([
630 Constraint::Percentage((100 - percent_x) / 2),
631 Constraint::Percentage(percent_x),
632 Constraint::Percentage((100 - percent_x) / 2),
633 ])
634 .split(popup_layout[1])[1]
635 }
636}
637
638pub async fn run() -> Result<()> {
640 let mut app = TuiApp::new()?;
641 app.run()
642}