dotstate 0.3.3

A modern, secure, and user-friendly dotfile manager built with Rust
Documentation
use crate::utils::{focused_border_style, unfocused_border_style};
use anyhow::Result;
use ratatui::prelude::*;
use ratatui::text::{Line, Span, Text};
use ratatui::widgets::{
    Block, Borders, Padding, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, Wrap,
};
use std::path::PathBuf;
use syntect::easy::HighlightLines;
use syntect::highlighting::{Style as SyntectStyle, Theme};
use syntect::parsing::SyntaxSet;
use syntect::util::LinesWithEndings;

/// Common file preview component
pub struct FilePreview;

impl FilePreview {
    /// Render a file preview with syntax highlighting
    ///
    /// # Arguments
    /// * `frame` - The frame to render to
    /// * `area` - The area to render the preview in
    /// * `file_path` - Path to the file to preview
    /// * `scroll_offset` - Number of lines to skip from the top
    /// * `focused` - Whether the preview pane is focused (for border color)
    /// * `title` - Optional custom title (defaults to "Preview")
    /// * `syntax_set` - Syntax definitions for highlighting
    /// * `theme` - Theme for highlighting
    /// * `config` - Application config for icon settings
    #[allow(clippy::too_many_arguments)]
    pub fn render(
        frame: &mut Frame,
        area: Rect,
        file_path: &PathBuf,
        scroll_offset: &mut usize,
        focused: bool,
        title: Option<&str>,
        content_override: Option<&str>,
        syntax_set: &SyntaxSet,
        theme: &Theme,
        config: &crate::config::Config,
    ) -> Result<()> {
        let preview_title = title.unwrap_or("Preview");
        let no_color = crate::styles::theme().theme_type == crate::styles::ThemeType::NoColor;
        let t = crate::styles::theme();
        let (border_style, border_type) = if focused {
            (focused_border_style(), t.border_focused_type)
        } else {
            (unfocused_border_style(), t.border_type)
        };

        // Read file content or use override
        if file_path.is_file() || content_override.is_some() {
            let content_result = if let Some(content) = content_override {
                Ok(content.to_string())
            } else {
                std::fs::read_to_string(file_path)
            };

            if let Ok(content) = content_result {
                let total_lines = content.lines().count().max(1);
                // borders(2) + padding(2) = 4 vertical, same horizontal
                let visible_height = area.height.saturating_sub(4) as usize;
                let inner_width = area.width.saturating_sub(4) as usize;

                // Clamp scroll offset: count backwards from end to find how many
                // content lines fit, accounting for long lines that wrap
                let content_lines: Vec<&str> = content.lines().collect();
                let mut visual_rows = 0;
                let mut fitting_from_end = 0;
                for line in content_lines.iter().rev() {
                    let rows = if inner_width > 0 && !line.is_empty() {
                        line.len().div_ceil(inner_width)
                    } else {
                        1
                    };
                    if visual_rows + rows > visible_height && fitting_from_end > 0 {
                        break;
                    }
                    visual_rows += rows;
                    fitting_from_end += 1;
                }
                let max_scroll = total_lines.saturating_sub(fitting_from_end);
                *scroll_offset = (*scroll_offset).min(max_scroll);

                // Determine syntax
                let syntax = if let Some(content_str) = content_override {
                    // If content override is provided, try to detect syntax from content or default to Diff if it looks like one
                    if content_str.starts_with("diff --git") || content_str.starts_with("--- a/") {
                        syntax_set
                            .find_syntax_by_name("Diff")
                            .or_else(|| syntax_set.find_syntax_by_extension("diff"))
                            .or_else(|| syntax_set.find_syntax_by_extension("patch"))
                            .unwrap_or_else(|| syntax_set.find_syntax_plain_text())
                    } else {
                        // Try to guess from file extension first if path matches
                        syntax_set
                            .find_syntax_for_file(file_path)
                            .unwrap_or(None)
                            .unwrap_or_else(|| syntax_set.find_syntax_plain_text())
                    }
                } else {
                    // Standard detection logic
                    // First check for overrides based on filename
                    let file_name = file_path.file_name().and_then(|n| n.to_str()).unwrap_or("");

                    if file_name.ends_with("rc")
                        || file_name.contains("profile")
                        || file_name == ".aliases"
                        || file_name == ".functions"
                    {
                        // Assume shell for *rc files, profile, aliases, functions
                        syntax_set
                            .find_syntax_by_name("Bourne Again Shell (bash)")
                            .or_else(|| syntax_set.find_syntax_by_extension("sh"))
                            .or_else(|| syntax_set.find_syntax_for_file(file_path).unwrap_or(None))
                            .unwrap_or_else(|| syntax_set.find_syntax_plain_text())
                    } else if file_name.ends_with(".conf") || file_name.ends_with(".config") {
                        // Try to find a specific syntax, otherwise fallback to INI/Shell or just rely on extension
                        syntax_set
                            .find_syntax_for_file(file_path)
                            .unwrap_or(None)
                            .or_else(|| syntax_set.find_syntax_by_extension("ini"))
                            .unwrap_or_else(|| syntax_set.find_syntax_plain_text())
                    } else if file_name.ends_with(".vim")
                        || file_name == ".vimrc"
                        || file_name.contains("vim")
                    {
                        syntax_set
                            .find_syntax_by_extension("vim")
                            .or_else(|| syntax_set.find_syntax_by_name("VimL"))
                            .or_else(|| syntax_set.find_syntax_by_name("Vim Script"))
                            .or_else(|| syntax_set.find_syntax_by_extension("lua"))
                            .unwrap_or_else(|| syntax_set.find_syntax_plain_text())
                    } else {
                        // Standard detection
                        syntax_set
                            .find_syntax_for_file(file_path)
                            .unwrap_or(None)
                            .unwrap_or_else(|| syntax_set.find_syntax_plain_text())
                    }
                };

                let mut highlighter = HighlightLines::new(syntax, theme);

                // Skip lines up to scroll_offset efficiently
                let mut lines_iter = LinesWithEndings::from(&content);
                for _ in 0..*scroll_offset {
                    lines_iter.next();
                }

                // Process only visible lines
                let mut preview_lines = Vec::new();
                for line in lines_iter.take(visible_height) {
                    if no_color {
                        // No-color mode: do not emit any syntax-highlight fg/bg colors.
                        preview_lines.push(Line::from(Span::raw(line.to_string())));
                    } else {
                        // Highlight the line
                        let ranges: Vec<(SyntectStyle, &str)> = highlighter
                            .highlight_line(line, syntax_set)
                            .unwrap_or_default();

                        // Convert to Ratatui spans
                        let spans: Vec<Span> = ranges
                            .into_iter()
                            .map(|(style, text)| {
                                let fg = Color::Rgb(
                                    style.foreground.r,
                                    style.foreground.g,
                                    style.foreground.b,
                                );
                                Span::styled(text.to_string(), Style::default().fg(fg))
                            })
                            .collect();
                        preview_lines.push(Line::from(spans));
                    }
                }

                // Create text with lines
                let mut preview_text = Text::from(preview_lines);

                // Add footer info if there are more lines
                let end_line = (*scroll_offset + visible_height).min(total_lines);
                if total_lines > end_line {
                    preview_text.extend([
                        Line::from(""),
                        Line::from(""),
                        Line::from(format!(
                            "... ({} total lines, showing lines {}-{})",
                            total_lines,
                            *scroll_offset + 1,
                            end_line
                        )),
                    ]);
                }

                let preview = Paragraph::new(preview_text)
                    .block(
                        Block::default()
                            .borders(Borders::ALL)
                            .title(format!(" {preview_title} "))
                            .border_type(border_type)
                            .title_alignment(Alignment::Center)
                            .border_style(border_style)
                            .style(t.background_style())
                            .padding(Padding::uniform(1)),
                    )
                    .wrap(Wrap { trim: false }); // Don't trim whitespace

                frame.render_widget(preview, area);

                // === SCROLLBAR IMPLEMENTATION ===
                let mut scrollbar_state = ScrollbarState::new(max_scroll).position(*scroll_offset);

                let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
                    .begin_symbol(Some(""))
                    .end_symbol(Some(""))
                    .track_symbol(Some(""))
                    .thumb_symbol("");

                frame.render_stateful_widget(scrollbar, area, &mut scrollbar_state);
            } else {
                let error_text = format!("Unable to read file: {file_path:?}");
                let preview = Paragraph::new(error_text).block(
                    Block::default()
                        .borders(Borders::ALL)
                        .title(format!(" {preview_title} "))
                        .border_type(border_type)
                        .title_alignment(Alignment::Center)
                        .border_style(border_style)
                        .style(t.background_style())
                        .padding(Padding::uniform(1)),
                );
                frame.render_widget(preview, area);
            }
        } else if file_path.is_dir() {
            let mut preview_lines = Vec::new();
            let theme = crate::styles::theme();
            let icons = crate::icons::Icons::from_config(config);

            preview_lines.push(Line::from(vec![
                Span::styled("Directory: ", theme.title_style()),
                Span::styled(file_path.to_string_lossy(), theme.text_style()),
            ]));
            preview_lines.push(Line::from(""));

            let mut total_entries = 0;
            let visible_height = area.height.saturating_sub(4) as usize;
            let mut dir_max_scroll = 0;

            match std::fs::read_dir(file_path) {
                Ok(read_dir) => {
                    let mut entries: Vec<_> = read_dir.flatten().collect();

                    // Sort: directories first, then files, both alphabetically
                    entries.sort_by(|a, b| {
                        let a_path = a.path();
                        let b_path = b.path();
                        let a_is_dir = a_path.is_dir();
                        let b_is_dir = b_path.is_dir();

                        if a_is_dir == b_is_dir {
                            a.file_name().cmp(&b.file_name())
                        } else if a_is_dir {
                            std::cmp::Ordering::Less
                        } else {
                            std::cmp::Ordering::Greater
                        }
                    });

                    total_entries = entries.len();

                    // Clamp scroll offset for directory listing
                    dir_max_scroll =
                        total_entries.saturating_sub(visible_height.min(total_entries));
                    *scroll_offset = (*scroll_offset).min(dir_max_scroll);

                    if entries.is_empty() {
                        preview_lines.push(Line::from(Span::styled(
                            "  (Empty directory)",
                            theme.muted_style(),
                        )));
                    } else {
                        for entry in entries.iter().skip(*scroll_offset).take(visible_height) {
                            let path = entry.path();
                            let name = entry.file_name().to_string_lossy().to_string();
                            let is_dir = path.is_dir();

                            let icon = if is_dir { icons.folder() } else { icons.file() };
                            let item_style = if is_dir {
                                theme.title_style()
                            } else {
                                theme.text_style()
                            };

                            preview_lines.push(Line::from(vec![
                                Span::styled(format!("{icon} "), item_style),
                                Span::styled(name, item_style),
                            ]));
                        }
                    }
                }
                Err(e) => {
                    preview_lines.push(Line::from(Span::styled(
                        format!("Error reading directory: {e}"),
                        theme.error_style(),
                    )));
                }
            }

            let preview = Paragraph::new(Text::from(preview_lines)).block(
                Block::default()
                    .borders(Borders::ALL)
                    .title(format!(" {preview_title} "))
                    .border_type(border_type)
                    .title_alignment(Alignment::Center)
                    .border_style(border_style)
                    .style(t.background_style())
                    .padding(Padding::uniform(1)),
            );
            frame.render_widget(preview, area);

            if total_entries > visible_height {
                let mut scrollbar_state =
                    ScrollbarState::new(dir_max_scroll).position(*scroll_offset);
                let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
                    .begin_symbol(Some(""))
                    .end_symbol(Some(""))
                    .track_symbol(Some(""))
                    .thumb_symbol("");
                frame.render_stateful_widget(scrollbar, area, &mut scrollbar_state);
            }
        } else {
            let path_text = format!("Path: {file_path:?}");
            let preview = Paragraph::new(path_text).block(
                Block::default()
                    .borders(Borders::ALL)
                    .title(format!(" {preview_title} "))
                    .border_type(border_type)
                    .title_alignment(Alignment::Center)
                    .border_style(border_style)
                    .style(t.background_style())
                    .padding(Padding::uniform(1)),
            );
            frame.render_widget(preview, area);
        }

        Ok(())
    }
}