nnn

mirror of https://github.com/jarun/nnn
Log | Files | Refs | README | LICENSE

commit f34bdfc551710ed5f4936d6d7e5ef97074ad35b1
parent 972691fd53084393dad02faed6c2260aa2e19087
Author: Arun Prakash Jana <engineerarun@gmail.com>
Date:   Mon, 13 Apr 2026 01:17:21 +0530

Support preview pane with <kbd>P</kbd>
Add .npreview: a previewer plugin that nnn auto-detects

Diffstat:
Aplugins/.npreview | 135+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/nnn.c | 295+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Msrc/nnn.h | 3+++
3 files changed, 428 insertions(+), 5 deletions(-)

diff --git a/plugins/.npreview b/plugins/.npreview @@ -0,0 +1,135 @@ +#!/usr/bin/env sh + +# Description: Preview plugin for nnn's built-in preview pane +# Auto-detected from plugins dir +# Usage: .npreview <filepath> <width> <height> +# All dependencies optional — graceful fallback to head/file/strings + +set -euf + +F="$1" +W="${2:-80}" +H="${3:-25}" +ext="${F##*.}" +ext="$(printf "%s" "$ext" | tr '[:upper:]' '[:lower:]')" + +has() { type "$1" >/dev/null 2>&1; } +hd() { head -n "$H"; } + +# --- Handlers --- + +show_dir() { + has tree && { tree -L 1 -n --dirsfirst --noreport "$F" | hd; return; } + ls -1 "$F" 2>/dev/null | hd +} + +show_text() { + has bat && { bat --style=plain --color=never --paging=never --line-range=":${H}" -- "$F" 2>/dev/null; return; } + has batcat && { batcat --style=plain --color=never --paging=never --line-range=":${H}" -- "$F" 2>/dev/null; return; } + head -n "$H" "$F" +} + +show_pdf() { + has pdftotext && { pdftotext -l 1 -nopgbrk -q -- "$F" - 2>/dev/null | hd; return; } + has mutool && { mutool draw -F txt -i -- "$F" 1 2>/dev/null | hd; return; } + show_meta +} + +show_archive() { + case "$ext" in + rar) has unrar && { unrar lt -p- -- "$F" 2>/dev/null | hd; return; } ;; + 7z) has 7z && { 7z l -p -- "$F" 2>/dev/null | hd; return; } ;; + esac + has atool && { atool --list -- "$F" 2>/dev/null | hd; return; } + has bsdtar && { bsdtar --list --file "$F" 2>/dev/null | hd; return; } + has tar && { tar tf "$F" 2>/dev/null | hd; return; } + has unzip && [ "$ext" = "zip" ] && { unzip -l "$F" 2>/dev/null | hd; return; } + printf "[archive %s]\n" "$(du -h "$F" 2>/dev/null | cut -f1)" +} + +show_image() { + has img2txt && { img2txt --gamma=0.6 --width="$W" -- "$F" 2>/dev/null | hd; return; } + show_meta +} + +show_meta() { + has exiftool && { exiftool "$F" 2>/dev/null | hd; return; } + has mediainfo && { mediainfo "$F" 2>/dev/null | hd; return; } + has file && { file -b "$F"; return; } + printf "[no metadata tool]\n" +} + +show_json() { + has jq && { head -c 65536 "$F" | jq --monochrome-output . 2>/dev/null | hd; return; } + has python3 && { head -c 65536 "$F" | python3 -m json.tool 2>/dev/null | hd; return; } + show_text +} + +show_md() { + has glow && { glow -s notty "$F" 2>/dev/null | hd; return; } + has lowdown && { lowdown -Tterm "$F" 2>/dev/null | hd; return; } + show_text +} + +show_html() { + has w3m && { w3m -dump "$F" 2>/dev/null | hd; return; } + has lynx && { lynx -dump -- "$F" 2>/dev/null | hd; return; } + show_text +} + +show_elf() { + has readelf && { readelf -h "$F" 2>/dev/null | hd; return; } + has objdump && { objdump -f "$F" 2>/dev/null | hd; return; } + has file && file -b "$F" +} + +show_doc() { + has odt2txt && { odt2txt "$F" 2>/dev/null | hd; return; } + show_meta +} + +show_bin() { + has strings && { printf "[binary]\n"; strings "$F" 2>/dev/null | hd; return; } + printf "[binary %s]\n" "$(du -h "$F" 2>/dev/null | cut -f1)" +} + +# --- Directory --- +[ -d "$F" ] && { show_dir; exit 0; } + +# --- Dispatch by extension --- +case "$ext" in + a|ace|alz|arc|arj|bz|bz2|cab|cpio|deb|gz|jar|lha|lz|lzh|lzma|lzo|\ + rpm|rz|t7z|tar|tbz|tbz2|tgz|tlz|txz|tz|tzo|war|xpi|xz|z|zip|rar|7z|zst) + show_archive; exit 0 ;; + pdf) show_pdf; exit 0 ;; + md|mkd|markdown) show_md; exit 0 ;; + htm|html|xhtml) show_html; exit 0 ;; + json) show_json; exit 0 ;; + odt|ods|odp|sxw) show_doc; exit 0 ;; + torrent) has transmission-show && { transmission-show -- "$F" 2>/dev/null | hd; exit 0; } ;; + sqlite|sqlite3|db) has sqlite3 && { printf ".tables\n" | sqlite3 "$F" 2>/dev/null | hd; exit 0; } ;; + o|so|dylib) show_elf; exit 0 ;; + c|cc|cpp|h|hpp|rs|go|py|rb|js|ts|java|sh|bash|zsh|pl|lua|vim|el|\ + css|scss|yaml|yml|toml|ini|conf|xml|sql|diff|patch|awk|sed|make) + show_text; exit 0 ;; +esac + +# --- Dispatch by mime type --- +has file || { show_text; exit 0; } +mime="$(file -bL --mime-type -- "$F" 2>/dev/null)" + +case "$mime" in + text/*|*/xml) show_text ;; + application/json) show_json ;; + application/pdf) show_pdf ;; + application/zip|application/x-tar|application/x-rar*|\ + application/x-7z*|application/x-bzip*|application/x-xz|\ + application/x-compress*|application/gzip|application/zstd) + show_archive ;; + image/*) show_image ;; + audio/*|video/*) show_meta ;; + application/x-executable|application/x-sharedlib|application/x-pie-executable|\ + application/x-object) show_elf ;; + application/vnd.openxmlformats*|application/vnd.oasis.opendocument*) show_doc ;; + *) show_bin ;; +esac diff --git a/src/nnn.c b/src/nnn.c @@ -358,7 +358,7 @@ typedef struct { uint_t blkorder : 1; /* Set to sort by blocks used (disk usage) */ uint_t extnorder : 1; /* Order by extension */ uint_t showhidden : 1; /* Set to show hidden files */ - uint_t reserved0 : 1; + uint_t preview : 1; /* Show preview pane */ uint_t showdetail : 1; /* Clear to show lesser file info */ uint_t ctxactive : 1; /* Context active or not */ uint_t reverse : 1; /* Reverse sort */ @@ -477,6 +477,7 @@ static char *plgpath; static char *pnamebuf, *pselbuf, *findselpos; static char *mark; static char *trashcmd; +static char *previewer = NULL; #ifndef NOX11 static char hostname[_POSIX_HOST_NAME_MAX + 1]; #endif @@ -604,6 +605,7 @@ static runstate g_state; #define UTIL_GIO_TRASH 19 #define UTIL_RM_RF 20 #define UTIL_ARCHMNT 21 +#define UTIL_NPREVIEW 22 /* Utilities to open files, run actions */ static char * const utils[] = { @@ -645,6 +647,7 @@ static char * const utils[] = { "gio trash", "rm -rf --", "archivemount", + ".npreview", }; /* Common strings */ @@ -6281,9 +6284,10 @@ static void show_help(const char *path) "1FILES\n" "9o ^O Open with%15n Create new/link\n" "9f ^F File stats%14d Detail mode toggle\n" - "b^R Rename/dup%14r Batch rename\n" - "cz Archive%17e Edit file\n" - "c* Toggle exe%14> Export list\n" + "b^R Rename/dup%14P Preview toggle\n" + "cr Batch rename%12e Edit file\n" + "cz Archive%17* Toggle exe\n" + "c> Export list\n" "6Space + (Un)select%12m-m Select range/clear\n" "ca Select all%14A Invert sel\n" "9p ^P Copy here%12w ^W Cp/mv sel as\n" @@ -8064,6 +8068,272 @@ static inline void markhovered(void) } } +#define PREVIEW_BORDER_COL (xcols / 2) +#define PREVIEW_COL (PREVIEW_BORDER_COL + 2) +#define PREVIEW_WIDTH (xcols - PREVIEW_COL - 1) +#define MIN_PREVIEW_COLS 40 +#define PREVIEW_MAX_LINE 4096 + +/* Check if a file is likely text by reading initial bytes */ +static bool is_text_file(const char *fpath) +{ + int fd = open(fpath, O_RDONLY); + if (fd == -1) + return FALSE; + + unsigned char buf[512]; + ssize_t n = read(fd, buf, sizeof(buf)); + close(fd); + + if (n <= 0) + return FALSE; + + /* Reject known binary magic signatures */ + static const struct { + const unsigned char bytes[8]; + unsigned char len; + } magics[] = { + { { '%', 'P', 'D', 'F' }, 4 }, /* PDF */ + { { 0x89, 'P', 'N', 'G' }, 4 }, /* PNG */ + { { 'G', 'I', 'F', '8' }, 4 }, /* GIF */ + { { 0xFF, 0xD8, 0xFF }, 3 }, /* JPEG */ + { { 'P', 'K', 0x03, 0x04 }, 4 }, /* ZIP */ + { { 0x7F, 'E', 'L', 'F' }, 4 }, /* ELF */ + { { 0x1F, 0x8B }, 2 }, /* Gzip */ + { { 'B', 'M' }, 2 }, /* BMP */ + { { 'R', 'I', 'F', 'F' }, 4 }, /* RIFF */ + { { 'O', 'g', 'g', 'S' }, 4 }, /* OGG */ + { { 'f', 'L', 'a', 'C' }, 4 }, /* FLAC */ + { { 'I', 'D', '3' }, 3 }, /* MP3 ID3 */ + { { 0xFE, 0xED, 0xFA }, 3 }, /* Mach-O */ + { { 0xFD, '7', 'z', 'X', 'Z', 0x00 }, 6 }, /* XZ */ + { { '7', 'z', 0xBC, 0xAF, 0x27, 0x1C }, 6 }, /* 7z */ + { { 'S', 'Q', 'L', 'i', 't', 'e' }, 6 }, /* SQLite */ + }; + + for (size_t i = 0; i < sizeof(magics) / sizeof(magics[0]); ++i) { + if (n >= magics[i].len + && !memcmp(buf, magics[i].bytes, magics[i].len)) + return FALSE; + } + + /* MP3 sync word (needs mask check) */ + if (n >= 2 && buf[0] == 0xFF && (buf[1] & 0xE0) == 0xE0) + return FALSE; + + /* Check for NUL bytes and non-text control characters */ + for (ssize_t i = 0; i < n; ++i) { + unsigned char c = buf[i]; + if (c == '\0') + return FALSE; + /* Reject most control chars except common text ones */ + if (c < 0x20 && c != '\n' && c != '\r' && c != '\t' + && c != '\f' && c != '\033') /* ESC for ANSI */ + return FALSE; + } + + return TRUE; +} + +/* Draw the preview pane for the currently hovered file */ +static void preview_pane(const char *path) +{ + if (!ndents || !cfg.preview) + return; + + int previewcol = PREVIEW_BORDER_COL; + int previewwidth = PREVIEW_WIDTH; + + if (previewwidth < MIN_PREVIEW_COLS) + return; + + /* Draw vertical border */ + for (int i = 0; i < xlines - 1; ++i) { + move(i, previewcol); + addch(ACS_VLINE | A_DIM); + } + + char fpath[PATH_MAX]; + mkpath(path, pdents[cur].name, fpath); + + /* Auto-detect .npreview plugin */ + if (!previewer) { + previewer = malloc(xstrlen(plgpath) + xstrlen(utils[UTIL_NPREVIEW]) + 1); + mkpath(plgpath, utils[UTIL_NPREVIEW], previewer); + if (access(previewer, X_OK)) { + free(previewer); + previewer = NULL; + } + } + + if (previewer) { + int pipefd[2]; + if (pipe(pipefd) == -1) + return; + + pid_t pid = fork(); + if (pid == 0) { + close(pipefd[0]); + dup2(pipefd[1], STDOUT_FILENO); + dup2(pipefd[1], STDERR_FILENO); + close(pipefd[1]); + + char widthbuf[16], heightbuf[16]; + snprintf(widthbuf, sizeof(widthbuf), "%d", previewwidth); + snprintf(heightbuf, sizeof(heightbuf), "%d", xlines - 2); + + execlp(previewer, previewer, fpath, + widthbuf, heightbuf, + (char *)NULL); + _exit(EXIT_FAILURE); + } + close(pipefd[1]); + + if (pid > 0) { + FILE *fp = fdopen(pipefd[0], "r"); + if (fp) { + char line[PREVIEW_MAX_LINE]; + int row = 1; + int maxrows = xlines - 2; + + while (row < maxrows && fgets(line, sizeof(line), fp)) { + size_t len = xstrlen(line); + if (len > 0 && line[len - 1] == '\n') + line[--len] = '\0'; + if (len > 0 && line[len - 1] == '\r') + line[--len] = '\0'; + + /* Replace tabs with spaces */ + for (size_t i = 0; i < len; ++i) + if (line[i] == '\t') + line[i] = ' '; + + /* Wrap long lines across multiple rows */ + int off = 0; + do { + char save = '\0'; + if ((int)(len - off) > previewwidth) { + save = line[off + previewwidth]; + line[off + previewwidth] = '\0'; + } + mvaddstr(row, PREVIEW_COL, line + off); + if (save) + line[off + previewwidth] = save; + ++row; + off += previewwidth; + } while (off < (int)len && row < maxrows); + } + fclose(fp); + } else { + close(pipefd[0]); + } + waitpid(pid, NULL, 0); + } else { + close(pipefd[0]); + } + return; + } + + struct stat sb; + if (lstat(fpath, &sb) == -1) + return; + + /* For directories, show entry count */ + if (S_ISDIR(sb.st_mode)) { + DIR *dirp = opendir(fpath); + if (!dirp) + return; + + int count = 0; + struct dirent *dp; + char namebuf[PATH_MAX]; + int maxlines = xlines - 4; /* Leave header and status lines */ + + mvaddstr(1, PREVIEW_COL, "[directory]"); + + while ((dp = readdir(dirp)) && count < maxlines) { + if (dp->d_name[0] == '.' && (!dp->d_name[1] + || (dp->d_name[1] == '.' && !dp->d_name[2]))) + continue; + snprintf(namebuf, sizeof(namebuf), "%.*s", + previewwidth, dp->d_name); + mvaddstr(2 + count, PREVIEW_COL, namebuf); + ++count; + } + closedir(dirp); + return; + } + + /* For symlinks, resolve and show target */ + if (S_ISLNK(sb.st_mode)) { + char target[PATH_MAX]; + ssize_t len = readlink(fpath, target, sizeof(target) - 1); + if (len > 0) { + target[len] = '\0'; + mvprintw(1, PREVIEW_COL, "-> %.*s", previewwidth - 3, target); + } + /* Resolve symlink for further preview */ + if (stat(fpath, &sb) == -1) + return; + if (!S_ISREG(sb.st_mode)) + return; + } + + /* For regular files, show content preview if text */ + if (S_ISREG(sb.st_mode)) { + if (sb.st_size == 0) { + mvaddstr(1, PREVIEW_COL, "[empty file]"); + return; + } + + if (!is_text_file(fpath)) { + char szbuf[32]; + mvprintw(1, PREVIEW_COL, "[binary %s]", + coolsize(sb.st_size)); + (void)szbuf; + return; + } + + FILE *fp = fopen(fpath, "r"); + if (!fp) + return; + + char line[PREVIEW_MAX_LINE]; + int row = 1; + int maxrows = xlines - 2; /* Leave top and bottom lines */ + + while (row < maxrows && fgets(line, sizeof(line), fp)) { + /* Strip trailing newline */ + size_t len = xstrlen(line); + if (len > 0 && line[len - 1] == '\n') + line[--len] = '\0'; + if (len > 0 && line[len - 1] == '\r') + line[--len] = '\0'; + + /* Replace tabs with spaces for consistent display */ + for (size_t i = 0; i < len; ++i) + if (line[i] == '\t') + line[i] = ' '; + + /* Wrap long lines across multiple rows */ + int off = 0; + do { + char save = '\0'; + if ((int)(len - off) > previewwidth) { + save = line[off + previewwidth]; + line[off + previewwidth] = '\0'; + } + mvaddstr(row, PREVIEW_COL, line + off); + if (save) + line[off + previewwidth] = save; + ++row; + off += previewwidth; + } while (off < (int)len && row < maxrows); + } + fclose(fp); + } +} + static int adjust_cols(int n) { /* Calculate the number of cols available to print entry name */ @@ -8123,13 +8393,20 @@ static void redraw(char *path) getmaxyx(stdscr, xlines, xcols); int ncols = (xcols <= PATH_MAX) ? xcols : PATH_MAX; + /* Limit listing width when preview pane is active */ + if (cfg.preview && xcols >= (MIN_PREVIEW_COLS * 2 + 1)) { + int listcols = PREVIEW_BORDER_COL; + if (ncols > listcols) + ncols = listcols; + } int i, j = 1; // Fast redraw if (g_state.move) { g_state.move = 0; - if (ndents && (last_curscroll == curscroll)) + if (ndents && (last_curscroll == curscroll) + && !cfg.preview) return draw_line(ncols); } @@ -8252,6 +8529,9 @@ static void redraw(char *path) } markhovered(); + + if (cfg.preview) + preview_pane(path); } static bool cdprep(char *lastdir, char *lastname, char *path, char *newpath) @@ -8941,6 +9221,7 @@ nochange: case SEL_MFLTR: // fallthrough case SEL_HIDDEN: // fallthrough case SEL_DETAIL: // fallthrough + case SEL_PREVIEW: // fallthrough case SEL_SORT: switch (sel) { case SEL_MFLTR: @@ -8966,6 +9247,9 @@ nochange: cfg.showdetail ^= 1; cfg.blkorder = 0; continue; + case SEL_PREVIEW: + cfg.preview ^= 1; + continue; default: /* SEL_SORT */ r = set_sort_flags(get_input(messages[MSG_ORDER])); if (!r) { @@ -10083,6 +10367,7 @@ static void cleanup(void) free(dir_dispatched_bmp); free(bookmark); free(plug); + free(previewer); if (lastcmdpos != INVALID_POS) for (uchar_t pos = 0; pos <= lastcmdpos; ++pos) free(cmd_hist[pos]); diff --git a/src/nnn.h b/src/nnn.h @@ -113,6 +113,7 @@ enum action { SEL_SESSIONS, SEL_EXPORT, SEL_TIMETYPE, + SEL_PREVIEW, SEL_QUITCTX, SEL_QUITCD, SEL_QUIT, @@ -276,6 +277,8 @@ static struct key bindings[] = { { '>', SEL_EXPORT }, /* Set time type */ { 'T', SEL_TIMETYPE }, + /* Toggle preview pane */ + { 'P', SEL_PREVIEW }, /* Quit a context */ { 'q', SEL_QUITCTX }, /* Change dir on quit */