fpv 0.1.12

A minimal, keyboard-first TUI file previewer with syntax highlighting
Documentation
use crate::app::current_dir_state::truncate_for_status;
use crate::app::state::{NodeType, SessionState, TreeNode};
use crate::config::load::ThemeProfile;
use crate::fs::git::GitFileStatus;
use crate::tui::colors::{
    GIT_ADDED, GIT_CONFLICTED, GIT_DELETED, GIT_IGNORED, GIT_MODIFIED, GIT_RENAMED,
};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph};
use ratatui::Frame;
use std::path::{Component, Path, PathBuf};
use unicode_width::UnicodeWidthStr;

pub fn entry_prefix(node_type: &NodeType) -> &'static str {
    match node_type {
        NodeType::Directory => "/",
        NodeType::File => "",
        NodeType::Symlink => "@",
        NodeType::Unknown => "?",
    }
}

pub fn current_directory_header_line(state: &SessionState, width: usize) -> String {
    truncate_for_status(&raw_current_directory_header_line(state), width)
}

fn raw_current_directory_header_line(state: &SessionState) -> String {
    let path = full_display_path(&state.current_path);
    let Some(git) = &state.git_status else {
        return path;
    };
    let changes = git.change_count();
    if changes == 0 {
        format!("{path} git:({})", git.branch)
    } else {
        format!("{path} git:({}) [{changes} changes]", git.branch)
    }
}

fn full_display_path(path: &Path) -> String {
    if path.is_absolute() {
        return normalize_display_path(path).display().to_string();
    }
    std::env::current_dir()
        .map(|cwd| {
            normalize_display_path(&cwd.join(path))
                .display()
                .to_string()
        })
        .unwrap_or_else(|_| path.display().to_string())
}

fn absolute_display_path(path: &Path) -> PathBuf {
    if path.is_absolute() {
        normalize_display_path(path)
    } else {
        std::env::current_dir()
            .map(|cwd| normalize_display_path(&cwd.join(path)))
            .unwrap_or_else(|_| path.to_path_buf())
    }
}

fn normalize_display_path(path: &Path) -> PathBuf {
    let mut normalized = PathBuf::new();
    for component in path.components() {
        if !matches!(component, Component::CurDir) {
            normalized.push(component.as_os_str());
        }
    }
    if normalized.as_os_str().is_empty() {
        PathBuf::from(".")
    } else {
        normalized
    }
}

pub fn shorten_path_with_home(path: &Path, home: &str) -> String {
    let rendered = path.display().to_string();
    if rendered == home {
        "~".to_string()
    } else if rendered.starts_with(&(home.to_owned() + "/")) {
        format!("~{}", &rendered[home.len()..])
    } else {
        rendered
    }
}

pub fn display_path_with_home(path: &Path) -> String {
    let rendered = path.display().to_string();
    let Some(home) = std::env::var_os("HOME").map(|s| s.to_string_lossy().to_string()) else {
        return rendered;
    };
    shorten_path_with_home(path, &home)
}

pub fn color_from_name(name: &str) -> Color {
    match name {
        "black" => Color::Black,
        "red" => Color::Red,
        "green" => Color::Green,
        "yellow" => Color::Yellow,
        "blue" => Color::Blue,
        "magenta" => Color::Magenta,
        "cyan" => Color::Cyan,
        "gray" | "white" => Color::White,
        _ => Color::White,
    }
}

pub fn node_style(
    node: &TreeNode,
    theme: &ThemeProfile,
    git_status: Option<GitFileStatus>,
) -> Style {
    let mut style = match node.node_type {
        NodeType::Directory => Style::default().fg(color_from_name(&theme.directory_color)),
        NodeType::File => {
            let ext = node
                .path
                .extension()
                .and_then(|e| e.to_str())
                .unwrap_or_default()
                .to_ascii_lowercase();
            let color_name = theme
                .file_type_colors
                .get(&ext)
                .map(String::as_str)
                .unwrap_or(&theme.fallback_file_color);
            Style::default().fg(color_from_name(color_name))
        }
        _ => Style::default().fg(color_from_name(&theme.fallback_file_color)),
    };
    if theme.hidden_dim_enabled && node.name.starts_with('.') {
        style = style.add_modifier(Modifier::DIM);
    }
    if git_status == Some(GitFileStatus::Ignored) {
        style = Style::default().fg(GIT_IGNORED).add_modifier(Modifier::DIM);
    }
    style
}

pub fn draw_current_directory_header(
    frame: &mut Frame<'_>,
    area: ratatui::layout::Rect,
    state: &SessionState,
    theme: &ThemeProfile,
) {
    let path = full_display_path(&state.current_path);
    let mut spans = vec![Span::raw(path.clone())];
    let mut raw_line = path;

    if let Some(git) = &state.git_status {
        let changes = git.change_count();
        let git_text = if changes == 0 {
            format!("git:({})", git.branch)
        } else {
            format!("git:({}) [{changes} changes]", git.branch)
        };
        raw_line.push(' ');
        raw_line.push_str(&git_text);
        spans.push(Span::raw(" "));
        spans.push(Span::styled(
            git_text,
            Style::default().fg(color_from_name(&theme.directory_color)),
        ));
    }

    let truncated = truncate_for_status(&raw_line, area.width as usize);
    let display = if truncated == raw_line {
        Line::from(spans)
    } else {
        Line::from(truncated)
    };
    frame.render_widget(Paragraph::new(display), area);
}

pub fn draw_tree(
    frame: &mut Frame<'_>,
    area: ratatui::layout::Rect,
    nodes: &[TreeNode],
    state: &SessionState,
    theme: &ThemeProfile,
) {
    frame.render_widget(Clear, area);
    if nodes.is_empty() {
        let empty_label = state
            .current_dir_error
            .as_deref()
            .unwrap_or("(empty directory)");
        let empty = Paragraph::new(empty_label).block(Block::default().borders(Borders::ALL));
        frame.render_widget(empty, area);
        return;
    }

    let content_width = area.width.saturating_sub(2) as usize;
    let items: Vec<ListItem<'_>> = nodes
        .iter()
        .map(|n| {
            let icon = entry_prefix(&n.node_type);
            let status = git_status_label_for_node(state, n);
            let left = if icon.is_empty() {
                n.name.clone()
            } else {
                format!("{icon} {}", n.name)
            };
            let right_indicator = right_indicator_for_node(state, n, status);
            let (left_text, padding, right_label) =
                compose_tree_entry_segments(&left, right_indicator, content_width);

            let mut spans = Vec::with_capacity(3);
            spans.push(Span::styled(left_text, node_style(n, theme, status)));
            if padding > 0 {
                spans.push(Span::raw(" ".repeat(padding)));
            }
            if let Some(label) = right_label {
                spans.push(Span::styled(
                    label,
                    status_label_style(status, right_indicator),
                ));
            }

            ListItem::new(Line::from(spans))
        })
        .collect();

    let list = List::new(items)
        .block(Block::default().borders(Borders::ALL))
        .highlight_style(Style::default().add_modifier(Modifier::REVERSED));

    let mut list_state = ListState::default();
    list_state.select(Some(state.selected_index));
    frame.render_stateful_widget(list, area, &mut list_state);
}

pub fn directory_contains_uncommitted_changes(state: &SessionState, node: &TreeNode) -> bool {
    if node.node_type != NodeType::Directory {
        return false;
    }

    let Some(repo) = state.git_status.as_ref() else {
        return false;
    };
    let abs = absolute_display_path(&node.path);
    let Ok(rel_dir) = abs.strip_prefix(&repo.repo_root) else {
        return false;
    };
    if rel_dir.as_os_str().is_empty() {
        return false;
    }

    repo.file_statuses.iter().any(|(changed_path, status)| {
        *status != GitFileStatus::Ignored
            && (changed_path.as_path() == rel_dir || changed_path.starts_with(rel_dir))
    })
}

fn right_indicator_for_node(
    state: &SessionState,
    node: &TreeNode,
    status: Option<GitFileStatus>,
) -> Option<&'static str> {
    if status == Some(GitFileStatus::Ignored) {
        return None;
    }
    if let Some(status) = status {
        return Some(status.label());
    }
    if directory_contains_uncommitted_changes(state, node) {
        return Some("*");
    }
    None
}

fn git_status_label_for_node(state: &SessionState, node: &TreeNode) -> Option<GitFileStatus> {
    let repo = state.git_status.as_ref()?;
    let abs = absolute_display_path(&node.path);
    let rel = abs.strip_prefix(&repo.repo_root).ok()?;
    repo.file_statuses.get(rel).copied()
}

fn compose_tree_entry_segments(
    left: &str,
    right_indicator: Option<&'static str>,
    content_width: usize,
) -> (String, usize, Option<&'static str>) {
    if content_width == 0 {
        return (String::new(), 0, None);
    }

    let Some(label) = right_indicator else {
        return (truncate_for_status(left, content_width), 0, None);
    };
    let reserved = UnicodeWidthStr::width(label).saturating_add(1);

    if content_width <= reserved {
        return (truncate_for_status(left, content_width), 0, None);
    }

    let left_capacity = content_width - reserved;
    let left_text = truncate_for_status(left, left_capacity);
    let left_width = UnicodeWidthStr::width(left_text.as_str());
    let padding = content_width
        .saturating_sub(left_width)
        .saturating_sub(UnicodeWidthStr::width(label));

    (left_text, padding, Some(label))
}
fn status_label_style(
    status: Option<GitFileStatus>,
    right_indicator: Option<&'static str>,
) -> Style {
    if right_indicator == Some("*") {
        return Style::default()
            .fg(GIT_MODIFIED)
            .add_modifier(Modifier::BOLD);
    }
    match status {
        Some(GitFileStatus::Deleted) => Style::default()
            .fg(GIT_DELETED)
            .add_modifier(Modifier::BOLD),
        Some(GitFileStatus::Modified) => Style::default()
            .fg(GIT_MODIFIED)
            .add_modifier(Modifier::BOLD),
        Some(GitFileStatus::Added) | Some(GitFileStatus::Untracked) => {
            Style::default().fg(GIT_ADDED).add_modifier(Modifier::BOLD)
        }
        Some(GitFileStatus::Renamed) | Some(GitFileStatus::Copied) => Style::default()
            .fg(GIT_RENAMED)
            .add_modifier(Modifier::BOLD),
        Some(GitFileStatus::Conflicted) => Style::default()
            .fg(GIT_CONFLICTED)
            .add_modifier(Modifier::BOLD),
        Some(GitFileStatus::Ignored) => {
            Style::default().fg(GIT_IGNORED).add_modifier(Modifier::DIM)
        }
        None => Style::default(),
    }
}