code_mesh_tui/
file_viewer.rs

1use ratatui::{
2    layout::Rect,
3    widgets::{Block, Borders, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState},
4    style::Style,
5    text::{Line, Span},
6};
7use anyhow::Result;
8use crossterm::event::{KeyCode, KeyEvent};
9use std::path::Path;
10
11use crate::{
12    config::{FileViewerConfig, DiffStyle},
13    renderer::Renderer,
14    theme::Theme,
15    utils::syntax_highlighter::SyntaxHighlighter,
16};
17
18/// File viewer component for displaying and editing files
19pub struct FileViewer {
20    theme: Box<dyn Theme + Send + Sync>,
21    config: FileViewerConfig,
22    current_file: Option<FileContent>,
23    scroll_offset: usize,
24    syntax_highlighter: SyntaxHighlighter,
25    diff_style: DiffStyle,
26    is_visible: bool,
27}
28
29/// File content representation
30#[derive(Debug, Clone)]
31pub struct FileContent {
32    pub path: String,
33    pub content: String,
34    pub lines: Vec<String>,
35    pub language: Option<String>,
36    pub is_diff: bool,
37    pub file_type: FileType,
38}
39
40/// File type enumeration
41#[derive(Debug, Clone, PartialEq)]
42pub enum FileType {
43    Text,
44    Binary,
45    Image,
46    Archive,
47    Unknown,
48}
49
50impl FileViewer {
51    /// Create a new file viewer
52    pub fn new(config: &FileViewerConfig, theme: &dyn Theme) -> Self {
53        Self {
54            theme: Box::new(crate::theme::DefaultTheme), // Temporary
55            config: config.clone(),
56            current_file: None,
57            scroll_offset: 0,
58            syntax_highlighter: SyntaxHighlighter::new(),
59            diff_style: config.default_style,
60            is_visible: false,
61        }
62    }
63    
64    /// Open a file for viewing
65    pub async fn open_file(&mut self, path: &str) -> Result<()> {
66        let file_content = self.load_file(path).await?;
67        self.current_file = Some(file_content);
68        self.scroll_offset = 0;
69        self.is_visible = true;
70        Ok(())
71    }
72    
73    /// Close the current file
74    pub fn close_file(&mut self) {
75        self.current_file = None;
76        self.is_visible = false;
77        self.scroll_offset = 0;
78    }
79    
80    /// Check if the file viewer is visible
81    pub fn is_visible(&self) -> bool {
82        self.is_visible
83    }
84    
85    /// Get the current file path
86    pub fn current_file_path(&self) -> Option<&str> {
87        self.current_file.as_ref().map(|f| f.path.as_str())
88    }
89    
90    /// Toggle diff style between unified and side-by-side
91    pub fn toggle_diff_style(&mut self) {
92        self.diff_style = match self.diff_style {
93            DiffStyle::Unified => DiffStyle::SideBySide,
94            DiffStyle::SideBySide => DiffStyle::Unified,
95        };
96    }
97    
98    /// Handle key events
99    pub async fn handle_key_event(&mut self, key: KeyEvent) -> Result<()> {
100        match key.code {
101            KeyCode::Esc => {
102                self.close_file();
103            }
104            KeyCode::Char('d') if key.modifiers.contains(crossterm::event::KeyModifiers::CONTROL) => {
105                self.toggle_diff_style();
106            }
107            _ => {}
108        }
109        Ok(())
110    }
111    
112    /// Scroll up
113    pub fn scroll_up(&mut self) {
114        if self.scroll_offset > 0 {
115            self.scroll_offset -= 1;
116        }
117    }
118    
119    /// Scroll down
120    pub fn scroll_down(&mut self) {
121        if let Some(ref file) = self.current_file {
122            let max_offset = file.lines.len().saturating_sub(1);
123            if self.scroll_offset < max_offset {
124                self.scroll_offset += 1;
125            }
126        }
127    }
128    
129    /// Page up
130    pub fn page_up(&mut self) {
131        let page_size = 20; // Could be based on visible area
132        self.scroll_offset = self.scroll_offset.saturating_sub(page_size);
133    }
134    
135    /// Page down
136    pub fn page_down(&mut self) {
137        if let Some(ref file) = self.current_file {
138            let page_size = 20;
139            let max_offset = file.lines.len().saturating_sub(page_size);
140            self.scroll_offset = (self.scroll_offset + page_size).min(max_offset);
141        }
142    }
143    
144    /// Update the component
145    pub async fn update(&mut self) -> Result<()> {
146        // Handle any periodic updates
147        Ok(())
148    }
149    
150    /// Update theme
151    pub fn update_theme(&mut self, theme: &dyn Theme) {
152        // In a real implementation, we'd clone or recreate the theme
153        // For now, this is a placeholder
154    }
155    
156    /// Load file content from disk
157    async fn load_file(&self, path: &str) -> Result<FileContent> {
158        let _path_obj = Path::new(path);
159        
160        // Check file size
161        let metadata = std::fs::metadata(path)?;
162        if metadata.len() > self.config.max_file_size as u64 {
163            return Err(anyhow::anyhow!("File too large: {} bytes", metadata.len()));
164        }
165        
166        // Determine file type
167        let file_type = self.detect_file_type(path);
168        
169        // Read file content
170        let content = match file_type {
171            FileType::Binary | FileType::Image | FileType::Archive => {
172                format!("Binary file: {} ({} bytes)", path, metadata.len())
173            }
174            _ => {
175                match std::fs::read_to_string(path) {
176                    Ok(content) => content,
177                    Err(_e) => {
178                        // Try reading as binary and show hex dump
179                        let binary_data = std::fs::read(path)?;
180                        self.format_hex_dump(&binary_data)
181                    }
182                }
183            }
184        };
185        
186        let lines: Vec<String> = content.lines().map(|s| s.to_string()).collect();
187        
188        // Detect language for syntax highlighting
189        let language = if self.config.syntax_highlighting {
190            self.detect_language(path, &content)
191        } else {
192            None
193        };
194        
195        // Check if this is a diff file
196        let is_diff = content.starts_with("diff --git") || 
197                     content.starts_with("--- ") ||
198                     path.ends_with(".diff") || 
199                     path.ends_with(".patch");
200        
201        Ok(FileContent {
202            path: path.to_string(),
203            content,
204            lines,
205            language,
206            is_diff,
207            file_type,
208        })
209    }
210    
211    /// Detect file type based on extension and content
212    fn detect_file_type(&self, path: &str) -> FileType {
213        let path = Path::new(path);
214        
215        if let Some(extension) = path.extension().and_then(|e| e.to_str()) {
216            match extension.to_lowercase().as_str() {
217                "jpg" | "jpeg" | "png" | "gif" | "bmp" | "svg" | "webp" => FileType::Image,
218                "zip" | "tar" | "gz" | "bz2" | "xz" | "7z" | "rar" => FileType::Archive,
219                "exe" | "dll" | "so" | "dylib" | "bin" => FileType::Binary,
220                _ => FileType::Text,
221            }
222        } else {
223            FileType::Unknown
224        }
225    }
226    
227    /// Detect programming language for syntax highlighting
228    fn detect_language(&self, path: &str, content: &str) -> Option<String> {
229        let path = Path::new(path);
230        
231        // First try by file extension
232        if let Some(extension) = path.extension().and_then(|e| e.to_str()) {
233            let language = match extension.to_lowercase().as_str() {
234                "rs" => Some("rust"),
235                "js" | "mjs" => Some("javascript"),
236                "ts" => Some("typescript"),
237                "py" => Some("python"),
238                "go" => Some("go"),
239                "java" => Some("java"),
240                "c" => Some("c"),
241                "cpp" | "cxx" | "cc" => Some("cpp"),
242                "h" | "hpp" => Some("c"),
243                "cs" => Some("csharp"),
244                "php" => Some("php"),
245                "rb" => Some("ruby"),
246                "swift" => Some("swift"),
247                "kt" => Some("kotlin"),
248                "scala" => Some("scala"),
249                "r" => Some("r"),
250                "sql" => Some("sql"),
251                "sh" | "bash" => Some("bash"),
252                "ps1" => Some("powershell"),
253                "html" | "htm" => Some("html"),
254                "css" => Some("css"),
255                "scss" | "sass" => Some("scss"),
256                "xml" => Some("xml"),
257                "json" => Some("json"),
258                "yaml" | "yml" => Some("yaml"),
259                "toml" => Some("toml"),
260                "md" | "markdown" => Some("markdown"),
261                "tex" => Some("latex"),
262                "vim" => Some("vim"),
263                "lua" => Some("lua"),
264                "pl" => Some("perl"),
265                "clj" | "cljs" => Some("clojure"),
266                "hs" => Some("haskell"),
267                "ml" => Some("ocaml"),
268                "elm" => Some("elm"),
269                "ex" | "exs" => Some("elixir"),
270                "erl" => Some("erlang"),
271                "dart" => Some("dart"),
272                _ => None,
273            };
274            
275            if language.is_some() {
276                return language.map(|s| s.to_string());
277            }
278        }
279        
280        // Try to detect by filename
281        if let Some(filename) = path.file_name().and_then(|f| f.to_str()) {
282            match filename.to_lowercase().as_str() {
283                "dockerfile" => return Some("dockerfile".to_string()),
284                "makefile" => return Some("makefile".to_string()),
285                "rakefile" => return Some("ruby".to_string()),
286                "gemfile" => return Some("ruby".to_string()),
287                "cargo.toml" => return Some("toml".to_string()),
288                "package.json" => return Some("json".to_string()),
289                _ => {}
290            }
291        }
292        
293        // Try to detect by shebang
294        if let Some(first_line) = content.lines().next() {
295            if first_line.starts_with("#!") {
296                if first_line.contains("python") {
297                    return Some("python".to_string());
298                } else if first_line.contains("bash") || first_line.contains("sh") {
299                    return Some("bash".to_string());
300                } else if first_line.contains("node") {
301                    return Some("javascript".to_string());
302                } else if first_line.contains("ruby") {
303                    return Some("ruby".to_string());
304                } else if first_line.contains("perl") {
305                    return Some("perl".to_string());
306                }
307            }
308        }
309        
310        None
311    }
312    
313    /// Format binary data as hex dump
314    fn format_hex_dump(&self, data: &[u8]) -> String {
315        let mut result = String::new();
316        
317        for (i, chunk) in data.chunks(16).enumerate() {
318            // Address
319            result.push_str(&format!("{:08x}  ", i * 16));
320            
321            // Hex bytes
322            for (j, byte) in chunk.iter().enumerate() {
323                if j == 8 {
324                    result.push(' ');
325                }
326                result.push_str(&format!("{:02x} ", byte));
327            }
328            
329            // Padding for incomplete lines
330            if chunk.len() < 16 {
331                for j in chunk.len()..16 {
332                    if j == 8 {
333                        result.push(' ');
334                    }
335                    result.push_str("   ");
336                }
337            }
338            
339            // ASCII representation
340            result.push_str(" |");
341            for byte in chunk {
342                if byte.is_ascii_graphic() || *byte == b' ' {
343                    result.push(*byte as char);
344                } else {
345                    result.push('.');
346                }
347            }
348            result.push_str("|\n");
349        }
350        
351        result
352    }
353    
354    /// Render the file viewer
355    pub fn render(&mut self, renderer: &Renderer, area: Rect) {
356        if !self.is_visible {
357            return;
358        }
359        
360        let title = if let Some(ref file) = self.current_file {
361            format!("File: {}", file.path)
362        } else {
363            "No file open".to_string()
364        };
365        
366        let block = Block::default()
367            .title(title)
368            .borders(Borders::ALL)
369            .border_style(Style::default().fg(self.theme.border()));
370        
371        renderer.render_widget(block.clone(), area);
372        
373        let inner_area = block.inner(area);
374        
375        if let Some(ref file) = self.current_file {
376            if file.is_diff {
377                self.render_diff(renderer, inner_area, file);
378            } else {
379                self.render_text_file(renderer, inner_area, file);
380            }
381        } else {
382            let empty_msg = Paragraph::new("No file open")
383                .style(Style::default().fg(self.theme.text_muted()));
384            renderer.render_widget(empty_msg, inner_area);
385        }
386    }
387    
388    /// Render a text file
389    fn render_text_file(&self, renderer: &Renderer, area: Rect, file: &FileContent) {
390        let visible_height = area.height as usize;
391        let start_line = self.scroll_offset;
392        let end_line = (start_line + visible_height).min(file.lines.len());
393        
394        let visible_lines = &file.lines[start_line..end_line];
395        
396        let mut lines = Vec::new();
397        for (i, line) in visible_lines.iter().enumerate() {
398            let line_number = start_line + i + 1;
399            
400            let formatted_line = if self.config.show_line_numbers {
401                let line_num_style = Style::default().fg(self.theme.text_muted());
402                let line_content = if self.config.syntax_highlighting && file.language.is_some() {
403                    // Apply syntax highlighting
404                    self.syntax_highlighter.highlight(line, file.language.as_ref().unwrap())
405                } else {
406                    vec![Span::styled(line, Style::default().fg(self.theme.text()))]
407                };
408                
409                let mut spans = vec![
410                    Span::styled(format!("{:4} ", line_number), line_num_style),
411                ];
412                spans.extend(line_content);
413                Line::from(spans)
414            } else {
415                if self.config.syntax_highlighting && file.language.is_some() {
416                    let highlighted = self.syntax_highlighter.highlight(line, file.language.as_ref().unwrap());
417                    Line::from(highlighted)
418                } else {
419                    Line::from(Span::styled(line, Style::default().fg(self.theme.text())))
420                }
421            };
422            
423            lines.push(formatted_line);
424        }
425        
426        let paragraph = Paragraph::new(lines)
427            .style(Style::default().bg(self.theme.background()));
428        
429        renderer.render_widget(paragraph, area);
430        
431        // Render scrollbar if needed
432        if file.lines.len() > visible_height {
433            let scrollbar = Scrollbar::default()
434                .orientation(ScrollbarOrientation::VerticalRight)
435                .begin_symbol(Some("↑"))
436                .end_symbol(Some("↓"));
437            
438            let mut scrollbar_state = ScrollbarState::default()
439                .content_length(file.lines.len())
440                .position(self.scroll_offset);
441            
442            // Note: scrollbar rendering would need proper state management
443            // renderer.render_stateful_widget(scrollbar, area, &mut scrollbar_state);
444        }
445    }
446    
447    /// Render a diff file
448    fn render_diff(&self, renderer: &Renderer, area: Rect, file: &FileContent) {
449        // This is a simplified diff renderer
450        // A full implementation would parse the diff and show changes
451        let lines: Vec<Line> = file.lines
452            .iter()
453            .skip(self.scroll_offset)
454            .take(area.height as usize)
455            .map(|line| {
456                let style = if line.starts_with('+') {
457                    Style::default()
458                        .fg(self.theme.diff_added())
459                        .bg(self.theme.background_element())
460                } else if line.starts_with('-') {
461                    Style::default()
462                        .fg(self.theme.diff_removed())
463                        .bg(self.theme.background_element())
464                } else if line.starts_with("@@") {
465                    Style::default()
466                        .fg(self.theme.diff_context())
467                        .bg(self.theme.background_panel())
468                } else {
469                    Style::default().fg(self.theme.text())
470                };
471                
472                Line::from(Span::styled(line, style))
473            })
474            .collect();
475        
476        let paragraph = Paragraph::new(lines)
477            .style(Style::default().bg(self.theme.background()));
478        
479        renderer.render_widget(paragraph, area);
480    }
481}
482
483#[cfg(test)]
484mod tests {
485    use super::*;
486    use crate::config::FileViewerConfig;
487    
488    #[test]
489    fn test_file_type_detection() {
490        let config = FileViewerConfig::default();
491        let theme = crate::theme::DefaultTheme;
492        let viewer = FileViewer::new(&config, &theme);
493        
494        assert_eq!(viewer.detect_file_type("test.rs"), FileType::Text);
495        assert_eq!(viewer.detect_file_type("image.jpg"), FileType::Image);
496        assert_eq!(viewer.detect_file_type("archive.zip"), FileType::Archive);
497        assert_eq!(viewer.detect_file_type("binary.exe"), FileType::Binary);
498    }
499    
500    #[test]
501    fn test_language_detection() {
502        let config = FileViewerConfig::default();
503        let theme = crate::theme::DefaultTheme;
504        let viewer = FileViewer::new(&config, &theme);
505        
506        assert_eq!(viewer.detect_language("test.rs", ""), Some("rust".to_string()));
507        assert_eq!(viewer.detect_language("script.py", "#!/usr/bin/env python"), Some("python".to_string()));
508        assert_eq!(viewer.detect_language("Dockerfile", "FROM ubuntu"), Some("dockerfile".to_string()));
509    }
510}