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 KeyCode::Char('c') => {
168 self.error_message = Some("Create stack: Not implemented yet".to_string());
170 }
171 KeyCode::Char('s') => {
172 self.error_message = Some("Submit entry: Not implemented yet".to_string());
174 }
175 KeyCode::Char('p') => {
176 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 let chunks = Layout::default()
238 .direction(Direction::Vertical)
239 .margin(1)
240 .constraints([
241 Constraint::Length(3), Constraint::Min(0), Constraint::Length(3), ])
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 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 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 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 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
647pub async fn run() -> Result<()> {
649 let mut app = TuiApp::new()?;
650 app.run()
651}