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 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 let chunks = Layout::default()
231 .direction(Direction::Vertical)
232 .margin(1)
233 .constraints([
234 Constraint::Length(3), Constraint::Min(0), Constraint::Length(3), ])
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 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 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 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 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
642pub async fn run() -> Result<()> {
644 let mut app = TuiApp::new()?;
645 app.run()
646}