Skip to main content

git_warp/
tui.rs

1use crate::error::Result;
2use ratatui::{
3    backend::CrosstermBackend,
4    Terminal as RatatuiTerminal,
5    widgets::{Block, Borders, Paragraph, List, ListItem, ListState, Gauge, Table, Row, Cell},
6    layout::{Layout, Constraint, Direction, Alignment, Margin},
7    text::{Span, Line},
8    style::{Style, Color, Modifier},
9    Frame,
10};
11use crossterm::{
12    event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, poll},
13    execute,
14    terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
15};
16use std::{io, time::{Duration, Instant}, path::PathBuf};
17use chrono::Timelike;
18
19pub struct TuiApp {
20    should_quit: bool,
21    selected_index: usize,
22    last_update: Instant,
23}
24
25#[derive(Debug, Clone)]
26pub struct AgentActivity {
27    pub timestamp: String,
28    pub agent_name: String,
29    pub activity: String,
30    pub file_path: Option<PathBuf>,
31    pub status: AgentStatus,
32}
33
34#[derive(Debug, Clone)]
35pub enum AgentStatus {
36    Active,
37    Waiting,
38    Completed,
39    Error,
40}
41
42impl AgentStatus {
43    pub fn color(&self) -> Color {
44        match self {
45            AgentStatus::Active => Color::Green,
46            AgentStatus::Waiting => Color::Yellow,
47            AgentStatus::Completed => Color::Blue,
48            AgentStatus::Error => Color::Red,
49        }
50    }
51    
52    pub fn symbol(&self) -> &'static str {
53        match self {
54            AgentStatus::Active => "๐Ÿ”„",
55            AgentStatus::Waiting => "โณ",
56            AgentStatus::Completed => "โœ…",
57            AgentStatus::Error => "โŒ",
58        }
59    }
60}
61
62impl TuiApp {
63    pub fn new() -> Self {
64        Self {
65            should_quit: false,
66            selected_index: 0,
67            last_update: Instant::now(),
68        }
69    }
70    
71    pub fn get_selected_index(&self) -> usize {
72        self.selected_index
73    }
74    
75    pub fn set_selected_index(&mut self, index: usize) {
76        self.selected_index = index;
77    }
78    
79    pub fn get_last_update(&self) -> Instant {
80        self.last_update
81    }
82    
83    pub fn set_last_update(&mut self, time: Instant) {
84        self.last_update = time;
85    }
86    
87    pub fn should_quit(&self) -> bool {
88        self.should_quit
89    }
90    
91    pub fn run(&mut self) -> Result<()> {
92        // Setup terminal
93        enable_raw_mode()?;
94        let mut stdout = io::stdout();
95        execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
96        let backend = CrosstermBackend::new(stdout);
97        let mut terminal = RatatuiTerminal::new(backend)?;
98        
99        let res = self.run_app(&mut terminal);
100        
101        // Restore terminal
102        disable_raw_mode()?;
103        execute!(
104            terminal.backend_mut(),
105            LeaveAlternateScreen,
106            DisableMouseCapture
107        )?;
108        terminal.show_cursor()?;
109        
110        res
111    }
112    
113    fn run_app(&mut self, terminal: &mut RatatuiTerminal<CrosstermBackend<io::Stdout>>) -> Result<()> {
114        // Mock agent data for demo - in real implementation this would come from file watchers
115        let mut activities = vec![
116            AgentActivity {
117                timestamp: "14:32:15".to_string(),
118                agent_name: "Claude-Code".to_string(),
119                activity: "Analyzing code structure".to_string(),
120                file_path: Some(PathBuf::from("/project/src/main.rs")),
121                status: AgentStatus::Active,
122            },
123            AgentActivity {
124                timestamp: "14:31:42".to_string(),
125                agent_name: "Claude-Code".to_string(),
126                activity: "Refactoring function".to_string(),
127                file_path: Some(PathBuf::from("/project/src/utils.rs")),
128                status: AgentStatus::Completed,
129            },
130            AgentActivity {
131                timestamp: "14:30:18".to_string(),
132                agent_name: "Claude-Code".to_string(),
133                activity: "Waiting for user input".to_string(),
134                file_path: None,
135                status: AgentStatus::Waiting,
136            },
137        ];
138        
139        loop {
140            // Non-blocking event check
141            let timeout = Duration::from_millis(100);
142            if poll(timeout)? {
143                if let Event::Key(key) = event::read()? {
144                    match key.code {
145                        KeyCode::Char('q') => {
146                            self.should_quit = true;
147                        }
148                        KeyCode::Esc => {
149                            self.should_quit = true;
150                        }
151                        KeyCode::Up => {
152                            if self.selected_index > 0 {
153                                self.selected_index -= 1;
154                            }
155                        }
156                        KeyCode::Down => {
157                            if self.selected_index < activities.len().saturating_sub(1) {
158                                self.selected_index += 1;
159                            }
160                        }
161                        KeyCode::Char('r') => {
162                            // Simulate refresh - add new activity
163                            activities.insert(0, AgentActivity {
164                                timestamp: format!("{:02}:{:02}:{:02}", 
165                                    chrono::Local::now().hour(),
166                                    chrono::Local::now().minute(), 
167                                    chrono::Local::now().second()),
168                                agent_name: "Claude-Code".to_string(),
169                                activity: "Processing new request".to_string(),
170                                file_path: Some(PathBuf::from("/project/src/new_module.rs")),
171                                status: AgentStatus::Active,
172                            });
173                            self.selected_index = 0;
174                        }
175                        _ => {}
176                    }
177                }
178            }
179            
180            // Update UI
181            terminal.draw(|f| self.draw_agents_dashboard(f, &activities))?;
182            
183            if self.should_quit {
184                break;
185            }
186        }
187        
188        Ok(())
189    }
190    
191    fn draw_agents_dashboard(&self, f: &mut Frame, activities: &[AgentActivity]) {
192        let chunks = Layout::default()
193            .direction(Direction::Vertical)
194            .margin(1)
195            .constraints([
196                Constraint::Length(3),    // Header
197                Constraint::Min(8),       // Main content
198                Constraint::Length(5),    // Stats
199                Constraint::Length(3),    // Help
200            ])
201            .split(f.size());
202        
203        // Header
204        let header = Paragraph::new("๐Ÿค– Agent Activity Monitor")
205            .style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD))
206            .alignment(Alignment::Center)
207            .block(Block::default().borders(Borders::ALL));
208        f.render_widget(header, chunks[0]);
209        
210        // Main activity list
211        let activity_items: Vec<ListItem> = activities
212            .iter()
213            .enumerate()
214            .map(|(i, activity)| {
215                let style = if i == self.selected_index {
216                    Style::default().bg(Color::DarkGray)
217                } else {
218                    Style::default()
219                };
220                
221                let file_info = if let Some(path) = &activity.file_path {
222                    format!(" ({})", path.file_name().unwrap_or_default().to_string_lossy())
223                } else {
224                    String::new()
225                };
226                
227                let content = format!(
228                    "{} {} [{}] {}{}!", 
229                    activity.status.symbol(),
230                    activity.timestamp,
231                    activity.agent_name,
232                    activity.activity,
233                    file_info
234                );
235                
236                ListItem::new(Line::from(Span::styled(
237                    content,
238                    style.fg(activity.status.color())
239                )))
240            })
241            .collect();
242        
243        let activities_list = List::new(activity_items)
244            .block(Block::default()
245                .title("Recent Activity")
246                .borders(Borders::ALL))
247            .highlight_style(Style::default().add_modifier(Modifier::REVERSED))
248            .highlight_symbol(">> ");
249        
250        let mut list_state = ListState::default();
251        list_state.select(Some(self.selected_index));
252        f.render_stateful_widget(activities_list, chunks[1], &mut list_state);
253        
254        // Stats section
255        let stats_chunks = Layout::default()
256            .direction(Direction::Horizontal)
257            .constraints([
258                Constraint::Percentage(33),
259                Constraint::Percentage(33),
260                Constraint::Percentage(34),
261            ])
262            .split(chunks[2]);
263        
264        let active_count = activities.iter().filter(|a| matches!(a.status, AgentStatus::Active)).count();
265        let total_count = activities.len();
266        let completed_count = activities.iter().filter(|a| matches!(a.status, AgentStatus::Completed)).count();
267        
268        // Active agents gauge
269        let active_ratio = if total_count > 0 { active_count as f64 / total_count as f64 } else { 0.0 };
270        let active_gauge = Gauge::default()
271            .block(Block::default().title("Active").borders(Borders::ALL))
272            .gauge_style(Style::default().fg(Color::Green))
273            .ratio(active_ratio)
274            .label(format!("{}/{}", active_count, total_count));
275        f.render_widget(active_gauge, stats_chunks[0]);
276        
277        // Completion rate
278        let completion_ratio = if total_count > 0 { completed_count as f64 / total_count as f64 } else { 0.0 };
279        let completion_gauge = Gauge::default()
280            .block(Block::default().title("Completed").borders(Borders::ALL))
281            .gauge_style(Style::default().fg(Color::Blue))
282            .ratio(completion_ratio)
283            .label(format!("{:.1}%", completion_ratio * 100.0));
284        f.render_widget(completion_gauge, stats_chunks[1]);
285        
286        // Uptime
287        let uptime = self.last_update.elapsed().as_secs();
288        let uptime_display = Paragraph::new(format!("{}m {}s", uptime / 60, uptime % 60))
289            .block(Block::default().title("Uptime").borders(Borders::ALL))
290            .alignment(Alignment::Center)
291            .style(Style::default().fg(Color::Yellow));
292        f.render_widget(uptime_display, stats_chunks[2]);
293        
294        // Help
295        let help_text = "โ†‘โ†“: Navigate | r: Refresh | q: Quit | Esc: Exit";
296        let help = Paragraph::new(help_text)
297            .style(Style::default().fg(Color::Gray))
298            .alignment(Alignment::Center)
299            .block(Block::default().borders(Borders::ALL).title("Help"));
300        f.render_widget(help, chunks[3]);
301    }
302}
303
304pub struct AgentsDashboard;
305
306impl AgentsDashboard {
307    pub fn new() -> Self {
308        Self
309    }
310    
311    pub fn run(&self) -> Result<()> {
312        let mut app = TuiApp::new();
313        app.run()
314    }
315    
316    /// Start monitoring agents in a specific worktree
317    pub fn monitor_worktree(&self, worktree_path: PathBuf) -> Result<()> {
318        println!("๐Ÿ” Starting agent monitoring for: {}", worktree_path.display());
319        
320        // TODO: In real implementation, set up file watchers here
321        // let (tx, rx) = mpsc::channel();
322        // let mut watcher: RecommendedWatcher = Watcher::new_immediate(move |res| {
323        //     tx.send(res).unwrap();
324        // })?;
325        // watcher.watch(&worktree_path, RecursiveMode::Recursive)?;
326        
327        let mut app = TuiApp::new();
328        app.run()
329    }
330}
331
332pub struct CleanupTui;
333
334impl CleanupTui {
335    pub fn new() -> Self {
336        Self
337    }
338    
339    pub fn run(&self) -> Result<Vec<String>> {
340        use crate::git::GitRepository;
341        use crossterm::{
342            event::{self, Event, KeyCode, KeyEventKind},
343            execute,
344            terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
345        };
346        use ratatui::{
347            backend::CrosstermBackend,
348            layout::{Alignment, Constraint, Direction, Layout},
349            style::{Color, Style},
350            widgets::{Block, Borders, List, ListItem, Paragraph},
351            Terminal,
352        };
353        use std::io;
354
355        // Get the git repository and worktrees
356        let git_repo = GitRepository::find()
357            .map_err(|_| anyhow::anyhow!("Not in a git repository"))?;
358        let worktrees = git_repo.list_worktrees()?;
359        let branch_statuses = git_repo.analyze_branches_for_cleanup(&worktrees)?;
360
361        if branch_statuses.is_empty() {
362            println!("โœจ No worktrees found that can be cleaned up!");
363            return Ok(vec![]);
364        }
365
366        // Setup terminal
367        enable_raw_mode()?;
368        let mut stdout = io::stdout();
369        execute!(stdout, EnterAlternateScreen)?;
370        let backend = CrosstermBackend::new(stdout);
371        let mut terminal = Terminal::new(backend)?;
372
373        let mut selected_index = 0;
374        let mut selected_branches: Vec<bool> = vec![false; branch_statuses.len()];
375        let mut should_quit = false;
376        let mut confirmed = false;
377
378        let result = loop {
379            terminal.draw(|f| {
380                let chunks = Layout::default()
381                    .direction(Direction::Vertical)
382                    .margin(1)
383                    .constraints([
384                        Constraint::Length(3),
385                        Constraint::Min(0),
386                        Constraint::Length(4),
387                    ])
388                    .split(f.size());
389
390                // Header
391                let header = Paragraph::new("๐Ÿงน Interactive Worktree Cleanup")
392                    .style(Style::default().fg(Color::Yellow))
393                    .alignment(Alignment::Center)
394                    .block(Block::default().borders(Borders::ALL));
395                f.render_widget(header, chunks[0]);
396
397                // Branch list
398                let items: Vec<ListItem> = branch_statuses
399                    .iter()
400                    .enumerate()
401                    .map(|(i, status)| {
402                        let checkbox = if selected_branches[i] { "โ˜‘๏ธ" } else { "โ˜" };
403                        let merged_indicator = if status.is_merged { "โœ…" } else { "๐Ÿ”„" };
404                        let style = if i == selected_index {
405                            Style::default().bg(Color::Blue).fg(Color::White)
406                        } else {
407                            Style::default()
408                        };
409                        
410                        ListItem::new(format!(
411                            "{} {} {} - {}{}",
412                            checkbox,
413                            merged_indicator,
414                            status.branch,
415                            if status.has_remote { "with remote" } else { "no remote" },
416                            if status.has_uncommitted_changes { " (uncommitted)" } else { "" }
417                        )).style(style)
418                    })
419                    .collect();
420
421                let list = List::new(items)
422                    .block(Block::default()
423                        .borders(Borders::ALL)
424                        .title("Select branches to clean up (Space to select, Enter to confirm)"));
425                f.render_widget(list, chunks[1]);
426
427                // Footer with controls
428                let selected_count = selected_branches.iter().filter(|&&x| x).count();
429                let footer_text = format!(
430                    "โ†‘โ†“: Navigate | Space: Select | Enter: Confirm ({} selected) | q: Quit",
431                    selected_count
432                );
433                let footer = Paragraph::new(footer_text)
434                    .style(Style::default().fg(Color::Gray))
435                    .alignment(Alignment::Center)
436                    .block(Block::default().borders(Borders::ALL).title("Controls"));
437                f.render_widget(footer, chunks[2]);
438            })?;
439
440            // Handle input
441            if let Event::Key(key) = event::read()? {
442                if key.kind == KeyEventKind::Press {
443                    match key.code {
444                        KeyCode::Char('q') => {
445                            should_quit = true;
446                            break;
447                        }
448                        KeyCode::Up => {
449                            if selected_index > 0 {
450                                selected_index -= 1;
451                            }
452                        }
453                        KeyCode::Down => {
454                            if selected_index < branch_statuses.len() - 1 {
455                                selected_index += 1;
456                            }
457                        }
458                        KeyCode::Char(' ') => {
459                            selected_branches[selected_index] = !selected_branches[selected_index];
460                        }
461                        KeyCode::Enter => {
462                            confirmed = true;
463                            break;
464                        }
465                        _ => {}
466                    }
467                }
468            }
469        };
470
471        // Cleanup terminal
472        disable_raw_mode()?;
473        execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
474        terminal.show_cursor()?;
475
476        if should_quit || !confirmed {
477            return Ok(vec![]);
478        }
479
480        // Return selected branches
481        let selected: Vec<String> = branch_statuses
482            .iter()
483            .enumerate()
484            .filter_map(|(i, status)| {
485                if selected_branches[i] {
486                    Some(status.branch.clone())
487                } else {
488                    None
489                }
490            })
491            .collect();
492
493        Ok(selected)
494    }
495}
496
497pub struct ConfigTui;
498
499impl ConfigTui {
500    pub fn new() -> Self {
501        Self
502    }
503    
504    pub fn run(&self) -> Result<()> {
505        println!("โš™๏ธ Configuration Editor");
506        println!("=======================");
507        println!("๐Ÿ“ Interactive config editor coming in v0.3.1");
508        println!("๐Ÿ’ก For now, use: warp config --show");
509        println!("๐Ÿ’ก Edit config file at: ~/.config/git-warp/config.toml");
510        
511        // Show current TUI for demonstration
512        let mut app = TuiApp::new();
513        app.run()
514    }
515}
516
517#[cfg(test)]
518mod tests {
519    use super::*;
520    
521    #[test]
522    fn test_tui_creation() {
523        let dashboard = AgentsDashboard::new();
524        // Just test that we can create the TUI components
525        let _cleanup_tui = CleanupTui::new();
526        let _config_tui = ConfigTui::new();
527    }
528    
529    #[test]
530    fn test_agent_status() {
531        assert_eq!(AgentStatus::Active.symbol(), "๐Ÿ”„");
532        assert_eq!(AgentStatus::Waiting.color(), Color::Yellow);
533        assert_eq!(AgentStatus::Completed.symbol(), "โœ…");
534        assert_eq!(AgentStatus::Error.color(), Color::Red);
535    }
536    
537    #[test]
538    fn test_agent_activity() {
539        let activity = AgentActivity {
540            timestamp: "12:34:56".to_string(),
541            agent_name: "TestAgent".to_string(),
542            activity: "Testing".to_string(),
543            file_path: Some(PathBuf::from("/test/file.rs")),
544            status: AgentStatus::Active,
545        };
546        
547        assert_eq!(activity.agent_name, "TestAgent");
548        assert!(activity.file_path.is_some());
549    }
550}