lsv 0.1.15

Three‑pane terminal file viewer (TUI) with preview and Lua configuration
Documentation
use std::{
    path::Path,
    process::Command,
};

use ratatui::{
    layout::Rect,
    style::{
        Color,
        Style,
    },
    text::{
        Line,
        Span,
    },
    widgets::{
        Block,
        Borders,
        Clear,
        Paragraph,
        Wrap,
    },
};

use crate::ui::ansi::ansi_spans;
use mlua::Value as LuaValue;

const PREVIEW_LINES_LIMIT: usize = 1000;

pub fn draw_preview_panel(
    f: &mut ratatui::Frame,
    area: Rect,
    app: &mut crate::App,
)
{
    f.render_widget(Clear, area);
    let block = preview_block(app);
    let preview_area = block.inner(area);
    let mut dynamic_lines: Option<Vec<String>> = None;
    if let Some(sel) = app.selected_entry()
    {
        if !sel.is_dir
        {
            let key =
                (sel.path.clone(), preview_area.width, preview_area.height);
            if app.preview.cache_key.as_ref() == Some(&key)
            {
                dynamic_lines = app.preview.cache_lines.clone();
            }
            else
            {
                dynamic_lines = run_previewer(
                    app,
                    &sel.path,
                    preview_area,
                    PREVIEW_LINES_LIMIT,
                );
                app.preview.cache_key = Some(key);
                app.preview.cache_lines = dynamic_lines.clone();
            }
        }
        else
        {
            app.preview.cache_key = None;
            app.preview.cache_lines = None;
        }
    }
    let text: Vec<Line> = if let Some(sel) = app.selected_entry()
    {
        if sel.is_dir
        {
            let block_inner = block.inner(area);
            let inner_w = block_inner.width;
            let fmt = app.config.ui.row.clone().unwrap_or_default();
            let list = app.read_dir_sorted(&sel.path).unwrap_or_default();
            let limit = PREVIEW_LINES_LIMIT.min(list.len());
            list.into_iter()
                .take(limit)
                .map(|e| {
                    crate::ui::panes::build_row_line(app, &fmt, &e, inner_w)
                })
                .collect()
        }
        else if let Some(lines) = dynamic_lines.as_ref()
        {
            if lines.is_empty()
            {
                vec![Line::from(Span::styled(
                    "<no selection>",
                    Style::default().fg(Color::DarkGray),
                ))]
            }
            else
            {
                lines.iter().map(|l| Line::from(ansi_spans(l))).collect()
            }
        }
        else if app.preview.static_lines.is_empty()
        {
            vec![Line::from(Span::styled(
                "<no selection>",
                Style::default().fg(Color::DarkGray),
            ))]
        }
        else
        {
            app.preview
                .static_lines
                .iter()
                .map(|l| Line::from(ansi_spans(l)))
                .collect()
        }
    }
    else if app.preview.static_lines.is_empty()
    {
        vec![Line::from(Span::styled(
            "<no selection>",
            Style::default().fg(Color::DarkGray),
        ))]
    }
    else
    {
        app.preview
            .static_lines
            .iter()
            .map(|l| Line::from(ansi_spans(l)))
            .collect()
    };

    let mut para = Paragraph::new(text).block(block).wrap(Wrap { trim: true });
    if let Some(th) = app.config.ui.theme.as_ref()
    {
        let mut st = Style::default();
        if let Some(fg) =
            th.item_fg.as_ref().and_then(|s| crate::ui::colors::parse_color(s))
        {
            st = st.fg(fg);
        }
        if let Some(bg) =
            th.item_bg.as_ref().and_then(|s| crate::ui::colors::parse_color(s))
        {
            st = st.bg(bg);
        }
        para = para.style(st);
    }
    f.render_widget(para, area);
}

fn preview_block(app: &crate::App) -> Block<'static>
{
    let mut block = Block::default().borders(Borders::ALL);
    if let Some(th) = app.config.ui.theme.as_ref()
    {
        if let Some(bg) =
            th.pane_bg.as_ref().and_then(|s| crate::ui::colors::parse_color(s))
        {
            block = block.style(Style::default().bg(bg));
        }
        if let Some(bfg) = th
            .border_fg
            .as_ref()
            .and_then(|s| crate::ui::colors::parse_color(s))
        {
            block = block.border_style(Style::default().fg(bfg));
        }
    }
    block
}

fn run_previewer(
    app: &crate::App,
    path: &Path,
    area: Rect,
    limit: usize,
) -> Option<Vec<String>>
{
    if let Some(lua) = app.lua.as_ref()
        && let (engine, Some(key)) = (&lua.engine, lua.previewer.as_ref())
    {
        let lua = engine.lua();
        if let Ok(func) = lua.registry_value::<mlua::Function>(key)
        {
            let path_str = path.to_string_lossy().to_string();
            let dir_str = path
                .parent()
                .unwrap_or_else(|| Path::new("."))
                .to_string_lossy()
                .to_string();
            let ext = path
                .extension()
                .and_then(|s| s.to_str())
                .unwrap_or("")
                .to_string();
            let is_binary = file_is_binary(path);
            let name_now = path
                .file_name()
                .map(|s| s.to_string_lossy().to_string())
                .unwrap_or_default();
            if let Ok(ctx) = lua.create_table()
            {
                let _ = ctx.set("current_file", path_str.clone());
                let _ = ctx.set("current_file_dir", dir_str.clone());
                let _ = ctx.set("current_file_name", name_now.clone());
                let _ = ctx.set("current_file_extension", ext.clone());
                let _ = ctx.set("is_binary", is_binary);
                let _ = ctx.set("preview_height", area.height as i64);
                let _ = ctx.set("preview_width", area.width as i64);
                let _ = ctx.set("preview_x", area.x as i64);
                let _ = ctx.set("preview_y", area.y as i64);

                match func.call::<LuaValue>(ctx)
                {
                    Ok(LuaValue::String(s)) => match s.to_str()
                    {
                        Ok(cmd) =>
                        {
                            let cmd = cmd.to_string();
                            crate::trace::log(format!(
                                "[preview] lua cmd='{}' cwd='{}' file='{}'",
                                cmd, dir_str, path_str
                            ));
                            return run_previewer_command(
                                &cmd, &dir_str, &path_str, limit,
                            );
                        }
                        Err(e) =>
                        {
                            crate::trace::log(format!(
                                "[preview] lua previewer returned non-utf8 \
                                 string: {}",
                                e
                            ));
                        }
                    },
                    Ok(LuaValue::Nil) =>
                    {
                        crate::trace::log(format!(
                            "[preview] lua previewer returned nil for file {} \
                             (ext: {})",
                            path_str, ext
                        ));
                    }
                    Ok(other) =>
                    {
                        crate::trace::log(format!(
                            "[preview] lua previewer returned unexpected \
                             type: {}",
                            other.type_name()
                        ));
                    }
                    Err(e) =>
                    {
                        let bt = std::backtrace::Backtrace::force_capture();
                        crate::trace::log(format!(
                            "[preview] lua error: {}",
                            e
                        ));
                        crate::trace::log(format!(
                            "[preview] backtrace:\n{}",
                            bt
                        ));
                    }
                }
            }
        }
    }
    None
}

fn run_previewer_command(
    cmd: &str,
    dir_str: &str,
    path_str: &str,
    limit: usize,
) -> Option<Vec<String>>
{
    let started = std::time::Instant::now();
    crate::trace::log(format!(
        "[preview] run: shell='{}' cwd='{}' cmd='{}' file='{}'",
        if cfg!(windows) { "cmd" } else { "sh" },
        dir_str,
        cmd,
        path_str
    ));

    #[cfg(windows)]
    let mut command = {
        let mut c = Command::new("cmd");
        c.arg("/C").arg(cmd);
        c
    };
    #[cfg(not(windows))]
    let mut command = {
        let mut c = Command::new("sh");
        c.arg("-lc").arg(cmd);
        c
    };

    match command
        .current_dir(dir_str)
        // No implicit LSV_* env; use placeholders or Lua ctx instead
        .env("FORCE_COLOR", "1")
        .env("CLICOLOR_FORCE", "1")
        .output()
    {
        Ok(out) =>
        {
            let elapsed = started.elapsed().as_millis();
            let mut buf = Vec::new();
            buf.extend_from_slice(&out.stdout);
            if !out.stderr.is_empty()
            {
                buf.push(b'\n');
                buf.extend_from_slice(&out.stderr);
            }
            let text = String::from_utf8_lossy(&buf).replace('\r', "");
            crate::trace::log(format!(
                "[preview] done: success={} exit_code={:?} bytes_out={} \
                 elapsed_ms={}",
                out.status.success(),
                out.status.code(),
                text.len(),
                elapsed
            ));
            if !out.status.success()
            {
                crate::trace::log(format!(
                    "[preview] non-zero status running '{}'",
                    cmd
                ));
            }
            let mut lines: Vec<String> = Vec::new();
            for l in text.lines()
            {
                lines.push(l.to_string());
                if lines.len() >= limit
                {
                    break;
                }
            }
            Some(lines)
        }
        Err(e) =>
        {
            crate::trace::log(format!(
                "[preview] error spawning via {}: {}",
                if cfg!(windows) { "cmd" } else { "sh" },
                e
            ));
            #[cfg(windows)]
            {
                crate::trace::log(
                    "[preview] hint: ensure the command is available in \
                     cmd.exe or adjust your previewer to use \
                     Windows-compatible tooling.",
                );
            }
            None
        }
    }
}

fn file_is_binary(path: &Path) -> bool
{
    if let Ok(mut f) = std::fs::File::open(path)
    {
        let mut buf = [0u8; 4096];
        if let Ok(n) = std::io::Read::read(&mut f, &mut buf)
        {
            let slice = &buf[..n];
            if slice.contains(&0)
            {
                return true;
            }
            if std::str::from_utf8(slice).is_err()
            {
                return true;
            }
        }
    }
    false
}