watchdiff_tui/ui/
tui.rs

1use std::io;
2use std::time::Duration;
3use std::path::PathBuf;
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::{Backend, CrosstermBackend},
11    layout::{Alignment, Constraint, Direction, Layout, Rect},
12    style::{Color, Modifier, Style},
13    text::{Line, Span},
14    widgets::{
15        Block, Borders, Clear, List, ListItem, ListState, Paragraph, Scrollbar,
16        ScrollbarOrientation, ScrollbarState, Wrap,
17    },
18    Frame, Terminal,
19};
20use crate::core::{AppEvent, AppState, FileEventKind, FileWatcher, HighlightedFileEvent};
21use std::time::Instant;
22
23/// Vim mode for enhanced navigation
24#[derive(Debug, Clone, PartialEq)]
25pub enum VimMode {
26    Normal,
27    Disabled,
28}
29
30/// Application UI mode
31#[derive(Debug, Clone, PartialEq)]
32pub enum AppMode {
33    Normal,
34    Search,
35    Help,
36}
37
38/// Search mode state for fuzzy file search
39#[derive(Debug, Clone, Default)]
40pub struct SearchState {
41    pub query: String,
42    pub filtered_files: Vec<PathBuf>,
43    pub selected_index: usize,
44    pub preview_scroll: usize,
45}
46
47impl SearchState {
48    pub fn update_filtered_files(&mut self, all_files: &std::collections::HashSet<PathBuf>, events: &[crate::core::HighlightedFileEvent]) {
49        if self.query.is_empty() {
50            // Show all files when no query
51            self.filtered_files = all_files.iter().cloned().collect();
52        } else {
53            // Apply fuzzy search
54            let mut scored_files: Vec<(PathBuf, i32)> = all_files
55                .iter()
56                .filter_map(|path| {
57                    let score = self.fuzzy_match(path);
58                    if score > 0 {
59                        Some((path.clone(), score))
60                    } else {
61                        None
62                    }
63                })
64                .collect();
65            
66            // Sort by score (higher is better) and recent activity
67            scored_files.sort_by(|a, b| {
68                let score_cmp = b.1.cmp(&a.1);
69                if score_cmp == std::cmp::Ordering::Equal {
70                    // If scores are equal, prioritize recently changed files
71                    let a_recent = events.iter().any(|e| e.path == a.0);
72                    let b_recent = events.iter().any(|e| e.path == b.0);
73                    b_recent.cmp(&a_recent)
74                } else {
75                    score_cmp
76                }
77            });
78            
79            // Extract just the paths
80            self.filtered_files = scored_files.into_iter().map(|(path, _)| path).collect();
81        }
82        
83        // Reset selection if out of bounds
84        if self.selected_index >= self.filtered_files.len() {
85            self.selected_index = 0;
86        }
87    }
88    
89    fn fuzzy_match(&self, path: &PathBuf) -> i32 {
90        let query = self.query.to_lowercase();
91        let path_str = path.to_string_lossy().to_lowercase();
92        let filename = path.file_name()
93            .and_then(|n| n.to_str())
94            .unwrap_or("")
95            .to_lowercase();
96        
97        // Simple fuzzy matching algorithm
98        let mut score: i32 = 0;
99        let mut query_chars = query.chars().peekable();
100        let mut consecutive_bonus = 0;
101        
102        // First check filename for exact substring match (higher score)
103        if filename.contains(&query) {
104            score += 100;
105        }
106        
107        // Then check full path
108        if path_str.contains(&query) {
109            score += 50;
110        }
111        
112        // Character-by-character fuzzy matching
113        let path_chars: Vec<char> = path_str.chars().collect();
114        let mut path_idx = 0;
115        
116        while let Some(&query_char) = query_chars.peek() {
117            if path_idx >= path_chars.len() {
118                break;
119            }
120            
121            if path_chars[path_idx] == query_char {
122                score += 10 + consecutive_bonus;
123                consecutive_bonus += 5; // Bonus for consecutive matches
124                query_chars.next();
125            } else {
126                consecutive_bonus = 0;
127            }
128            path_idx += 1;
129        }
130        
131        // Penalty for longer paths (prefer shorter, more specific matches)
132        score = score.saturating_sub(path_str.len() as i32 / 10);
133        
134        // Return 0 if we didn't match all query characters
135        if query_chars.peek().is_some() {
136            0
137        } else {
138            score.max(1)
139        }
140    }
141    
142    pub fn get_selected_file(&self) -> Option<&PathBuf> {
143        self.filtered_files.get(self.selected_index)
144    }
145    
146    pub fn move_up(&mut self) {
147        if self.selected_index > 0 {
148            self.selected_index -= 1;
149        }
150    }
151    
152    pub fn move_down(&mut self) {
153        if self.selected_index + 1 < self.filtered_files.len() {
154            self.selected_index += 1;
155        }
156    }
157    
158    pub fn add_char(&mut self, c: char) {
159        self.query.push(c);
160    }
161    
162    pub fn remove_char(&mut self) {
163        self.query.pop();
164    }
165    
166    pub fn clear(&mut self) {
167        self.query.clear();
168        self.filtered_files.clear();
169        self.selected_index = 0;
170        self.preview_scroll = 0;
171    }
172}
173
174/// Stores vim key sequence state for multi-key commands
175#[derive(Debug, Clone, Default)]
176pub struct VimKeySequence {
177    pub keys: String,
178    pub last_key_time: Option<Instant>,
179}
180
181impl VimKeySequence {
182    pub fn push_key(&mut self, key: char) {
183        // Reset if too much time has passed (1 second timeout)
184        if let Some(last_time) = self.last_key_time {
185            if last_time.elapsed().as_secs() > 1 {
186                self.keys.clear();
187            }
188        }
189        
190        self.keys.push(key);
191        self.last_key_time = Some(Instant::now());
192        
193        // Limit sequence length to prevent memory issues
194        if self.keys.len() > 10 {
195            self.keys.clear();
196        }
197    }
198    
199    pub fn clear(&mut self) {
200        self.keys.clear();
201        self.last_key_time = None;
202    }
203    
204    pub fn matches(&self, sequence: &str) -> bool {
205        self.keys == sequence
206    }
207}
208
209/// Strip ANSI escape codes from a string
210fn strip_ansi_codes(input: &str) -> String {
211    let mut result = String::new();
212    let mut chars = input.chars().peekable();
213    
214    while let Some(ch) = chars.next() {
215        if ch == '\x1b' && chars.peek() == Some(&'[') {
216            // Skip the escape sequence
217            chars.next(); // consume '['
218            while let Some(ch) = chars.next() {
219                if ch.is_ascii_alphabetic() {
220                    break;
221                }
222            }
223        } else {
224            result.push(ch);
225        }
226    }
227    
228    result
229}
230
231pub struct TuiApp {
232    pub state: AppState,
233    pub watcher: FileWatcher,
234    pub list_state: ListState,
235    pub should_quit: bool,
236    pub diff_scroll: usize,
237    pub file_list_scroll: usize,
238    pub vim_mode: VimMode,
239    pub vim_key_sequence: VimKeySequence,
240    pub app_mode: AppMode,
241    pub search_state: SearchState,
242}
243
244impl TuiApp {
245    pub fn new(watcher: FileWatcher) -> Self {
246        let initial_files = watcher.get_initial_files().unwrap_or_default();
247        let mut state = AppState::default();
248        
249        for file in initial_files {
250            state.watched_files.insert(file);
251        }
252
253        Self {
254            state,
255            watcher,
256            list_state: ListState::default(),
257            should_quit: false,
258            diff_scroll: 0,
259            file_list_scroll: 0,
260            vim_mode: VimMode::Disabled, // Start with vim mode disabled
261            vim_key_sequence: VimKeySequence::default(),
262            app_mode: AppMode::Normal,
263            search_state: SearchState::default(),
264        }
265    }
266
267    pub fn run<B: Backend>(mut self, terminal: &mut Terminal<B>) -> io::Result<()> {
268        loop {
269            terminal.draw(|f| self.ui(f))?;
270
271            // Handle file watcher events
272            match self.watcher.recv_timeout(Duration::from_millis(50)) {
273                Ok(AppEvent::FileChanged(file_event)) => {
274                    self.state.add_event(file_event);
275                }
276                Ok(AppEvent::Quit) => {
277                    self.should_quit = true;
278                }
279                Ok(_) => {}
280                Err(_) => {} // Timeout, continue
281            }
282
283            // Handle keyboard input
284            if event::poll(Duration::from_millis(50))? {
285                if let Event::Key(key) = event::read()? {
286                    if key.kind == KeyEventKind::Press {
287                        // Handle search mode keys first
288                        if self.app_mode == AppMode::Search {
289                            if self.handle_search_keys(&key) {
290                                continue; // Key was handled by search mode
291                            }
292                        }
293
294                        // Handle vim mode toggle and key sequences
295                        if self.handle_vim_keys(&key) {
296                            continue; // Key was handled by vim mode
297                        }
298                        
299                        match key.code {
300                            KeyCode::Char('q') | KeyCode::Esc => {
301                                match self.app_mode {
302                                    AppMode::Search => {
303                                        // Exit search mode
304                                        self.app_mode = AppMode::Normal;
305                                        self.search_state.clear();
306                                    }
307                                    AppMode::Help => {
308                                        // Exit help mode
309                                        self.app_mode = AppMode::Normal;
310                                    }
311                                    AppMode::Normal => {
312                                        // Toggle vim mode with Esc if not already quitting
313                                        if self.vim_mode == VimMode::Disabled {
314                                            self.vim_mode = VimMode::Normal;
315                                            self.vim_key_sequence.clear();
316                                        } else {
317                                            self.should_quit = true;
318                                        }
319                                    }
320                                }
321                            },
322                            KeyCode::Char('h') | KeyCode::F(1) => {
323                                self.app_mode = if self.app_mode == AppMode::Help {
324                                    AppMode::Normal
325                                } else {
326                                    AppMode::Help
327                                };
328                            },
329                            KeyCode::Char('/') => {
330                                // Enter search mode
331                                self.app_mode = AppMode::Search;
332                                self.search_state.clear();
333                            },
334                            KeyCode::Char('p') if key.modifiers.contains(crossterm::event::KeyModifiers::CONTROL) => {
335                                // Enter search mode (Ctrl+P alternative)
336                                self.app_mode = AppMode::Search;
337                                self.search_state.clear();
338                            },
339                            KeyCode::Up | KeyCode::Char('k') => {
340                                if self.diff_scroll > 0 {
341                                    self.diff_scroll -= 1;
342                                }
343                            }
344                            KeyCode::Down | KeyCode::Char('j') => {
345                                let max_scroll = self.state.events.len().saturating_sub(1);
346                                if self.diff_scroll < max_scroll {
347                                    self.diff_scroll += 1;
348                                }
349                            }
350                            KeyCode::PageUp => {
351                                self.diff_scroll = self.diff_scroll.saturating_sub(10);
352                            }
353                            KeyCode::PageDown => {
354                                let max_scroll = self.state.events.len().saturating_sub(1);
355                                self.diff_scroll = (self.diff_scroll + 10).min(max_scroll);
356                            }
357                            KeyCode::Home => {
358                                self.diff_scroll = 0;
359                            }
360                            KeyCode::End => {
361                                self.diff_scroll = self.state.events.len().saturating_sub(1);
362                            }
363                            KeyCode::Left => {
364                                if self.file_list_scroll > 0 {
365                                    self.file_list_scroll -= 1;
366                                }
367                            }
368                            KeyCode::Right => {
369                                let max_scroll = self.state.watched_files.len().saturating_sub(1);
370                                if self.file_list_scroll < max_scroll {
371                                    self.file_list_scroll += 1;
372                                }
373                            }
374                            _ => {}
375                        }
376                    }
377                }
378            }
379
380            if self.should_quit {
381                break;
382            }
383        }
384
385        Ok(())
386    }
387
388    fn ui(&mut self, f: &mut Frame) {
389        match self.app_mode {
390            AppMode::Help => {
391                self.render_help(f);
392                return;
393            }
394            AppMode::Search => {
395                self.render_search_mode(f);
396                return;
397            }
398            AppMode::Normal => {
399                // Continue with normal rendering
400            }
401        }
402
403        let chunks = Layout::default()
404            .direction(Direction::Vertical)
405            .margin(1)
406            .constraints([
407                Constraint::Percentage(70), // Diff log
408                Constraint::Percentage(25), // File list
409                Constraint::Min(3),         // Status bar
410            ])
411            .split(f.area());
412
413        self.render_diff_log(f, chunks[0]);
414        self.render_file_list(f, chunks[1]);
415        self.render_status(f, chunks[2]);
416    }
417
418    fn render_diff_log(&mut self, f: &mut Frame, area: Rect) {
419        let events = &self.state.highlighted_events;
420        
421        let mut lines = Vec::new();
422        let visible_height = area.height as usize - 2; // Account for borders
423        
424        if events.is_empty() {
425            lines.push(Line::from(vec![
426                Span::styled("Watching for file changes...", Style::default().fg(Color::Gray))
427            ]));
428        } else {
429            // Ensure scroll position is within bounds
430            let max_scroll = events.len().saturating_sub(1);
431            if self.diff_scroll > max_scroll {
432                self.diff_scroll = max_scroll;
433            }
434            
435            let start_idx = self.diff_scroll.min(events.len());
436            let end_idx = (start_idx + visible_height).min(events.len());
437            
438            // Only slice if we have a valid range
439            if start_idx < events.len() && start_idx <= end_idx {
440                for event in &events[start_idx..end_idx] {
441                    lines.extend(self.format_highlighted_file_event(event));
442                    lines.push(Line::from(""));
443                }
444            }
445        }
446
447        let paragraph = Paragraph::new(lines)
448            .block(
449                Block::default()
450                    .borders(Borders::ALL)
451                    .border_style(Style::default().fg(Color::Rgb(80, 80, 80)))
452                    .title(" 📊 Changes (↑↓ to scroll, PgUp/PgDn, Home/End) ")
453                    .title_style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD))
454            )
455            .wrap(Wrap { trim: true })
456            .scroll((0, 0));
457
458        f.render_widget(paragraph, area);
459
460        // Render scrollbar
461        if events.len() > visible_height {
462            let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
463                .begin_symbol(Some("↑"))
464                .end_symbol(Some("↓"));
465            let safe_position = self.diff_scroll.min(events.len().saturating_sub(1));
466            let mut scrollbar_state = ScrollbarState::new(events.len())
467                .position(safe_position);
468            f.render_stateful_widget(
469                scrollbar,
470                area.inner(ratatui::layout::Margin { vertical: 1, horizontal: 1 }),
471                &mut scrollbar_state,
472            );
473        }
474    }
475
476    fn format_highlighted_file_event<'a>(&self, event: &'a HighlightedFileEvent) -> Vec<Line<'a>> {
477        let mut lines = Vec::new();
478        
479        let timestamp = event.timestamp
480            .duration_since(std::time::UNIX_EPOCH)
481            .unwrap_or_default()
482            .as_secs();
483        
484        let time_str = format!("{:02}:{:02}:{:02}", 
485            (timestamp % 86400) / 3600,
486            (timestamp % 3600) / 60,
487            timestamp % 60
488        );
489
490        let (event_symbol, event_type, color, bg_color) = match &event.kind {
491            FileEventKind::Created => ("●", "CREATED", Color::Green, Color::Rgb(0, 40, 0)),
492            FileEventKind::Modified => ("●", "MODIFIED", Color::Yellow, Color::Rgb(40, 40, 0)),
493            FileEventKind::Deleted => ("●", "DELETED", Color::Red, Color::Rgb(40, 0, 0)),
494            FileEventKind::Moved { .. } => ("●", "MOVED", Color::Blue, Color::Rgb(0, 0, 40)),
495        };
496
497        // Modern header with better visual separation
498        lines.push(Line::from(vec![
499            Span::styled(format!("[{}] ", time_str), Style::default().fg(Color::Rgb(100, 100, 100))),
500            Span::styled(format!(" {} {} ", event_symbol, event_type), 
501                Style::default().fg(color).bg(bg_color).add_modifier(Modifier::BOLD)),
502            Span::styled(format!(" {} ", event.path.display()), 
503                Style::default().fg(Color::White).add_modifier(Modifier::BOLD)),
504        ]));
505        
506        // Add a subtle separator line
507        lines.push(Line::from(Span::styled("|--", Style::default().fg(Color::Rgb(60, 60, 60)))));
508
509        // Use syntax-highlighted diff if available, otherwise fallback to basic coloring
510        if let Some(ref highlighted_diff) = event.highlighted_diff {
511            // Strip ANSI escape codes and render with basic styling
512            for line in highlighted_diff.lines().take(20) {
513                let prefix = "| ";
514                let clean_line = strip_ansi_codes(line);
515                lines.push(Line::from(vec![
516                    Span::styled(prefix, Style::default().fg(Color::Rgb(60, 60, 60))),
517                    Span::raw(clean_line)
518                ]));
519            }
520        } else if let Some(diff) = &event.diff {
521            // Improved diff coloring with better visual hierarchy
522            for line in diff.lines().take(20) {
523                let prefix = "| ";
524                let styled_line = if let Some(stripped) = line.strip_prefix('+') {
525                    vec![
526                        Span::styled(prefix, Style::default().fg(Color::Rgb(60, 60, 60))),
527                        Span::styled("+", Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)),
528                        Span::styled(stripped, Style::default().fg(Color::Rgb(150, 255, 150)).bg(Color::Rgb(0, 25, 0))),
529                    ]
530                } else if let Some(stripped) = line.strip_prefix('-') {
531                    vec![
532                        Span::styled(prefix, Style::default().fg(Color::Rgb(60, 60, 60))),
533                        Span::styled("-", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)),
534                        Span::styled(stripped, Style::default().fg(Color::Rgb(255, 150, 150)).bg(Color::Rgb(25, 0, 0))),
535                    ]
536                } else if line.starts_with("@@") {
537                    vec![
538                        Span::styled(prefix, Style::default().fg(Color::Rgb(60, 60, 60))),
539                        Span::styled(line, Style::default().fg(Color::Cyan).bg(Color::Rgb(0, 20, 30)).add_modifier(Modifier::BOLD)),
540                    ]
541                } else {
542                    vec![
543                        Span::styled(prefix, Style::default().fg(Color::Rgb(60, 60, 60))),
544                        Span::styled(line, Style::default().fg(Color::Rgb(200, 200, 200))),
545                    ]
546                };
547                lines.push(Line::from(styled_line));
548            }
549        }
550
551        // Use syntax-highlighted preview if available, otherwise fallback to basic preview
552        if let Some(ref highlighted_preview) = event.highlighted_preview {
553            lines.push(Line::from(vec![
554                Span::styled("|-- ", Style::default().fg(Color::Rgb(60, 60, 60))),
555                Span::styled("Preview", Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)),
556            ]));
557            for line in highlighted_preview.lines().take(5) {
558                let clean_line = strip_ansi_codes(line);
559                lines.push(Line::from(vec![
560                    Span::styled("|   ", Style::default().fg(Color::Rgb(60, 60, 60))),
561                    Span::raw(clean_line)
562                ]));
563            }
564        } else if let Some(preview) = &event.content_preview {
565            // Improved preview with better formatting
566            lines.push(Line::from(vec![
567                Span::styled("|-- ", Style::default().fg(Color::Rgb(60, 60, 60))),
568                Span::styled("Preview", Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)),
569            ]));
570            for line in preview.lines().take(5) {
571                lines.push(Line::from(vec![
572                    Span::styled("|   ", Style::default().fg(Color::Rgb(60, 60, 60))),
573                    Span::styled(line, Style::default().fg(Color::Rgb(180, 180, 180)))
574                ]));
575            }
576        }
577
578        // Add a closing separator
579        lines.push(Line::from(Span::styled("`--", Style::default().fg(Color::Rgb(60, 60, 60)))));
580        
581        lines
582    }
583
584    fn render_file_list(&mut self, f: &mut Frame, area: Rect) {
585        let files: Vec<ListItem> = self.state.watched_files
586            .iter()
587            .enumerate()
588            .map(|(i, path)| {
589                let style = if i % 2 == 0 {
590                    Style::default().fg(Color::Rgb(220, 220, 220))
591                } else {
592                    Style::default().fg(Color::Rgb(180, 180, 180)).bg(Color::Rgb(20, 20, 25))
593                };
594                
595                let filename = path.file_name()
596                    .and_then(|n| n.to_str())
597                    .map(|s| s.to_string())
598                    .unwrap_or_else(|| path.display().to_string());
599                let parent = path.parent()
600                    .map(|p| p.display().to_string())
601                    .unwrap_or_default();
602                
603                ListItem::new(Line::from(vec![
604                    Span::styled("📄 ", Style::default().fg(Color::Cyan)),
605                    Span::styled(filename, style.add_modifier(Modifier::BOLD)),
606                    if !parent.is_empty() {
607                        Span::styled(format!(" ({})", parent), Style::default().fg(Color::Rgb(120, 120, 120)))
608                    } else {
609                        Span::raw("")
610                    }
611                ]))
612            })
613            .collect();
614
615        let list = List::new(files)
616            .block(
617                Block::default()
618                    .borders(Borders::ALL)
619                    .border_style(Style::default().fg(Color::Rgb(80, 80, 80)))
620                    .title(format!(" 📁 Watched Files ({}) (←→ to scroll) ", self.state.watched_files.len()))
621                    .title_style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD))
622            )
623            .highlight_style(Style::default().bg(Color::Rgb(0, 50, 100)).add_modifier(Modifier::BOLD));
624
625        f.render_stateful_widget(list, area, &mut self.list_state);
626    }
627
628    fn render_status(&self, f: &mut Frame, area: Rect) {
629        // Create vim mode indicator
630        let vim_indicator = match self.vim_mode {
631            VimMode::Normal => {
632                let mut spans = vec![
633                    Span::styled(" VIM ", Style::default().fg(Color::Black).bg(Color::Yellow).add_modifier(Modifier::BOLD)),
634                ];
635                // Show key sequence if any
636                if !self.vim_key_sequence.keys.is_empty() {
637                    spans.push(Span::styled(
638                        format!(" {} ", self.vim_key_sequence.keys),
639                        Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)
640                    ));
641                }
642                spans
643            }
644            VimMode::Disabled => vec![
645                Span::styled(" ESC ", Style::default().fg(Color::White).bg(Color::Gray).add_modifier(Modifier::BOLD)),
646                Span::styled(" for vim mode", Style::default().fg(Color::Rgb(150, 150, 150))),
647            ],
648        };
649        
650        let mut first_line = vec![
651            Span::styled("⌨️  Press ", Style::default().fg(Color::Rgb(150, 150, 150))),
652            Span::styled(" q ", Style::default().fg(Color::White).bg(Color::Red).add_modifier(Modifier::BOLD)),
653            Span::styled(" to quit, ", Style::default().fg(Color::Rgb(150, 150, 150))),
654            Span::styled(" h ", Style::default().fg(Color::White).bg(Color::Green).add_modifier(Modifier::BOLD)),
655            Span::styled(" for help, ", Style::default().fg(Color::Rgb(150, 150, 150))),
656            Span::styled(" / ", Style::default().fg(Color::White).bg(Color::Cyan).add_modifier(Modifier::BOLD)),
657            Span::styled(" to search | ", Style::default().fg(Color::Rgb(150, 150, 150))),
658        ];
659        first_line.extend(vim_indicator);
660        
661        let status_text = vec![
662            Line::from(first_line),
663            Line::from(vec![
664                Span::styled("📊 Events: ", Style::default().fg(Color::Rgb(150, 150, 150))),
665                Span::styled(
666                    self.state.events.len().to_string(),
667                    Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)
668                ),
669                Span::styled(" | 📁 Files watched: ", Style::default().fg(Color::Rgb(150, 150, 150))),
670                Span::styled(
671                    self.state.watched_files.len().to_string(),
672                    Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)
673                ),
674                // Show navigation hints based on vim mode
675                match self.vim_mode {
676                    VimMode::Normal => Span::styled(" | hjkl:move gg:top G:bottom", Style::default().fg(Color::Rgb(120, 120, 120))),
677                    VimMode::Disabled => Span::styled(" | ↑↓←→:move", Style::default().fg(Color::Rgb(120, 120, 120))),
678                },
679            ]),
680        ];
681
682        let status = Paragraph::new(status_text)
683            .block(Block::default()
684                .borders(Borders::ALL)
685                .border_style(Style::default().fg(Color::Rgb(80, 80, 80)))
686                .title(" ℹ️  Status ")
687                .title_style(Style::default().fg(Color::Magenta).add_modifier(Modifier::BOLD)))
688            .alignment(Alignment::Center);
689
690        f.render_widget(status, area);
691    }
692
693    fn render_search_mode(&mut self, f: &mut Frame) {
694        // Ensure cursor is visible in search mode
695        // This is handled by ratatui when we call set_cursor_position
696        let chunks = Layout::default()
697            .direction(Direction::Vertical)
698            .margin(1)
699            .constraints([
700                Constraint::Length(3),      // Search input
701                Constraint::Min(10),        // File list + preview
702            ])
703            .split(f.area());
704
705        // Render search input
706        self.render_search_input(f, chunks[0]);
707        
708        // Split the remaining area for file list and preview
709        let content_chunks = Layout::default()
710            .direction(Direction::Horizontal)
711            .constraints([
712                Constraint::Percentage(40), // File list
713                Constraint::Percentage(60), // Preview
714            ])
715            .split(chunks[1]);
716
717        self.render_search_results(f, content_chunks[0]);
718        self.render_file_preview(f, content_chunks[1]);
719    }
720
721    fn render_search_input(&self, f: &mut Frame, area: Rect) {
722        // Create input text with visual cursor indicator
723        let prefix = "🔍 ";
724        let input_text = format!("{}{}█", prefix, self.search_state.query);
725        
726        let input = Paragraph::new(input_text)
727            .block(
728                Block::default()
729                    .borders(Borders::ALL)
730                    .border_style(Style::default().fg(Color::Yellow))
731                    .title(" Search Files ")
732                    .title_style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD))
733            );
734        f.render_widget(input, area);
735        
736        // Position the terminal cursor at the end (after the visual cursor)
737        // This helps with terminal cursor visibility
738        let cursor_x = area.x + 1 + prefix.chars().count() as u16 + self.search_state.query.len() as u16 + 1;
739        let cursor_y = area.y + 1;
740        
741        // Ensure cursor is within bounds
742        if cursor_x < area.x + area.width - 1 {
743            f.set_cursor_position((cursor_x, cursor_y));
744        }
745    }
746
747    fn render_search_results(&mut self, f: &mut Frame, area: Rect) {
748        // Update filtered files based on current query
749        self.search_state.update_filtered_files(&self.state.watched_files, &self.state.highlighted_events);
750        
751        let items: Vec<ListItem> = self.search_state.filtered_files
752            .iter()
753            .enumerate()
754            .map(|(i, path)| {
755                let style = if i == self.search_state.selected_index {
756                    Style::default().bg(Color::Blue).fg(Color::White).add_modifier(Modifier::BOLD)
757                } else {
758                    Style::default().fg(Color::White)
759                };
760                
761                let filename = path.file_name()
762                    .and_then(|n| n.to_str())
763                    .unwrap_or("")
764                    .to_string();
765                let parent = path.parent()
766                    .map(|p| p.display().to_string())
767                    .unwrap_or_default();
768
769                // Check if file has recent changes
770                let has_changes = self.state.highlighted_events.iter().any(|e| e.path == *path);
771                let change_indicator = if has_changes { "🟡 " } else { "📄 " };
772                
773                ListItem::new(Line::from(vec![
774                    Span::styled(change_indicator, Style::default().fg(Color::Cyan)),
775                    Span::styled(filename, style.add_modifier(Modifier::BOLD)),
776                    if !parent.is_empty() {
777                        Span::styled(format!(" ({})", parent), Style::default().fg(Color::Rgb(120, 120, 120)))
778                    } else {
779                        Span::raw("")
780                    }
781                ]))
782            })
783            .collect();
784
785        let list = List::new(items)
786            .block(
787                Block::default()
788                    .borders(Borders::ALL)
789                    .border_style(Style::default().fg(Color::Cyan))
790                    .title(format!(" Files ({}/{}) ", 
791                        self.search_state.filtered_files.len(),
792                        self.state.watched_files.len()
793                    ))
794                    .title_style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD))
795            );
796
797        f.render_widget(list, area);
798    }
799
800    fn render_file_preview(&mut self, f: &mut Frame, area: Rect) {
801        let selected_file = self.search_state.get_selected_file();
802        
803        if let Some(file_path) = selected_file {
804            // Try to read file content
805            match std::fs::read_to_string(file_path) {
806                Ok(content) => {
807                    let language = crate::highlight::SyntaxHighlighter::default()
808                        .get_language_from_path(file_path)
809                        .unwrap_or_else(|| "Plain Text".to_string());
810                    
811                    // Check if file has recent changes for diff preview
812                    let recent_event = self.state.highlighted_events
813                        .iter()
814                        .find(|e| e.path == *file_path);
815                    
816                    if let Some(event) = recent_event {
817                        self.render_diff_preview(f, area, file_path, &content, event);
818                    } else {
819                        self.render_file_content_preview(f, area, file_path, &content, &language);
820                    }
821                }
822                Err(_) => {
823                    let error_text = vec![
824                        Line::from(Span::styled("Cannot read file", Style::default().fg(Color::Red))),
825                        Line::from(Span::styled(file_path.display().to_string(), Style::default().fg(Color::Gray))),
826                    ];
827                    
828                    let paragraph = Paragraph::new(error_text)
829                        .block(
830                            Block::default()
831                                .borders(Borders::ALL)
832                                .border_style(Style::default().fg(Color::Red))
833                                .title(" Preview ")
834                                .title_style(Style::default().fg(Color::Red))
835                        );
836                    f.render_widget(paragraph, area);
837                }
838            }
839        } else {
840            let placeholder = Paragraph::new("Select a file to preview")
841                .style(Style::default().fg(Color::Gray))
842                .alignment(Alignment::Center)
843                .block(
844                    Block::default()
845                        .borders(Borders::ALL)
846                        .border_style(Style::default().fg(Color::Gray))
847                        .title(" Preview ")
848                );
849            f.render_widget(placeholder, area);
850        }
851    }
852
853    fn render_file_content_preview(&self, f: &mut Frame, area: Rect, file_path: &std::path::Path, content: &str, language: &str) {
854        let visible_height = area.height as usize - 2; // Account for borders
855        let lines: Vec<&str> = content.lines().collect();
856        
857        let start_line = self.search_state.preview_scroll;
858        let end_line = (start_line + visible_height).min(lines.len());
859        
860        // Create syntax highlighter and highlight entire content for better state management
861        let highlighter = crate::highlight::SyntaxHighlighter::default();
862        let highlighted_content = highlighter.highlight_code(content, language);
863        
864        let visible_lines: Vec<Line> = (start_line..end_line)
865            .map(|absolute_line_idx| {
866                let line_num = absolute_line_idx + 1;
867                let line_num_span = Span::styled(
868                    format!("{:4} │ ", line_num), 
869                    Style::default().fg(Color::Rgb(100, 100, 100))
870                );
871                
872                let mut spans = vec![line_num_span];
873                
874                // Get highlighted spans for this line from the pre-highlighted content
875                if let Some(line_spans) = highlighted_content.get(absolute_line_idx) {
876                    for (style, text) in line_spans {
877                        spans.push(Span::styled(text.clone(), style.clone()));
878                    }
879                } else if let Some(plain_line) = lines.get(absolute_line_idx) {
880                    // Fallback to plain text if highlighting failed
881                    spans.push(Span::raw(*plain_line));
882                }
883                
884                Line::from(spans)
885            })
886            .collect();
887
888        let paragraph = Paragraph::new(visible_lines)
889            .block(
890                Block::default()
891                    .borders(Borders::ALL)
892                    .border_style(Style::default().fg(Color::Green))
893                    .title(format!(" {} [{}] (↑↓ PgUp/PgDn ←→ to scroll) ", 
894                        file_path.file_name().and_then(|n| n.to_str()).unwrap_or(""),
895                        language
896                    ))
897                    .title_style(Style::default().fg(Color::Green).add_modifier(Modifier::BOLD))
898            )
899            .wrap(Wrap { trim: false });
900
901        f.render_widget(paragraph, area);
902    }
903
904    fn render_diff_preview(&self, f: &mut Frame, area: Rect, file_path: &std::path::Path, _content: &str, event: &crate::core::HighlightedFileEvent) {
905        let mut lines = Vec::new();
906        
907        // Show file change information
908        let (event_symbol, event_type, color) = match &event.kind {
909            crate::core::FileEventKind::Created => ("●", "CREATED", Color::Green),
910            crate::core::FileEventKind::Modified => ("●", "MODIFIED", Color::Yellow),
911            crate::core::FileEventKind::Deleted => ("●", "DELETED", Color::Red),
912            crate::core::FileEventKind::Moved { .. } => ("●", "MOVED", Color::Blue),
913        };
914
915        let timestamp = event.timestamp
916            .duration_since(std::time::UNIX_EPOCH)
917            .unwrap_or_default()
918            .as_secs();
919        let time_str = format!("{:02}:{:02}:{:02}", 
920            (timestamp % 86400) / 3600,
921            (timestamp % 3600) / 60,
922            timestamp % 60
923        );
924
925        lines.push(Line::from(vec![
926            Span::styled(format!("[{}] ", time_str), Style::default().fg(Color::Rgb(100, 100, 100))),
927            Span::styled(format!("{} {} ", event_symbol, event_type), Style::default().fg(color).add_modifier(Modifier::BOLD)),
928        ]));
929        lines.push(Line::from(""));
930
931        // Show diff if available
932        if let Some(diff) = &event.diff {
933            for (i, line) in diff.lines().enumerate() {
934                if i >= (area.height as usize - 6) { // Leave space for headers
935                    break;
936                }
937                
938                let styled_line = if let Some(stripped) = line.strip_prefix('+') {
939                    Line::from(vec![
940                        Span::styled("+", Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)),
941                        Span::styled(stripped, Style::default().fg(Color::Rgb(150, 255, 150))),
942                    ])
943                } else if let Some(stripped) = line.strip_prefix('-') {
944                    Line::from(vec![
945                        Span::styled("-", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)),
946                        Span::styled(stripped, Style::default().fg(Color::Rgb(255, 150, 150))),
947                    ])
948                } else if line.starts_with("@@") {
949                    Line::from(Span::styled(line, Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)))
950                } else {
951                    Line::from(Span::styled(line, Style::default().fg(Color::Rgb(200, 200, 200))))
952                };
953                lines.push(styled_line);
954            }
955        }
956
957        let paragraph = Paragraph::new(lines)
958            .block(
959                Block::default()
960                    .borders(Borders::ALL)
961                    .border_style(Style::default().fg(Color::Yellow))
962                    .title(format!(" 🔄 {} ", 
963                        file_path.file_name().and_then(|n| n.to_str()).unwrap_or("")
964                    ))
965                    .title_style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD))
966            )
967            .wrap(Wrap { trim: false });
968
969        f.render_widget(paragraph, area);
970    }
971
972    fn render_help(&self, f: &mut Frame) {
973        let popup_area = self.centered_rect(80, 60, f.area());
974
975        let help_text = vec![
976            Line::from(vec![
977                Span::styled("WatchDiff - File Watching Tool", Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD))
978            ]),
979            Line::from(""),
980            Line::from("Keyboard Shortcuts:"),
981            Line::from(""),
982            Line::from(vec![
983                Span::styled("  q, Esc     ", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)),
984                Span::styled("- Quit the application", Style::default())
985            ]),
986            Line::from(vec![
987                Span::styled("  h, F1      ", Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)),
988                Span::styled("- Show/hide this help", Style::default())
989            ]),
990            Line::from(vec![
991                Span::styled("  ↑, k       ", Style::default().fg(Color::Blue).add_modifier(Modifier::BOLD)),
992                Span::styled("- Scroll diff log up", Style::default())
993            ]),
994            Line::from(vec![
995                Span::styled("  ↓, j       ", Style::default().fg(Color::Blue).add_modifier(Modifier::BOLD)),
996                Span::styled("- Scroll diff log down", Style::default())
997            ]),
998            Line::from(vec![
999                Span::styled("  PgUp       ", Style::default().fg(Color::Blue).add_modifier(Modifier::BOLD)),
1000                Span::styled("- Scroll diff log up (fast)", Style::default())
1001            ]),
1002            Line::from(vec![
1003                Span::styled("  PgDn       ", Style::default().fg(Color::Blue).add_modifier(Modifier::BOLD)),
1004                Span::styled("- Scroll diff log down (fast)", Style::default())
1005            ]),
1006            Line::from(vec![
1007                Span::styled("  Home       ", Style::default().fg(Color::Blue).add_modifier(Modifier::BOLD)),
1008                Span::styled("- Go to top of diff log", Style::default())
1009            ]),
1010            Line::from(vec![
1011                Span::styled("  End        ", Style::default().fg(Color::Blue).add_modifier(Modifier::BOLD)),
1012                Span::styled("- Go to bottom of diff log", Style::default())
1013            ]),
1014            Line::from(vec![
1015                Span::styled("  ←, →       ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
1016                Span::styled("- Scroll file list", Style::default())
1017            ]),
1018            Line::from(""),
1019            Line::from(vec![
1020                Span::styled("Search Mode", Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)),
1021                Span::styled(" (Press / or Ctrl+P):", Style::default())
1022            ]),
1023            Line::from(""),
1024            Line::from(vec![
1025                Span::styled("  /          ", Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)),
1026                Span::styled("- Enter search mode", Style::default())
1027            ]),
1028            Line::from(vec![
1029                Span::styled("  Ctrl+P     ", Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)),
1030                Span::styled("- Fuzzy file search (like fzf)", Style::default())
1031            ]),
1032            Line::from(vec![
1033                Span::styled("  ↑/↓, j/k   ", Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)),
1034                Span::styled("- Navigate search results", Style::default())
1035            ]),
1036            Line::from(vec![
1037                Span::styled("  Enter      ", Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)),
1038                Span::styled("- Jump to file in diff view", Style::default())
1039            ]),
1040            Line::from(vec![
1041                Span::styled("  Ctrl+U/D   ", Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)),
1042                Span::styled("- Scroll preview up/down", Style::default())
1043            ]),
1044            Line::from(vec![
1045                Span::styled("  PgUp/PgDn  ", Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)),
1046                Span::styled("- Page preview up/down", Style::default())
1047            ]),
1048            Line::from(vec![
1049                Span::styled("  ←→         ", Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)),
1050                Span::styled("- Fine scroll preview", Style::default())
1051            ]),
1052            Line::from(vec![
1053                Span::styled("  Esc        ", Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)),
1054                Span::styled("- Exit search mode", Style::default())
1055            ]),
1056            Line::from(""),
1057            Line::from(vec![
1058                Span::styled("Vim Mode", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
1059                Span::styled(" (Press Esc to toggle):", Style::default())
1060            ]),
1061            Line::from(""),
1062            Line::from(vec![
1063                Span::styled("  h, j, k, l  ", Style::default().fg(Color::Magenta).add_modifier(Modifier::BOLD)),
1064                Span::styled("- Move left, down, up, right", Style::default())
1065            ]),
1066            Line::from(vec![
1067                Span::styled("  gg         ", Style::default().fg(Color::Magenta).add_modifier(Modifier::BOLD)),
1068                Span::styled("- Go to top", Style::default())
1069            ]),
1070            Line::from(vec![
1071                Span::styled("  G          ", Style::default().fg(Color::Magenta).add_modifier(Modifier::BOLD)),
1072                Span::styled("- Go to bottom", Style::default())
1073            ]),
1074            Line::from(vec![
1075                Span::styled("  w, b       ", Style::default().fg(Color::Magenta).add_modifier(Modifier::BOLD)),
1076                Span::styled("- Jump forward/backward (5 lines)", Style::default())
1077            ]),
1078            Line::from(vec![
1079                Span::styled("  0, $       ", Style::default().fg(Color::Magenta).add_modifier(Modifier::BOLD)),
1080                Span::styled("- Go to start/end of line", Style::default())
1081            ]),
1082            Line::from(vec![
1083                Span::styled("  Ctrl+d/u   ", Style::default().fg(Color::Magenta).add_modifier(Modifier::BOLD)),
1084                Span::styled("- Half page down/up", Style::default())
1085            ]),
1086            Line::from(vec![
1087                Span::styled("  Ctrl+f/b   ", Style::default().fg(Color::Magenta).add_modifier(Modifier::BOLD)),
1088                Span::styled("- Full page down/up", Style::default())
1089            ]),
1090            Line::from(vec![
1091                Span::styled("  i          ", Style::default().fg(Color::Magenta).add_modifier(Modifier::BOLD)),
1092                Span::styled("- Exit vim mode", Style::default())
1093            ]),
1094            Line::from(""),
1095            Line::from("Features:"),
1096            Line::from(""),
1097            Line::from("• Real-time file change monitoring"),
1098            Line::from("• Respects .gitignore patterns"),
1099            Line::from("• Shows diffs for text file changes"),
1100            Line::from("• Scrollable diff log and file list"),
1101            Line::from("• High performance with async processing"),
1102        ];
1103
1104        let paragraph = Paragraph::new(help_text)
1105            .block(
1106                Block::default()
1107                    .borders(Borders::ALL)
1108                    .title(" Help ")
1109                    .title_style(Style::default().fg(Color::Cyan))
1110            )
1111            .wrap(Wrap { trim: true });
1112
1113        f.render_widget(Clear, popup_area);
1114        f.render_widget(paragraph, popup_area);
1115    }
1116
1117
1118    fn centered_rect(&self, percent_x: u16, percent_y: u16, r: Rect) -> Rect {
1119        let popup_layout = Layout::default()
1120            .direction(Direction::Vertical)
1121            .constraints([
1122                Constraint::Percentage((100 - percent_y) / 2),
1123                Constraint::Percentage(percent_y),
1124                Constraint::Percentage((100 - percent_y) / 2),
1125            ])
1126            .split(r);
1127
1128        Layout::default()
1129            .direction(Direction::Horizontal)
1130            .constraints([
1131                Constraint::Percentage((100 - percent_x) / 2),
1132                Constraint::Percentage(percent_x),
1133                Constraint::Percentage((100 - percent_x) / 2),
1134            ])
1135            .split(popup_layout[1])[1]
1136    }
1137    
1138    /// Jump to a specific file in the diff view and scroll to show it
1139    fn jump_to_file_in_diff_view(&mut self, target_file: &PathBuf) {
1140        // Find the most recent event for this file in the diff log
1141        if let Some(position) = self.state.highlighted_events
1142            .iter()
1143            .position(|event| event.path == *target_file) 
1144        {
1145            // Set the diff scroll to show this file's event at the top of the view
1146            self.diff_scroll = position;
1147            
1148            // Also clear any file list scroll to return to default view
1149            self.file_list_scroll = 0;
1150        } else {
1151            // If file not found in recent events, it means there are no recent changes
1152            // for this file. Scroll to top to show the most recent activity.
1153            self.diff_scroll = 0;
1154            self.file_list_scroll = 0;
1155        }
1156    }
1157
1158    /// Handle search mode key input
1159    fn handle_search_keys(&mut self, key: &crossterm::event::KeyEvent) -> bool {
1160        use crossterm::event::{KeyCode, KeyModifiers};
1161        
1162        match key.code {
1163            KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => {
1164                self.search_state.add_char(c);
1165                true
1166            }
1167            KeyCode::Backspace => {
1168                self.search_state.remove_char();
1169                true
1170            }
1171            KeyCode::Up | KeyCode::Char('k') => {
1172                self.search_state.move_up();
1173                true
1174            }
1175            KeyCode::Down | KeyCode::Char('j') => {
1176                self.search_state.move_down();
1177                true
1178            }
1179            KeyCode::Enter => {
1180                // Jump to selected file in diff view
1181                if let Some(selected_file) = self.search_state.get_selected_file().cloned() {
1182                    self.jump_to_file_in_diff_view(&selected_file);
1183                    self.app_mode = AppMode::Normal;
1184                    self.search_state.clear();
1185                }
1186                true
1187            }
1188            KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => {
1189                // Page up in preview
1190                self.search_state.preview_scroll = self.search_state.preview_scroll.saturating_sub(10);
1191                true
1192            }
1193            KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => {
1194                // Page down in preview
1195                self.search_state.preview_scroll += 10;
1196                true
1197            }
1198            KeyCode::PageUp => {
1199                // Page up in preview
1200                self.search_state.preview_scroll = self.search_state.preview_scroll.saturating_sub(10);
1201                true
1202            }
1203            KeyCode::PageDown => {
1204                // Page down in preview
1205                self.search_state.preview_scroll += 10;
1206                true
1207            }
1208            KeyCode::Left => {
1209                // Scroll left in preview (horizontal scroll)
1210                self.search_state.preview_scroll = self.search_state.preview_scroll.saturating_sub(1);
1211                true
1212            }
1213            KeyCode::Right => {
1214                // Scroll right/down in preview
1215                self.search_state.preview_scroll += 1;
1216                true
1217            }
1218            _ => false, // Let other keys be handled normally
1219        }
1220    }
1221
1222    /// Handle vim mode key sequences and navigation
1223    fn handle_vim_keys(&mut self, key: &crossterm::event::KeyEvent) -> bool {
1224        if self.vim_mode == VimMode::Disabled {
1225            return false;
1226        }
1227        
1228        use crossterm::event::{KeyCode, KeyModifiers};
1229        
1230        match key.code {
1231            // Handle Ctrl+key combinations first (before the general char pattern)
1232            KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => {
1233                self.vim_half_page_down();
1234                return true;
1235            }
1236            KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => {
1237                self.vim_half_page_up();
1238                return true;
1239            }
1240            KeyCode::Char('f') if key.modifiers.contains(KeyModifiers::CONTROL) => {
1241                self.vim_page_down();
1242                return true;
1243            }
1244            KeyCode::Char('b') if key.modifiers.contains(KeyModifiers::CONTROL) => {
1245                self.vim_page_up();
1246                return true;
1247            }
1248            KeyCode::Char(c) => {
1249                // Handle regular character keys
1250                match c {
1251                    // Disable vim mode
1252                    'i' => {
1253                        self.vim_mode = VimMode::Disabled;
1254                        self.vim_key_sequence.clear();
1255                        return true;
1256                    }
1257                    // Basic vim movements
1258                    'h' => {
1259                        self.vim_move_left();
1260                        return true;
1261                    }
1262                    'j' => {
1263                        self.vim_move_down();
1264                        return true;
1265                    }
1266                    'k' => {
1267                        self.vim_move_up();
1268                        return true;
1269                    }
1270                    'l' => {
1271                        self.vim_move_right();
1272                        return true;
1273                    }
1274                    // Word movements (adapted for diff context)
1275                    'w' => {
1276                        self.vim_word_forward();
1277                        return true;
1278                    }
1279                    'b' => {
1280                        self.vim_word_backward();
1281                        return true;
1282                    }
1283                    // Line movements
1284                    '0' => {
1285                        self.vim_line_start();
1286                        return true;
1287                    }
1288                    '$' => {
1289                        self.vim_line_end();
1290                        return true;
1291                    }
1292                    // Handle multi-character sequences
1293                    'g' | 'G' => {
1294                        self.vim_key_sequence.push_key(c);
1295                        self.handle_vim_sequence();
1296                        return true;
1297                    }
1298                    // Always let search key pass through to main handler
1299                    '/' => {
1300                        self.vim_key_sequence.clear();
1301                        return false;
1302                    }
1303                    _ => {
1304                        // Clear sequence for unrecognized keys
1305                        self.vim_key_sequence.clear();
1306                        return false;
1307                    }
1308                }
1309            }
1310            _ => {
1311                // Clear sequence for unrecognized keys
1312                self.vim_key_sequence.clear();
1313                return false;
1314            }
1315        }
1316    }
1317    
1318    /// Handle vim multi-character sequences like 'gg' and 'G'
1319    fn handle_vim_sequence(&mut self) {
1320        if self.vim_key_sequence.matches("gg") {
1321            self.vim_goto_top();
1322            self.vim_key_sequence.clear();
1323        } else if self.vim_key_sequence.matches("G") {
1324            self.vim_goto_bottom();
1325            self.vim_key_sequence.clear();
1326        }
1327        // Clear if we have an incomplete sequence that's too old
1328        else if let Some(last_time) = self.vim_key_sequence.last_key_time {
1329            if last_time.elapsed().as_millis() > 500 {
1330                self.vim_key_sequence.clear();
1331            }
1332        }
1333    }
1334    
1335    /// Vim movement implementations
1336    fn vim_move_up(&mut self) {
1337        if self.diff_scroll > 0 {
1338            self.diff_scroll -= 1;
1339        }
1340    }
1341    
1342    fn vim_move_down(&mut self) {
1343        let max_scroll = self.state.events.len().saturating_sub(1);
1344        if self.diff_scroll < max_scroll {
1345            self.diff_scroll += 1;
1346        }
1347    }
1348    
1349    fn vim_move_left(&mut self) {
1350        if self.file_list_scroll > 0 {
1351            self.file_list_scroll -= 1;
1352        }
1353    }
1354    
1355    fn vim_move_right(&mut self) {
1356        let max_scroll = self.state.watched_files.len().saturating_sub(1);
1357        if self.file_list_scroll < max_scroll {
1358            self.file_list_scroll += 1;
1359        }
1360    }
1361    
1362    fn vim_word_forward(&mut self) {
1363        // Move down by 5 lines (word-like movement in diff context)
1364        let max_scroll = self.state.events.len().saturating_sub(1);
1365        self.diff_scroll = (self.diff_scroll + 5).min(max_scroll);
1366    }
1367    
1368    fn vim_word_backward(&mut self) {
1369        // Move up by 5 lines (word-like movement in diff context)
1370        self.diff_scroll = self.diff_scroll.saturating_sub(5);
1371    }
1372    
1373    fn vim_line_start(&mut self) {
1374        // In diff view context, move to leftmost position
1375        self.file_list_scroll = 0;
1376    }
1377    
1378    fn vim_line_end(&mut self) {
1379        // In diff view context, move to rightmost position
1380        let max_scroll = self.state.watched_files.len().saturating_sub(1);
1381        self.file_list_scroll = max_scroll;
1382    }
1383    
1384    fn vim_goto_top(&mut self) {
1385        self.diff_scroll = 0;
1386    }
1387    
1388    fn vim_goto_bottom(&mut self) {
1389        self.diff_scroll = self.state.events.len().saturating_sub(1);
1390    }
1391    
1392    fn vim_half_page_down(&mut self) {
1393        let max_scroll = self.state.events.len().saturating_sub(1);
1394        self.diff_scroll = (self.diff_scroll + 10).min(max_scroll);
1395    }
1396    
1397    fn vim_half_page_up(&mut self) {
1398        self.diff_scroll = self.diff_scroll.saturating_sub(10);
1399    }
1400    
1401    fn vim_page_down(&mut self) {
1402        let max_scroll = self.state.events.len().saturating_sub(1);
1403        self.diff_scroll = (self.diff_scroll + 20).min(max_scroll);
1404    }
1405    
1406    fn vim_page_up(&mut self) {
1407        self.diff_scroll = self.diff_scroll.saturating_sub(20);
1408    }
1409}
1410
1411pub fn setup_terminal() -> Result<Terminal<CrosstermBackend<io::Stdout>>, io::Error> {
1412    enable_raw_mode()?;
1413    let mut stdout = io::stdout();
1414    execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
1415    let backend = CrosstermBackend::new(stdout);
1416    Terminal::new(backend)
1417}
1418
1419pub fn restore_terminal(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> Result<(), io::Error> {
1420    disable_raw_mode()?;
1421    execute!(
1422        terminal.backend_mut(),
1423        LeaveAlternateScreen,
1424        DisableMouseCapture
1425    )?;
1426    terminal.show_cursor()
1427}