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
18pub 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#[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#[derive(Debug, Clone, PartialEq)]
42pub enum FileType {
43 Text,
44 Binary,
45 Image,
46 Archive,
47 Unknown,
48}
49
50impl FileViewer {
51 pub fn new(config: &FileViewerConfig, theme: &dyn Theme) -> Self {
53 Self {
54 theme: Box::new(crate::theme::DefaultTheme), 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 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 pub fn close_file(&mut self) {
75 self.current_file = None;
76 self.is_visible = false;
77 self.scroll_offset = 0;
78 }
79
80 pub fn is_visible(&self) -> bool {
82 self.is_visible
83 }
84
85 pub fn current_file_path(&self) -> Option<&str> {
87 self.current_file.as_ref().map(|f| f.path.as_str())
88 }
89
90 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 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 pub fn scroll_up(&mut self) {
114 if self.scroll_offset > 0 {
115 self.scroll_offset -= 1;
116 }
117 }
118
119 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 pub fn page_up(&mut self) {
131 let page_size = 20; self.scroll_offset = self.scroll_offset.saturating_sub(page_size);
133 }
134
135 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 pub async fn update(&mut self) -> Result<()> {
146 Ok(())
148 }
149
150 pub fn update_theme(&mut self, theme: &dyn Theme) {
152 }
155
156 async fn load_file(&self, path: &str) -> Result<FileContent> {
158 let _path_obj = Path::new(path);
159
160 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 let file_type = self.detect_file_type(path);
168
169 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 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 let language = if self.config.syntax_highlighting {
190 self.detect_language(path, &content)
191 } else {
192 None
193 };
194
195 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 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 fn detect_language(&self, path: &str, content: &str) -> Option<String> {
229 let path = Path::new(path);
230
231 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 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 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 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 result.push_str(&format!("{:08x} ", i * 16));
320
321 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 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 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 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 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 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 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 }
445 }
446
447 fn render_diff(&self, renderer: &Renderer, area: Rect, file: &FileContent) {
449 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}