elio 1.6.0

Snappy, batteries-included terminal file manager with rich previews, inline images, bulk actions, and trash support.
Documentation
mod data;
mod directive;
mod ini;
mod logs;

use crate::{file_info::CustomCodeKind, preview::appearance as theme};
use ratatui::{
    style::Style,
    text::{Line, Span},
};

pub(crate) fn render_custom_code_preview<F>(
    kind: CustomCodeKind,
    text: &str,
    line_numbers: bool,
    line_limit: usize,
    canceled: &F,
) -> Vec<Line<'static>>
where
    F: Fn() -> bool,
{
    let code_palette = theme::code_preview_palette();
    let source_lines = crate::preview::collect_preview_lines_with_limit(
        text,
        crate::preview::clamp_code_preview_line_limit(line_limit),
    );
    let number_width = crate::preview::line_number_width(source_lines.len());
    let mut rendered = Vec::new();
    let mut jsonc_block_comment = false;

    for (index, line) in source_lines.iter().enumerate() {
        if canceled() {
            break;
        }

        let mut spans = Vec::new();
        if line_numbers {
            spans.push(crate::preview::line_number_span(index + 1, number_width));
        } else {
            spans.push(Span::styled(
                "",
                Style::default().fg(code_palette.line_number),
            ));
        }

        let body = match kind {
            CustomCodeKind::DirectiveConf => {
                directive::highlight_directive_conf_line(line, code_palette)
            }
            CustomCodeKind::Ini => ini::highlight_ini_line(line, code_palette, false),
            CustomCodeKind::DesktopEntry => ini::highlight_ini_line(line, code_palette, true),
            CustomCodeKind::Json => data::highlight_json_line(line, code_palette),
            CustomCodeKind::Jsonc => {
                data::highlight_jsonc_line(line, code_palette, &mut jsonc_block_comment)
            }
            CustomCodeKind::Toml => data::highlight_toml_line(line, code_palette),
            CustomCodeKind::Yaml => data::highlight_yaml_line(line, code_palette),
            CustomCodeKind::Log => logs::highlight_log_line(line, code_palette),
        };
        spans.extend(body);
        rendered.push(Line::from(spans));
    }

    if rendered.is_empty() && !canceled() {
        rendered.push(Line::from("File is empty"));
    }

    rendered
}

pub(super) fn split_comment(input: &str) -> (&str, Option<&str>) {
    let mut in_string = false;
    let mut quote = '\0';
    let mut escape = false;

    for (index, ch) in input.char_indices() {
        if in_string {
            if escape {
                escape = false;
                continue;
            }
            if ch == '\\' && quote == '"' {
                escape = true;
                continue;
            }
            if ch == quote {
                in_string = false;
            }
            continue;
        }

        if ch == '"' || ch == '\'' {
            in_string = true;
            quote = ch;
            continue;
        }

        if ch == '#' {
            return (&input[..index], Some(&input[index..]));
        }
    }

    (input, None)
}

pub(super) fn split_unquoted_once(input: &str, needle: char) -> Option<(&str, &str)> {
    let mut in_string = false;
    let mut quote = '\0';
    let mut escape = false;

    for (index, ch) in input.char_indices() {
        if in_string {
            if escape {
                escape = false;
                continue;
            }
            if ch == '\\' && quote == '"' {
                escape = true;
                continue;
            }
            if ch == quote {
                in_string = false;
            }
            continue;
        }

        if ch == '"' || ch == '\'' {
            in_string = true;
            quote = ch;
            continue;
        }

        if ch == needle {
            let right_start = index + ch.len_utf8();
            return Some((&input[..index], &input[right_start..]));
        }
    }

    None
}

pub(super) fn split_jsonc_segments<'a>(
    line: &'a str,
    in_block_comment: &mut bool,
) -> Vec<(bool, &'a str)> {
    let mut segments = Vec::new();
    let mut cursor = 0usize;

    while cursor < line.len() {
        if *in_block_comment {
            let comment_start = cursor;
            if let Some(offset) = line[cursor..].find("*/") {
                let end = cursor + offset + 2;
                segments.push((true, &line[comment_start..end]));
                *in_block_comment = false;
                cursor = end;
            } else {
                segments.push((true, &line[comment_start..]));
                return segments;
            }
            continue;
        }

        let code_start = cursor;
        let mut index = cursor;
        let mut in_string = false;
        let mut escape = false;

        while index < line.len() {
            let ch = line[index..].chars().next().expect("valid utf-8 char");
            let next = index + ch.len_utf8();

            if in_string {
                if escape {
                    escape = false;
                    index = next;
                    continue;
                }
                if ch == '\\' {
                    escape = true;
                    index = next;
                    continue;
                }
                if ch == '"' {
                    in_string = false;
                }
                index = next;
                continue;
            }

            if ch == '"' {
                in_string = true;
                index = next;
                continue;
            }

            if ch == '/'
                && let Some(next_char) = line[next..].chars().next()
            {
                if next_char == '/' {
                    if code_start < index {
                        segments.push((false, &line[code_start..index]));
                    }
                    segments.push((true, &line[index..]));
                    return segments;
                }

                if next_char == '*' {
                    if code_start < index {
                        segments.push((false, &line[code_start..index]));
                    }

                    let comment_start = index;
                    let search_start = next + next_char.len_utf8();
                    if let Some(offset) = line[search_start..].find("*/") {
                        let end = search_start + offset + 2;
                        segments.push((true, &line[comment_start..end]));
                        cursor = end;
                    } else {
                        segments.push((true, &line[comment_start..]));
                        *in_block_comment = true;
                        return segments;
                    }
                    break;
                }
            }

            index = next;
        }

        if cursor == index {
            segments.push((false, &line[cursor..]));
            return segments;
        }

        if index >= line.len() {
            segments.push((false, &line[code_start..]));
            return segments;
        }
    }

    if segments.is_empty() {
        segments.push((false, line));
    }
    segments
}

pub(super) fn scan_quoted_segment(input: &str, start: usize) -> usize {
    let quote = input[start..].chars().next().unwrap_or('"');
    let mut index = start + quote.len_utf8();
    let mut escape = false;

    while let Some(ch) = input[index..].chars().next() {
        if escape {
            escape = false;
            index += ch.len_utf8();
            continue;
        }
        if ch == '\\' && quote == '"' {
            escape = true;
            index += ch.len_utf8();
            continue;
        }
        if ch == quote {
            return index + ch.len_utf8();
        }
        index += ch.len_utf8();
    }

    input.len()
}

pub(super) fn looks_numeric(token: &str) -> bool {
    let stripped = token.trim_matches(',');
    !stripped.is_empty()
        && stripped
            .chars()
            .all(|ch| ch.is_ascii_digit() || matches!(ch, '.' | '_' | '-' | '+' | 'x' | 'o' | 'b'))
}

pub(super) fn styled_text(
    text: &str,
    color: ratatui::style::Color,
    modifier: ratatui::style::Modifier,
) -> Span<'static> {
    Span::styled(
        text.to_string(),
        Style::default().fg(color).add_modifier(modifier),
    )
}

#[cfg(test)]
mod tests {
    use super::*;

    fn line_text(line: &Line<'_>) -> String {
        line.spans
            .iter()
            .map(|span| span.content.as_ref())
            .collect::<String>()
    }

    fn assert_span_color(line: &Line<'_>, token: &str, expected: ratatui::style::Color) {
        assert!(
            line.spans
                .iter()
                .any(|span| span.content.contains(token) && span.style.fg == Some(expected)),
            "expected token {token:?} with color {expected:?} in line {:?}",
            line_text(line)
        );
    }

    #[test]
    fn jsonc_renderer_keeps_line_comments_and_block_comments() {
        let lines = render_custom_code_preview(
            CustomCodeKind::Jsonc,
            "{\n  // comment\n  /* block */\n  \"name\": \"elio\"\n}\n",
            true,
            20,
            &|| false,
        );

        assert!(
            lines[1]
                .spans
                .iter()
                .any(|span| span.content.contains("// comment"))
        );
        assert!(
            lines[2]
                .spans
                .iter()
                .any(|span| span.content.contains("/* block */"))
        );
        assert!(line_text(&lines[3]).contains("\"name\": \"elio\""));
    }

    #[test]
    fn directive_renderer_keeps_existing_palette_contract() {
        let palette = theme::code_preview_palette();
        let lines = render_custom_code_preview(
            CustomCodeKind::DirectiveConf,
            "font_size 11.5\nforeground #c0c6e2\ninclude ~/.config/kitty/theme.conf\n",
            true,
            20,
            &|| false,
        );

        assert_span_color(&lines[0], "font_size", palette.function);
        assert_span_color(&lines[0], "11.5", palette.constant);
        assert_span_color(&lines[1], "foreground", palette.function);
        assert_span_color(&lines[1], "#c0c6e2", palette.constant);
        assert_span_color(&lines[2], "include", palette.function);
        assert_span_color(&lines[2], "~/.config/kitty/theme.conf", palette.string);
    }

    #[test]
    fn desktop_entry_renderer_handles_unicode_values() {
        let lines = render_custom_code_preview(
            CustomCodeKind::DesktopEntry,
            "[Desktop Entry]\nName=エリオ\nName[ja]=日本語アプリ\n",
            true,
            20,
            &|| false,
        );

        assert!(
            lines
                .iter()
                .flat_map(|line| line.spans.iter())
                .any(|span| span.content.contains("日本語アプリ"))
        );
    }

    #[test]
    fn log_renderer_highlights_levels_and_fields() {
        let lines = render_custom_code_preview(
            CustomCodeKind::Log,
            "2026-03-10T12:00:00Z ERROR request_id=42 path=/login failed\n",
            true,
            20,
            &|| false,
        );

        assert!(
            lines[0]
                .spans
                .iter()
                .any(|span| span.content.contains("ERROR"))
        );
        assert!(
            lines[0]
                .spans
                .iter()
                .any(|span| span.content.contains("request_id"))
        );
    }
}