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#[derive(Debug, Clone, PartialEq)]
25pub enum VimMode {
26 Normal,
27 Disabled,
28}
29
30#[derive(Debug, Clone, PartialEq)]
32pub enum AppMode {
33 Normal,
34 Search,
35 Help,
36}
37
38#[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 self.filtered_files = all_files.iter().cloned().collect();
52 } else {
53 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 scored_files.sort_by(|a, b| {
68 let score_cmp = b.1.cmp(&a.1);
69 if score_cmp == std::cmp::Ordering::Equal {
70 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 self.filtered_files = scored_files.into_iter().map(|(path, _)| path).collect();
81 }
82
83 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 let mut score: i32 = 0;
99 let mut query_chars = query.chars().peekable();
100 let mut consecutive_bonus = 0;
101
102 if filename.contains(&query) {
104 score += 100;
105 }
106
107 if path_str.contains(&query) {
109 score += 50;
110 }
111
112 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; query_chars.next();
125 } else {
126 consecutive_bonus = 0;
127 }
128 path_idx += 1;
129 }
130
131 score = score.saturating_sub(path_str.len() as i32 / 10);
133
134 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#[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 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 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
209fn 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 chars.next(); 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, 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 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(_) => {} }
282
283 if event::poll(Duration::from_millis(50))? {
285 if let Event::Key(key) = event::read()? {
286 if key.kind == KeyEventKind::Press {
287 if self.app_mode == AppMode::Search {
289 if self.handle_search_keys(&key) {
290 continue; }
292 }
293
294 if self.handle_vim_keys(&key) {
296 continue; }
298
299 match key.code {
300 KeyCode::Char('q') | KeyCode::Esc => {
301 match self.app_mode {
302 AppMode::Search => {
303 self.app_mode = AppMode::Normal;
305 self.search_state.clear();
306 }
307 AppMode::Help => {
308 self.app_mode = AppMode::Normal;
310 }
311 AppMode::Normal => {
312 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 self.app_mode = AppMode::Search;
332 self.search_state.clear();
333 },
334 KeyCode::Char('p') if key.modifiers.contains(crossterm::event::KeyModifiers::CONTROL) => {
335 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 }
401 }
402
403 let chunks = Layout::default()
404 .direction(Direction::Vertical)
405 .margin(1)
406 .constraints([
407 Constraint::Percentage(70), Constraint::Percentage(25), Constraint::Min(3), ])
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; 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 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 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 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 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 lines.push(Line::from(Span::styled("|--", Style::default().fg(Color::Rgb(60, 60, 60)))));
508
509 if let Some(ref highlighted_diff) = event.highlighted_diff {
511 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 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 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 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 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 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 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 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 let chunks = Layout::default()
697 .direction(Direction::Vertical)
698 .margin(1)
699 .constraints([
700 Constraint::Length(3), Constraint::Min(10), ])
703 .split(f.area());
704
705 self.render_search_input(f, chunks[0]);
707
708 let content_chunks = Layout::default()
710 .direction(Direction::Horizontal)
711 .constraints([
712 Constraint::Percentage(40), Constraint::Percentage(60), ])
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 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 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 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 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 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 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 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; 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 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 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 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 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 if let Some(diff) = &event.diff {
933 for (i, line) in diff.lines().enumerate() {
934 if i >= (area.height as usize - 6) { 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 fn jump_to_file_in_diff_view(&mut self, target_file: &PathBuf) {
1140 if let Some(position) = self.state.highlighted_events
1142 .iter()
1143 .position(|event| event.path == *target_file)
1144 {
1145 self.diff_scroll = position;
1147
1148 self.file_list_scroll = 0;
1150 } else {
1151 self.diff_scroll = 0;
1154 self.file_list_scroll = 0;
1155 }
1156 }
1157
1158 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 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 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 self.search_state.preview_scroll += 10;
1196 true
1197 }
1198 KeyCode::PageUp => {
1199 self.search_state.preview_scroll = self.search_state.preview_scroll.saturating_sub(10);
1201 true
1202 }
1203 KeyCode::PageDown => {
1204 self.search_state.preview_scroll += 10;
1206 true
1207 }
1208 KeyCode::Left => {
1209 self.search_state.preview_scroll = self.search_state.preview_scroll.saturating_sub(1);
1211 true
1212 }
1213 KeyCode::Right => {
1214 self.search_state.preview_scroll += 1;
1216 true
1217 }
1218 _ => false, }
1220 }
1221
1222 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 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 match c {
1251 'i' => {
1253 self.vim_mode = VimMode::Disabled;
1254 self.vim_key_sequence.clear();
1255 return true;
1256 }
1257 '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 'w' => {
1276 self.vim_word_forward();
1277 return true;
1278 }
1279 'b' => {
1280 self.vim_word_backward();
1281 return true;
1282 }
1283 '0' => {
1285 self.vim_line_start();
1286 return true;
1287 }
1288 '$' => {
1289 self.vim_line_end();
1290 return true;
1291 }
1292 'g' | 'G' => {
1294 self.vim_key_sequence.push_key(c);
1295 self.handle_vim_sequence();
1296 return true;
1297 }
1298 '/' => {
1300 self.vim_key_sequence.clear();
1301 return false;
1302 }
1303 _ => {
1304 self.vim_key_sequence.clear();
1306 return false;
1307 }
1308 }
1309 }
1310 _ => {
1311 self.vim_key_sequence.clear();
1313 return false;
1314 }
1315 }
1316 }
1317
1318 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 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 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 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 self.diff_scroll = self.diff_scroll.saturating_sub(5);
1371 }
1372
1373 fn vim_line_start(&mut self) {
1374 self.file_list_scroll = 0;
1376 }
1377
1378 fn vim_line_end(&mut self) {
1379 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}