sfm

Simple File Manager
git clone git://git.emmett1.my/sfm.git
Log | Files | Refs | LICENSE

commit 8b9e5f19cdc509be44abe39ef0289c28739a2794
parent b7da442922fa92d3926d66aa8d428e7f363703c3
Author: emmett1 <emmett1.2miligrams@protonmail.com>
Date:   Sat, 14 Mar 2026 23:28:36 +0800

initial commit

Diffstat:
AMakefile | 33+++++++++++++++++++++++++++++++++
DREADME.md | 4----
AREADME.txt | 167+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asfm | 1394+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
4 files changed, 1594 insertions(+), 4 deletions(-)

diff --git a/Makefile b/Makefile @@ -0,0 +1,33 @@ +# Makefile for sfm - Simple File Manager + +PREFIX ?= /usr/local +BINDIR ?= $(PREFIX)/bin +MANDIR ?= $(PREFIX)/share/man/man1 +DOCDIR ?= $(PREFIX)/share/doc/sfm + +INSTALL ?= install +RM ?= rm -f + +.PHONY: all install uninstall clean + +all: + @echo "sfm is a shell script — nothing to build." + @echo "Run 'make install' to install." + +install: + @echo "Installing sfm to $(BINDIR)/sfm ..." + $(INSTALL) -d $(DESTDIR)$(BINDIR) + $(INSTALL) -m 755 sfm $(DESTDIR)$(BINDIR)/sfm + @echo "Installing README to $(DOCDIR) ..." + $(INSTALL) -d $(DESTDIR)$(DOCDIR) + $(INSTALL) -m 644 README.txt $(DESTDIR)$(DOCDIR)/README.txt + @echo "Done. Run 'sfm' to start." + +uninstall: + @echo "Removing sfm ..." + $(RM) $(DESTDIR)$(BINDIR)/sfm + $(RM) -r $(DESTDIR)$(DOCDIR) + @echo "Done." + +clean: + @echo "Nothing to clean." diff --git a/README.md b/README.md @@ -1,3 +0,0 @@ -# sfm - -Simple File Manager -\ No newline at end of file diff --git a/README.txt b/README.txt @@ -0,0 +1,167 @@ +sfm - Simple File Manager +========================== + + +DESCRIPTION +----------- +sfm is a lightweight, terminal-based file manager written entirely in +POSIX sh. It runs in your terminal with no external dependencies beyond +standard Unix tools (ls, awk, tput, stty, mv, cp, rm). Designed to be +fast, flicker-free, and keyboard-driven with a vim-inspired key layout. + + +REQUIREMENTS +------------ +- A POSIX-compatible shell (sh, dash, bash, etc.) +- Standard Unix tools: ls, awk, tput, stty, cp, mv, rm, mkdir, touch +- Optional: wl-copy / xclip / xsel / pbcopy (for clipboard support) +- Optional: mpv, vlc, feh, zathura, etc. (for smart file opening) +- Optional: readlink (for symlink display) + + +INSTALLATION +------------ +Using make (recommended): + + make install + +This installs sfm to /usr/local/bin by default. To change the prefix: + + make install PREFIX=/usr + +To uninstall: + + make uninstall + +Manual installation: + + cp sfm /usr/local/bin/sfm + chmod +x /usr/local/bin/sfm + + +USAGE +----- + sfm + +sfm will opens in the current directory. + + +NAVIGATION +---------- + j / k or up/down arrows Move up / down + h or left arrow Go to parent directory + l or right arrow / enter Open file or enter directory + g Jump to top of list + G Jump to bottom of list + ~ Go to home directory + ` (backtick) Jump to previous directory + + +SEARCH & FILTER +--------------- + / Enter search mode (filters listing as you type) + esc Clear filter and exit search mode + enter Exit search mode but keep filter active + + +DISPLAY TOGGLES +--------------- + . Toggle hidden files (dotfiles) + T Toggle size/date detail column + s Cycle sort mode: name -> size -> date + i Show file info (permissions, size, date) in status bar + R Refresh current directory listing + + +FILE OPERATIONS +--------------- + r Rename selected entry + m Make new directory + n New empty file + d Delete (single or selection) + u Move to trash (safe delete) + U Open trash directory + o Open with custom program + + +CLIPBOARD +--------- + y Yank / copy (works on multi-selection) + x Cut (works on multi-selection) + p Paste into current directory + c Copy full path of selected entry to system clipboard + + +MULTI-SELECT +------------ + space Toggle multi-select on current entry (cursor advances) + a Select all / deselect all + + +BOOKMARKS +--------- + b Bookmark current directory (press again to remove) + B Open bookmark picker (j/k navigate, enter jump) + +Bookmarks are saved to: ~/.config/sfm/bookmarks + + +SHELL & UTILITIES +----------------- + ! Drop into $SHELL in current directory (type "exit" to return) + + +HELP +---- + ? Show keyboard shortcuts overlay + + +QUIT +---- + q Quit sfm + + +STATUS BAR INDICATORS +--------------------- + [hidden] Hidden files are visible + [sort:size] Active sort mode (size or date; name is default) + [copy] A file is in the clipboard (copy mode) + [cut] A file is in the clipboard (cut mode) + [sel:N] N items are currently selected + [details] Size/date column is visible + + +SMART FILE OPENER +----------------- +When you open a file, sfm detects the file type by extension and +MIME type, then picks an appropriate program automatically: + + Text / code $EDITOR (or vi) + Images imv, feh, sxiv, eog, gimp + Video mpv, vlc, mplayer, totem + Audio mpv, vlc, cmus, mocp + PDF zathura, evince, okular, mupdf + Office docs libreoffice + Archives atool / bsdtar (lists contents in pager) + +Falls back to xdg-open, open (macOS), or $EDITOR if nothing matches. +Use 'o' to manually specify any program. + + +TRASH +----- +Files deleted with 'u' are moved to: + + ~/.local/share/sfm-trash/ + +Files are prefixed with a timestamp: YYYYMMDD_HHMMSS_filename +Use U to browse the trash directory. To restore a file, rename it +(press r) to remove the timestamp prefix, then cut (x) and paste (p) +it back to the desired location. + + +DATA FILES +---------- + ~/.config/sfm/bookmarks Saved bookmarks (one path per line) + ~/.local/share/sfm-trash/ Trashed files + diff --git a/sfm b/sfm @@ -0,0 +1,1394 @@ +#!/bin/sh +# sfm - Simple File Manager in POSIX sh + +# --- terminal control --- +tput_cmd() { command -v tput >/dev/null 2>&1 && tput "$@"; } + +RESET=$(tput_cmd sgr0) +BOLD=$(tput_cmd bold) +REV=$(tput_cmd rev) +RED=$(tput_cmd setaf 1) +GREEN=$(tput_cmd setaf 2) +YELLOW=$(tput_cmd setaf 3) +CYAN=$(tput_cmd setaf 6) +WHITE=$(tput_cmd setaf 7) +MAGENTA=$(tput_cmd setaf 5) +BLUE=$(tput_cmd setaf 4) +ERASE_LINE=$(tput_cmd el || printf '\033[K') + +# map a filename to a display colour based on extension +file_colour() { + _fc_name="$1" + _fc_ext="${_fc_name##*.}" + _fc_ext=$(printf '%s' "$_fc_ext" | tr '[:upper:]' '[:lower:]') + # executable? + [ -x "${CWD}/${_fc_name}" ] && { printf '%s' "${RED}${BOLD}"; return; } + case "$_fc_ext" in + # images + jpg|jpeg|png|gif|bmp|tiff|tif|webp|svg|ico|heic|heif|avif) + printf '%s' "${MAGENTA}" ;; + # video + mp4|mkv|avi|mov|wmv|flv|webm|m4v|mpeg|mpg|3gp|ogv) + printf '%s' "${MAGENTA}${BOLD}" ;; + # audio + mp3|flac|ogg|wav|aac|m4a|opus|wma|aiff) + printf '%s' "${CYAN}${BOLD}" ;; + # archives + zip|tar|gz|bz2|xz|zst|7z|rar|tgz|tbz2) + printf '%s' "${RED}" ;; + # documents / text + pdf|doc|docx|odt|xls|xlsx|ods|ppt|pptx|odp) + printf '%s' "${YELLOW}" ;; + # code / scripts + sh|bash|zsh|fish|py|rb|pl|lua|js|ts|jsx|tsx|\ + c|h|cpp|cc|cxx|hpp|rs|go|java|kt|swift|cs|php|r) + printf '%s' "${GREEN}" ;; + # data / config + json|xml|yaml|yml|toml|ini|conf|cfg|env) + printf '%s' "${CYAN}" ;; + # markdown / text + md|markdown|rst|txt|log) + printf '%s' "${WHITE}" ;; + # default + *) printf '%s' "${WHITE}" ;; + esac +} + +goto() { printf '\033[%d;%dH' "$1" "$2"; } +hide_cursor() { printf '\033[?25l'; } +show_cursor() { printf '\033[?25h'; } +term_rows() { tput_cmd lines || echo 24; } +term_cols() { tput_cmd cols || echo 80; } + +# --- state --- +CWD="$PWD" +SEL=0 +OFFSET=0 +PREV_SEL=0 +PREV_OFFSET=0 +NEED_FULL_REDRAW=1 +LAST_CHILD="" # name of dir we came from, used to restore selection on go-back +FILTER="" # current search string; empty = no filter +ALL_ENTRIES="" # unfiltered entries +ALL_COUNT=0 +SEARCHING=0 # 1 while in search input mode +SELECTED="" # newline-separated list of multi-selected entry names +CLIPBOARD="" # full path of yanked/cut entry +CLIP_MODE="" # "copy" or "cut" +INFO_MSG="" # ephemeral message shown in botbar +SHOW_HIDDEN=0 # 1 = show dotfiles +SORT_MODE="name" # name | size | date +SHOW_DETAILS=0 # 1 = show size+date column +TRASH_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/sfm-trash" +mkdir -p "$TRASH_DIR" +PREV_CWD="" # last visited directory, for jumping back +BOOKMARK_FILE="${XDG_CONFIG_HOME:-$HOME/.config}/sfm/bookmarks" +mkdir -p "$(dirname "$BOOKMARK_FILE")" +[ -f "$BOOKMARK_FILE" ] || touch "$BOOKMARK_FILE" + +# --- load directory --- +load_entries() { + ENTRIES="" + COUNT=0 + + # build list of dir names then file names, including dotfiles if enabled + for d in "$CWD"/*/; do + [ -d "$d" ] || continue + name="${d%/}"; name="${name##*/}" + [ "$name" = "*" ] && continue + case "$name" in .*) continue ;; esac # skip dotdirs here, handle below + ENTRIES="$ENTRIES +${name}/" + COUNT=$((COUNT + 1)) + done + if [ "$SHOW_HIDDEN" = "1" ]; then + for d in "$CWD"/./; do : ; done # no-op, just to avoid error + for d in "$CWD"/.*/; do + [ -d "$d" ] || continue + name="${d%/}"; name="${name##*/}" + [ "$name" = "." ] || [ "$name" = ".." ] && continue + [ "$name" = ".*" ] && continue + ENTRIES="$ENTRIES +${name}/" + COUNT=$((COUNT + 1)) + done + fi + + for f in "$CWD"/*; do + [ -e "$f" ] || [ -L "$f" ] || continue + [ -d "$f" ] && ! [ -L "$f" ] && continue + name="${f##*/}" + [ "$name" = "*" ] && continue + case "$name" in .*) continue ;; esac + if [ -L "$f" ]; then + ENTRIES="$ENTRIES +${name}@" + else + ENTRIES="$ENTRIES +${name}" + fi + COUNT=$((COUNT + 1)) + done + if [ "$SHOW_HIDDEN" = "1" ]; then + for f in "$CWD"/.*; do + [ -e "$f" ] || [ -L "$f" ] || continue + [ -d "$f" ] && ! [ -L "$f" ] && continue + name="${f##*/}" + [ "$name" = ".*" ] && continue + if [ -L "$f" ]; then + ENTRIES="$ENTRIES +${name}@" + else + ENTRIES="$ENTRIES +${name}" + fi + COUNT=$((COUNT + 1)) + done + fi + + ENTRIES="${ENTRIES# +}" + + # --- sort files only (dirs always stay first) --- + if [ "$SORT_MODE" != "name" ]; then + # separate dirs and files + _dirs=""; _files="" + _rest="$ENTRIES" + while [ -n "$_rest" ]; do + _line="${_rest%% +*}"; _next="${_rest#* +}" + [ "$_next" = "$_rest" ] && _next="" + _rest="$_next" + [ -z "$_line" ] && continue + case "$_line" in + */) if [ -z "$_dirs" ]; then _dirs="$_line" + else _dirs="$_dirs +$_line"; fi ;; + *) if [ -z "$_files" ]; then _files="$_line" + else _files="$_files +$_line"; fi ;; + esac + done + + # sort files using ls into a tmp file + if [ -n "$_files" ]; then + case "$SORT_MODE" in + size) ls -1Sp "$CWD" 2>/dev/null | grep -v '/' > /tmp/_fm_sorted ;; + date) ls -1tp "$CWD" 2>/dev/null | grep -v '/' > /tmp/_fm_sorted ;; + esac + # only keep files that were already in our list (respects hidden filter) + _sorted="" + while IFS= read -r _n; do + [ -z "$_n" ] && continue + # check if _n is in _files + case " +${_files} +" in *" +${_n} +"*) if [ -z "$_sorted" ]; then _sorted="$_n" + else _sorted="$_sorted +$_n"; fi ;; + esac + done < /tmp/_fm_sorted + _files="$_sorted" + fi + + # reassemble: dirs + sorted files + ENTRIES="" + _rest="$_dirs" + while [ -n "$_rest" ]; do + _line="${_rest%% +*}"; _next="${_rest#* +}" + [ "$_next" = "$_rest" ] && _next="" + _rest="$_next" + [ -z "$_line" ] && continue + if [ -z "$ENTRIES" ]; then ENTRIES="$_line" + else ENTRIES="$ENTRIES +$_line"; fi + done + _rest="$_files" + while [ -n "$_rest" ]; do + _line="${_rest%% +*}"; _next="${_rest#* +}" + [ "$_next" = "$_rest" ] && _next="" + _rest="$_next" + [ -z "$_line" ] && continue + if [ -z "$ENTRIES" ]; then ENTRIES="$_line" + else ENTRIES="$ENTRIES +$_line"; fi + done + # recount + COUNT=0 + _rest="$ENTRIES" + while [ -n "$_rest" ]; do + _line="${_rest%% +*}"; _next="${_rest#* +}" + [ "$_next" = "$_rest" ] && _next="" + _rest="$_next" + [ -n "$_line" ] && COUNT=$((COUNT + 1)) + done + fi + + ALL_ENTRIES="$ENTRIES" + ALL_COUNT="$COUNT" + apply_filter + NEED_FULL_REDRAW=1 +} + +# filter ALL_ENTRIES by FILTER string, update ENTRIES/COUNT +apply_filter() { + if [ -z "$FILTER" ]; then + ENTRIES="$ALL_ENTRIES" + COUNT="$ALL_COUNT" + return + fi + ENTRIES="" + COUNT=0 + printf '%s\n' "$ALL_ENTRIES" | while IFS= read -r line; do + case "$line" in + *"$FILTER"*) printf '%s\n' "$line" ;; + esac + done | { + while IFS= read -r line; do + ENTRIES="$ENTRIES +$line" + COUNT=$((COUNT + 1)) + done + # write back via temp — subshell can't modify parent vars directly + printf '%s\n' "$COUNT" > /tmp/_fm_count + printf '%s' "$ENTRIES" > /tmp/_fm_entries + } + COUNT=$(cat /tmp/_fm_count 2>/dev/null || echo 0) + ENTRIES=$(cat /tmp/_fm_entries 2>/dev/null || echo "") + ENTRIES="${ENTRIES# +}" +} + +get_entry() { + printf '%s\n' "$ENTRIES" | awk -v n="$(($1 + 1))" 'NR==n{print; exit}' +} + +# find index of entry by name (0-based), returns -1 if not found +find_entry() { + printf '%s\n' "$ENTRIES" | awk -v name="$1" ' + { if ($0 == name) { print NR-1; found=1; exit } } + END { if (!found) print -1 } + ' +} + +# is entry name in SELECTED list? +is_selected() { + case " +${SELECTED} +" in *" +$1 +"*) return 0 ;; esac + return 1 +} + +count_selected() { + _cnt=0 + _rest="$SELECTED" + while [ -n "$_rest" ]; do + _line="${_rest%% +*}" + _rest="${_rest#* +}" + [ "$_rest" = "$_line" ] && _rest="" # no more newlines + [ -n "$_line" ] && _cnt=$((_cnt + 1)) + done + printf '%d' "$_cnt" +} + + +toggle_selected() { + if is_selected "$1"; then + _new="" + _rest="$SELECTED" + while [ -n "$_rest" ]; do + _line="${_rest%% +*}" + _next="${_rest#* +}" + [ "$_next" = "$_rest" ] && _next="" + _rest="$_next" + [ "$_line" = "$1" ] && continue + [ -z "$_line" ] && continue + if [ -z "$_new" ]; then _new="$_line" + else _new="$_new +$_line" + fi + done + SELECTED="$_new" + else + if [ -z "$SELECTED" ]; then + SELECTED="$1" + else + SELECTED="$SELECTED +$1" + fi + fi +} + +# render one entry row in-place (no newline, no cursor movement after) +# args: row idx is_selected cols +render_row() { + _row=$1; _idx=$2; _selected=$3; _cols=$4 + entry=$(get_entry "$_idx") + + case "$entry" in + */) colour="${GREEN}${BOLD}" ;; + *@) + _name="${entry%@}" + # broken symlink if target doesn't exist + if [ -e "${CWD}/${_name}" ]; then + colour="${MAGENTA}${BOLD}" + else + colour="${RED}${BOLD}" + fi ;; + *) colour=$(file_colour "$entry") ;; + esac + + # multi-select marker + if is_selected "$entry"; then + _marker="${YELLOW}*${colour}" + else + _marker=" " + fi + + # build display string + case "$entry" in + *@) + _name="${entry%@}" + _target=$(readlink "${CWD}/${_name}" 2>/dev/null || echo "?") + # mark broken symlinks clearly + if [ ! -e "${CWD}/${_name}" ]; then + display="${_name} -> ${_target} [broken]" + else + display="${_name} -> ${_target}" + fi + ;; + *) + display="${entry}" + ;; + esac + + # build detail string (size + date) if enabled — fixed width for alignment + _detail="" + if [ "$SHOW_DETAILS" = "1" ]; then + _path="${CWD}/${entry%/}"; _path="${_path%@}" + _info=$(ls -ldh "$_path" 2>/dev/null) + _sz=$(printf '%s' "$_info" | awk '{print $5}') + _dt=$(printf '%s' "$_info" | awk '{print $6, $7}') + # fixed width: size=6, date=6 → total detail = 14 chars + _detail=$(printf ' %6s %-6s' "$_sz" "$_dt") + fi + + # layout: marker(1) + name + spaces + detail + _dlen=${#_detail} + maxw=$((_cols - 1 - _dlen - 1)) + if [ "${#display}" -gt "$maxw" ]; then + display=$(printf '%s' "$display" | cut -c1-$((_cols - _dlen - 4))) + display="${display}..." + fi + _namew=$((${#display} + 1)) # 1 for marker + padlen=$((_cols - _namew - _dlen)) + [ "$padlen" -lt 0 ] && padlen=0 + spaces=$(printf '%*s' "$padlen" '') + + goto "$_row" 1 + if [ "$_selected" = "1" ]; then + printf '%s%s%s%s%s%s%s' \ + "${REV}${colour}" "${_marker}" "${display}" \ + "${spaces}" \ + "${colour}${_detail}" \ + "${RESET}" "" + else + printf '%s%s%s%s%s%s%s' \ + "${colour}" "${_marker}" "${display}" \ + "${spaces}" \ + "${colour}${_detail}" \ + "${RESET}" "" + fi +} + +draw_topbar() { + _cols=$1 + spaces=$(printf '%*s' "$_cols" '') + goto 1 1 + printf '%s%s%s' "${CYAN}${BOLD}" "${spaces}" "${RESET}" +} + +draw_botbar() { + _rows=$1; _cols=$2 + if [ "$SEARCHING" = "1" ]; then + info=" $((SEL + 1))/${COUNT} ${CWD}" + keys=" search: ${FILTER} " + maxk=$((_cols - ${#info} - 1)) + [ "${#keys}" -gt "$maxk" ] && keys=$(printf '%s' "$keys" | cut -c1-"$maxk") + padlen=$((_cols - ${#info} - ${#keys})) + [ "$padlen" -lt 0 ] && padlen=0 + pad=$(printf '%*s' "$padlen" '') + goto "$_rows" 1 + printf '%s%s%s%s%s%s' \ + "${BOLD}${CYAN}" "${info}" \ + "${RESET}${pad}" \ + "${YELLOW}${keys}" \ + "${RESET}" + elif [ -n "$INFO_MSG" ]; then + info=" $((SEL + 1))/${COUNT} ${CWD}" + msg=" ${INFO_MSG} " + padlen=$((_cols - ${#info} - ${#msg})) + [ "$padlen" -lt 0 ] && padlen=0 + pad=$(printf '%*s' "$padlen" '') + goto "$_rows" 1 + printf '%s%s%s%s%s%s' \ + "${BOLD}${CYAN}" "${info}" \ + "${RESET}${pad}" \ + "${YELLOW}${msg}" \ + "${RESET}" + INFO_MSG="" + else + _ind="" + [ -n "$CLIPBOARD" ] && _ind=" [${CLIP_MODE}]" + _sc=$(count_selected) + [ "$_sc" -gt 0 ] && _ind="${_ind} [sel:${_sc}]" + [ "$SHOW_HIDDEN" = "1" ] && _ind="${_ind} [hidden]" + [ "$SORT_MODE" != "name" ] && _ind="${_ind} [sort:${SORT_MODE}]" + [ "$SHOW_DETAILS" = "1" ] && _ind="${_ind} [details]" + hint=" press ? for help " + info=" $((SEL + 1))/${COUNT} ${CWD}${_ind}" + padlen=$((_cols - ${#info} - ${#hint})) + [ "$padlen" -lt 0 ] && padlen=0 + pad=$(printf '%*s' "$padlen" '') + goto "$_rows" 1 + printf '%s%s%s%s%s%s' \ + "${BOLD}${CYAN}" "${info}" \ + "${RESET}${pad}" \ + "${YELLOW}${hint}" \ + "${RESET}" + fi +} + +draw() { + ROWS=$(term_rows) + COLS=$(term_cols) + LIST_ROWS=$((ROWS - 2)) # row 1 = topbar, row ROWS = botbar + + # clamp selection + [ "$SEL" -lt 0 ] && SEL=0 + [ "$COUNT" -gt 0 ] && [ "$SEL" -ge "$COUNT" ] && SEL=$((COUNT - 1)) + + # update scroll offset + if [ "$SEL" -lt "$OFFSET" ]; then + OFFSET=$SEL + elif [ "$SEL" -ge $((OFFSET + LIST_ROWS)) ]; then + OFFSET=$((SEL - LIST_ROWS + 1)) + fi + + # viewport shifted → must redraw all rows + [ "$OFFSET" != "$PREV_OFFSET" ] && NEED_FULL_REDRAW=1 + + if [ "$NEED_FULL_REDRAW" = "1" ]; then + # --- full repaint: move to top without erasing, overwrite every row --- + printf '\033[H' + draw_topbar "$COLS" + + row=2 + idx=$OFFSET + while [ "$row" -le $((LIST_ROWS + 1)) ] && [ "$idx" -lt "$COUNT" ]; do + sel=0; [ "$idx" -eq "$SEL" ] && sel=1 + render_row "$row" "$idx" "$sel" "$COLS" + row=$((row + 1)) + idx=$((idx + 1)) + done + # blank leftover rows (e.g. after entering a smaller directory) + while [ "$row" -le $((LIST_ROWS + 1)) ]; do + goto "$row" 1 + printf '%s' "${ERASE_LINE}" + row=$((row + 1)) + done + + draw_botbar "$ROWS" "$COLS" + NEED_FULL_REDRAW=0 + else + # --- fast path: only touch the two rows whose highlight state changed --- + # if nothing moved, skip entirely — no flicker at boundaries + if [ "$SEL" = "$PREV_SEL" ] && [ "$OFFSET" = "$PREV_OFFSET" ]; then + return 2>/dev/null || true + fi + prev_row=$(( (PREV_SEL - OFFSET) + 2 )) + new_row=$(( (SEL - OFFSET) + 2 )) + + if [ "$prev_row" -ge 2 ] && [ "$prev_row" -le $((LIST_ROWS + 1)) ]; then + render_row "$prev_row" "$PREV_SEL" 0 "$COLS" + fi + if [ "$new_row" -ge 2 ] && [ "$new_row" -le $((LIST_ROWS + 1)) ]; then + render_row "$new_row" "$SEL" 1 "$COLS" + fi + + draw_botbar "$ROWS" "$COLS" + fi + + PREV_SEL=$SEL + PREV_OFFSET=$OFFSET +} + +# --- input --- +read_key() { + IFS= read -r -n1 key 2>/dev/null || IFS= read -r key + if [ "$key" = "$(printf '\033')" ]; then + # peek at next char with a very short timeout + IFS= read -r -n1 -t 0.1 _k2 2>/dev/null || _k2="" + if [ -z "$_k2" ]; then + # nothing followed — bare esc + printf '\033'; return + fi + # got a second char — read one more for full sequence (e.g. [A [B [C [D) + IFS= read -r -n1 -t 0.1 _k3 2>/dev/null || _k3="" + key="${key}${_k2}${_k3}" + fi + printf '%s' "$key" +} + +setup_term() { old_stty=$(stty -g); stty -echo -icanon min 1 time 0; } +restore_term() { stty "$old_stty"; } + +# --- actions --- +# --- smart file opener --- +# Detects file type by extension then mime, picks the right program. +# Terminal programs run in the foreground; GUI programs are backgrounded. +_run_tty() { "$@"; } +_run_gui() { "$@" >/dev/null 2>&1 & } + +open_file() { + _f="$1" + _ext="${_f##*.}" + _ext=$(printf '%s' "$_ext" | tr '[:upper:]' '[:lower:]') + + # --- by extension --- + case "$_ext" in + # text / code → editor + txt|md|markdown|rst|csv|tsv|log|conf|cfg|ini|toml|yaml|yml|\ + sh|bash|zsh|fish|py|rb|pl|lua|js|ts|jsx|tsx|json|xml|html|\ + htm|css|scss|sass|c|h|cpp|cc|cxx|hpp|rs|go|java|kt|swift|\ + cs|php|r|sql|vim|diff|patch|makefile|dockerfile|gitignore|\ + env|lock|mod|sum) + _run_tty ${EDITOR:-vi} "$_f" + return ;; + # images → GUI viewer + jpg|jpeg|png|gif|bmp|tiff|tif|webp|svg|ico|heic|heif|avif) + for _v in imv imvr feh sxiv nsxiv eog eom viewnior shotwell gimp; do + command -v "$_v" >/dev/null 2>&1 && { _run_gui "$_v" "$_f"; return; } + done ;; + # video → GUI player + mp4|mkv|avi|mov|wmv|flv|webm|m4v|mpeg|mpg|3gp|ogv) + for _v in mpv vlc mplayer totem celluloid haruna; do + command -v "$_v" >/dev/null 2>&1 && { _run_gui "$_v" "$_f"; return; } + done ;; + # audio → player (mpv/vlc run fine headless too) + mp3|flac|ogg|wav|aac|m4a|opus|wma|aiff) + for _v in mpv vlc mplayer cmus mocp; do + command -v "$_v" >/dev/null 2>&1 && { _run_gui "$_v" "$_f"; return; } + done ;; + # PDF / documents + pdf) + for _v in zathura evince okular mupdf atril xreader; do + command -v "$_v" >/dev/null 2>&1 && { _run_gui "$_v" "$_f"; return; } + done ;; + # office docs + odt|ods|odp|doc|docx|xls|xlsx|ppt|pptx) + for _v in libreoffice soffice; do + command -v "$_v" >/dev/null 2>&1 && { _run_gui "$_v" "$_f"; return; } + done ;; + # archives → list contents in pager + zip|tar|gz|bz2|xz|zst|7z|rar) + if command -v atool >/dev/null 2>&1; then + atool -l "$_f" 2>&1 | ${PAGER:-less}; return + elif command -v bsdtar >/dev/null 2>&1; then + bsdtar -tf "$_f" 2>&1 | ${PAGER:-less}; return + fi ;; + esac + + # --- fallback: try mime type via `file` --- + if command -v file >/dev/null 2>&1; then + _mime=$(file --mime-type -b "$_f" 2>/dev/null) + case "$_mime" in + text/*|application/json|application/xml|application/javascript) + _run_tty ${EDITOR:-vi} "$_f"; return ;; + image/*) + for _v in imv feh sxiv nsxiv eog gimp; do + command -v "$_v" >/dev/null 2>&1 && { _run_gui "$_v" "$_f"; return; } + done ;; + video/*) + for _v in mpv vlc mplayer; do + command -v "$_v" >/dev/null 2>&1 && { _run_gui "$_v" "$_f"; return; } + done ;; + audio/*) + for _v in mpv vlc mplayer; do + command -v "$_v" >/dev/null 2>&1 && { _run_gui "$_v" "$_f"; return; } + done ;; + application/pdf) + for _v in zathura evince okular mupdf; do + command -v "$_v" >/dev/null 2>&1 && { _run_gui "$_v" "$_f"; return; } + done ;; + esac + fi + + # --- last resort: xdg-open / open, else editor --- + if command -v xdg-open >/dev/null 2>&1; then + _run_gui xdg-open "$_f" + elif command -v open >/dev/null 2>&1; then + _run_gui open "$_f" + else + _run_tty ${EDITOR:-vi} "$_f" + fi +} + +# read a line of input in raw mode, returns result in READ_LINE +# returns 1 if user pressed Esc (cancel), 0 on Enter +read_line() { + READ_LINE="" + show_cursor + while true; do + IFS= read -r -n1 _ch 2>/dev/null || IFS= read -r _ch + case "$_ch" in + "$(printf '\033')") + # read with timeout to distinguish bare esc from sequences + IFS= read -r -n2 -t 0.1 _seq 2>/dev/null || _seq="" + hide_cursor + READ_LINE=""; return 1 ;; + "$(printf '\n')"|\ + "$(printf '\r')") + hide_cursor + return 0 ;; + "$(printf '\177')"|\ + "$(printf '\010')") + if [ -n "$READ_LINE" ]; then + READ_LINE="${READ_LINE%?}" + printf '\b \b' + fi ;; + *) + READ_LINE="${READ_LINE}${_ch}" + printf '%s' "$_ch" ;; + esac + done +} + +do_open_with() { + entry=$(get_entry "$SEL") + [ -z "$entry" ] && return + case "$entry" in + */) INFO_MSG="cannot open-with a directory"; NEED_FULL_REDRAW=1; return ;; + esac + _target="${CWD}/${entry%@}" + goto "$(term_rows)" 1 + printf '%s%s Open "%s" with (esc=cancel): %s' \ + "${ERASE_LINE}" "${CYAN}${BOLD}" "$entry" "${RESET}" + if read_line && [ -n "$READ_LINE" ]; then + # check the program exists + if ! command -v "$READ_LINE" >/dev/null 2>&1; then + INFO_MSG="not found: ${READ_LINE}" + NEED_FULL_REDRAW=1 + return + fi + restore_term; show_cursor + printf '\033[2J\033[H' + "$READ_LINE" "$_target" + setup_term; hide_cursor + NEED_FULL_REDRAW=1 + else + NEED_FULL_REDRAW=1; draw + fi +} + +do_newfile() { + goto "$(term_rows)" 1 + printf '%s%s New file name (esc=cancel): %s' "${ERASE_LINE}" "${WHITE}${BOLD}" "${RESET}" + if read_line && [ -n "$READ_LINE" ]; then + touch "${CWD}/${READ_LINE}" + load_entries + else + NEED_FULL_REDRAW=1; draw + fi +} + +# --- help overlay helpers (must be top-level for dash compatibility) --- +_help_bx=0; _help_by=0; _help_iw=0; _help_hl="" + +help_row() { + _r=$1; _text=$2 + goto "$(( _help_by + _r ))" "$_help_bx" + if [ "${#_text}" -gt "$_help_iw" ]; then + _text=$(printf '%s' "$_text" | cut -c1-"$_help_iw") + fi + _pl=$(( _help_iw - ${#_text} )) + _p=$(printf '%*s' "$_pl" '') + printf '%s|%s%s%s%s|%s' \ + "${BOLD}${CYAN}" "${RESET}" "${_text}" "${_p}" \ + "${BOLD}${CYAN}" "${RESET}" +} + +help_sep() { + goto "$(( _help_by + $1 ))" "$_help_bx" + printf '%s|%s|%s' "${BOLD}${CYAN}" "$_help_hl" "${RESET}" +} + +do_help() { + ROWS=$(term_rows) + COLS=$(term_cols) + bw=$((COLS - 4)) + [ "$bw" -gt 52 ] && bw=52 + [ "$bw" -lt 36 ] && bw=36 + bh=41 + _help_bx=$(( (COLS - bw) / 2 )); [ "$_help_bx" -lt 1 ] && _help_bx=1 + _help_by=$(( (ROWS - bh) / 2 )); [ "$_help_by" -lt 1 ] && _help_by=1 + _help_iw=$((bw - 2)) + _help_hl=$(printf '%*s' "$_help_iw" '' | tr ' ' '-') + + goto "$_help_by" "$_help_bx" + printf '%s+%s+%s' "${BOLD}${CYAN}" "$_help_hl" "${RESET}" + + help_row 1 "" + help_row 2 " KEYBOARD SHORTCUTS" + help_sep 3 + help_row 4 " j/k up/down g/G top/bottom" + help_row 5 " h/left go back" + help_row 6 " l/right/enter open / enter dir" + help_sep 7 + help_row 8 " / search filter" + help_row 9 " esc clear filter" + help_row 10 " . toggle hidden files" + help_row 11 " i file info in status bar" + help_row 12 " s cycle sort: name/size/date" + help_row 13 " T toggle size/date details" + help_sep 14 + help_row 15 " space toggle multi-select" + help_row 16 " a select all / deselect all" + help_row 17 " y yank/copy (works on selection)" + help_row 18 " x cut (works on selection)" + help_row 19 " p paste" + help_row 20 " d delete (works on selection)" + help_sep 21 + help_row 22 " r rename R refresh" + help_row 23 " m make directory" + help_row 24 " n new file" + help_row 25 " u trash file (safe delete)" + help_row 26 " U open trash directory" + help_row 27 " ! drop to shell in CWD" + help_row 28 " o open with custom program" + help_sep 29 + help_row 30 " b bookmark current dir" + help_row 31 " B open bookmark picker" + help_row 32 " c copy path to clipboard" + help_row 33 " ~ go to home directory" + help_row 34 " \` jump to previous directory" + help_sep 35 + help_row 36 " q quit" + help_row 37 " ? this help" + help_row 38 "" + help_row 39 " press any key to close..." + + goto "$(( _help_by + bh ))" "$_help_bx" + printf '%s+%s+%s' "${BOLD}${CYAN}" "$_help_hl" "${RESET}" + + read_key > /dev/null + NEED_FULL_REDRAW=1 +} + +do_search() { + FILTER="" + SEARCHING=1 + show_cursor + while true; do + apply_filter + SEL=0; OFFSET=0; PREV_SEL=0; PREV_OFFSET=0 + NEED_FULL_REDRAW=1 + draw + IFS= read -r -n1 _ch 2>/dev/null || IFS= read -r _ch + case "$_ch" in + "$(printf '\033')") + # read with short timeout to catch escape sequences + IFS= read -r -n2 -t 0.1 _seq 2>/dev/null || _seq="" + if [ -z "$_seq" ]; then + # bare esc — clear filter and exit search + FILTER=""; SEARCHING=0 + apply_filter + SEL=0; OFFSET=0; PREV_SEL=0; PREV_OFFSET=0 + hide_cursor; NEED_FULL_REDRAW=1; draw; return + fi + # else it was an arrow key or other sequence — ignore it + ;; + "$(printf '\n')"|\ + "$(printf '\r')") + SEARCHING=0; hide_cursor; NEED_FULL_REDRAW=1; return ;; + "$(printf '\177')"|\ + "$(printf '\010')") + [ -n "$FILTER" ] && FILTER="${FILTER%?}" ;; + *) + FILTER="${FILTER}${_ch}" ;; + esac + done +} +do_clear_filter() { + FILTER="" + SEARCHING=0 + apply_filter + SEL=0; OFFSET=0; PREV_SEL=0; PREV_OFFSET=0 + NEED_FULL_REDRAW=1 +} + +do_toggle_hidden() { + if [ "$SHOW_HIDDEN" = "0" ]; then + SHOW_HIDDEN=1 + INFO_MSG="hidden files shown" + else + SHOW_HIDDEN=0 + INFO_MSG="hidden files hidden" + fi + SEL=0; OFFSET=0; PREV_SEL=0; PREV_OFFSET=0 + load_entries +} + +do_toggle_details() { + if [ "$SHOW_DETAILS" = "0" ]; then + SHOW_DETAILS=1 + INFO_MSG="details on" + else + SHOW_DETAILS=0 + INFO_MSG="details off" + fi + NEED_FULL_REDRAW=1 +} + +do_go_back() { + [ "$CWD" = "/" ] && return + FILTER="" + SELECTED="" + PREV_CWD="$CWD" + LAST_CHILD="${CWD##*/}/" # name of current dir with trailing slash + CWD=$(dirname "$CWD") + SEL=0; OFFSET=0; PREV_SEL=0; PREV_OFFSET=0 + load_entries + # restore selection to the directory we just came from + if [ -n "$LAST_CHILD" ]; then + idx=$(find_entry "$LAST_CHILD") + [ "$idx" -ge 0 ] 2>/dev/null && SEL=$idx + fi + LAST_CHILD="" +} + +do_open() { + entry=$(get_entry "$SEL") + case "$entry" in + */) + FILTER="" + SELECTED="" + _target="${CWD}/${entry%/}" + if [ ! -x "$_target" ] || [ ! -r "$_target" ]; then + INFO_MSG="permission denied: ${entry%/}" + NEED_FULL_REDRAW=1 + return + fi + PREV_CWD="$CWD" + CWD=$(cd "$_target" && pwd) + SEL=0; OFFSET=0; PREV_SEL=0; PREV_OFFSET=0 + load_entries + ;; + *@) + _name="${entry%@}" + _target="${CWD}/${_name}" + if [ -d "$_target" ]; then + if [ ! -x "$_target" ] || [ ! -r "$_target" ]; then + INFO_MSG="permission denied: ${_name}" + NEED_FULL_REDRAW=1 + return + fi + FILTER=""; SELECTED="" + PREV_CWD="$CWD" + CWD=$(cd "$_target" && pwd) + SEL=0; OFFSET=0; PREV_SEL=0; PREV_OFFSET=0 + load_entries + else + if [ ! -r "$_target" ]; then + INFO_MSG="permission denied: ${_name}" + NEED_FULL_REDRAW=1 + return + fi + restore_term; show_cursor + printf '\033[2J\033[H' + open_file "$_target" + setup_term; hide_cursor + NEED_FULL_REDRAW=1 + fi + ;; + *) + _target="${CWD}/${entry}" + if [ ! -r "$_target" ]; then + INFO_MSG="permission denied: ${entry}" + NEED_FULL_REDRAW=1 + return + fi + restore_term; show_cursor + printf '\033[2J\033[H' + open_file "$_target" + setup_term; hide_cursor + NEED_FULL_REDRAW=1 + ;; + esac +} + +do_delete() { + INFO_MSG="" + _c=$(count_selected) + if [ "$_c" -gt 0 ]; then + # --- bulk delete selected --- + restore_term; show_cursor + goto "$(term_rows)" 1 + printf '%s%s Delete %d selected items? [y/N] %s' \ + "${ERASE_LINE}" "${RED}${BOLD}" "$_c" "${RESET}" + IFS= read -r ans + setup_term; hide_cursor + case "$ans" in + y|Y) + _rest="$SELECTED" + while [ -n "$_rest" ]; do + _line="${_rest%% +*}" + _next="${_rest#* +}" + [ "$_next" = "$_rest" ] && _next="" + _rest="$_next" + [ -z "$_line" ] && continue + _t="${CWD}/${_line%/}" + if [ -d "$_t" ]; then rm -rf "$_t"; else rm -f "$_t"; fi + done + SELECTED="" + [ "$SEL" -ge "$((COUNT - 1))" ] && SEL=$((COUNT - 2)) + [ "$SEL" -lt 0 ] && SEL=0 + load_entries + ;; + *) NEED_FULL_REDRAW=1 ;; + esac + else + # --- single delete --- + entry=$(get_entry "$SEL") + target="${CWD}/${entry%/}" + restore_term; show_cursor + goto "$(term_rows)" 1 + printf '%s%s Delete "%s"? [y/N] %s' "${ERASE_LINE}" "${RED}${BOLD}" "$entry" "${RESET}" + IFS= read -r ans + setup_term; hide_cursor + case "$ans" in + y|Y) + if [ -d "$target" ]; then + if [ -n "$(ls -A "$target" 2>/dev/null)" ]; then + restore_term; show_cursor + goto "$(term_rows)" 1 + printf '%s%s "%s" not empty. Delete ALL? [y/N] %s' \ + "${ERASE_LINE}" "${RED}${BOLD}" "$entry" "${RESET}" + IFS= read -r ans2 + setup_term; hide_cursor + case "$ans2" in + y|Y) rm -rf "$target" ;; + *) NEED_FULL_REDRAW=1; return ;; + esac + else + rm -rf "$target" + fi + else + rm -f "$target" + fi + [ "$SEL" -ge "$((COUNT - 1))" ] && SEL=$((COUNT - 2)) + [ "$SEL" -lt 0 ] && SEL=0 + load_entries + ;; + *) NEED_FULL_REDRAW=1 ;; + esac + fi +} + +do_rename() { + entry=$(get_entry "$SEL") + goto "$(term_rows)" 1 + printf '%s%s Rename "%s" to (esc=cancel): %s' "${ERASE_LINE}" "${YELLOW}${BOLD}" "$entry" "${RESET}" + if read_line && [ -n "$READ_LINE" ] && [ "$READ_LINE" != "$entry" ]; then + mv "${CWD}/${entry%/}" "${CWD}/${READ_LINE}" + load_entries + else + NEED_FULL_REDRAW=1; draw + fi +} + +do_mkdir() { + goto "$(term_rows)" 1 + printf '%s%s New dir name (esc=cancel): %s' "${ERASE_LINE}" "${CYAN}${BOLD}" "${RESET}" + if read_line && [ -n "$READ_LINE" ]; then + mkdir -p "${CWD}/${READ_LINE}" + load_entries + else + NEED_FULL_REDRAW=1; draw + fi +} + +do_info() { + entry=$(get_entry "$SEL") + [ -z "$entry" ] && return + target="${CWD}/${entry%/}" + # permissions + type + _perm=$(ls -ld "$target" 2>/dev/null | awk '{print $1}') + # size (human readable via du, fallback to ls) + _size=$(du -sh "$target" 2>/dev/null | cut -f1) + [ -z "$_size" ] && _size=$(ls -lh "$target" 2>/dev/null | awk '{print $5}') + # modification date + _date=$(ls -ld "$target" 2>/dev/null | awk '{print $6, $7, $8}') + INFO_MSG="${_perm} ${_size} ${_date}" + NEED_FULL_REDRAW=1 +} + +do_shell() { + restore_term; show_cursor + printf '\033[2J\033[H' + cd "$CWD" || true + printf '%s(type "exit" to return to fm)%s\n' "${YELLOW}" "${RESET}" + ${SHELL:-sh} + # restore CWD in case user cd'd around + CWD="${PWD}" + setup_term; hide_cursor + NEED_FULL_REDRAW=1 +} + +do_sort() { + case "$SORT_MODE" in + name) SORT_MODE="size" ;; + size) SORT_MODE="date" ;; + date) SORT_MODE="name" ;; + esac + INFO_MSG="sort: ${SORT_MODE}" + SEL=0; OFFSET=0; PREV_SEL=0; PREV_OFFSET=0 + load_entries +} + +do_trash() { + entry=$(get_entry "$SEL") + [ -z "$entry" ] && return + target="${CWD}/${entry%/}" + _ts=$(date '+%Y%m%d_%H%M%S' 2>/dev/null || date '+%s') + _dest="${TRASH_DIR}/${_ts}_${entry%/}" + if mv "$target" "$_dest"; then + INFO_MSG="trashed: ${entry}" + [ "$SEL" -ge "$((COUNT - 1))" ] && SEL=$((COUNT - 2)) + [ "$SEL" -lt 0 ] && SEL=0 + load_entries + else + INFO_MSG="trash failed" + NEED_FULL_REDRAW=1 + fi +} + +do_open_trash() { + PREV_CWD="$CWD" + FILTER=""; SELECTED="" + CWD="$TRASH_DIR" + SEL=0; OFFSET=0; PREV_SEL=0; PREV_OFFSET=0 + load_entries +} + +do_copy_path() { + entry=$(get_entry "$SEL") + [ -z "$entry" ] && return + _path="${CWD}/${entry%/}" + if command -v wl-copy >/dev/null 2>&1; then + printf '%s' "$_path" | wl-copy + elif command -v xclip >/dev/null 2>&1; then + printf '%s' "$_path" | xclip -selection clipboard + elif command -v xsel >/dev/null 2>&1; then + printf '%s' "$_path" | xsel --clipboard --input + elif command -v pbcopy >/dev/null 2>&1; then + printf '%s' "$_path" | pbcopy + else + INFO_MSG="no clipboard tool found" + NEED_FULL_REDRAW=1 + return + fi + INFO_MSG="path copied: ${_path}" + NEED_FULL_REDRAW=1 +} + +do_jump_back() { + [ -z "$PREV_CWD" ] && { INFO_MSG="no previous directory"; NEED_FULL_REDRAW=1; return; } + _tmp="$CWD" + CWD="$PREV_CWD" + PREV_CWD="$_tmp" + FILTER=""; SELECTED="" + SEL=0; OFFSET=0; PREV_SEL=0; PREV_OFFSET=0 + load_entries +} + +do_bookmark_add() { + # toggle: if CWD already bookmarked, remove it; else add it + if grep -qx "$CWD" "$BOOKMARK_FILE" 2>/dev/null; then + # remove + _tmp=$(grep -vx "$CWD" "$BOOKMARK_FILE") + printf '%s\n' "$_tmp" > "$BOOKMARK_FILE" + INFO_MSG="bookmark removed: ${CWD}" + else + printf '%s\n' "$CWD" >> "$BOOKMARK_FILE" + INFO_MSG="bookmarked: ${CWD}" + fi + NEED_FULL_REDRAW=1 +} + +do_bookmark_jump() { + # count bookmarks + _bc=0 + while IFS= read -r _l; do [ -n "$_l" ] && _bc=$((_bc+1)); done < "$BOOKMARK_FILE" + [ "$_bc" -eq 0 ] && { INFO_MSG="no bookmarks saved"; NEED_FULL_REDRAW=1; return; } + + # draw a small picker overlay + ROWS=$(term_rows); COLS=$(term_cols) + _pw=$(( COLS * 2 / 3 )); [ "$_pw" -gt 70 ] && _pw=70; [ "$_pw" -lt 30 ] && _pw=30 + _ph=$(( _bc + 4 )); [ "$_ph" -gt $((ROWS - 4)) ] && _ph=$((ROWS - 4)) + _px=$(( (COLS - _pw) / 2 )); [ "$_px" -lt 1 ] && _px=1 + _py=$(( (ROWS - _ph) / 2 )); [ "$_py" -lt 1 ] && _py=1 + _iw=$((_pw - 2)) + _hl=$(printf '%*s' "$_iw" '' | tr ' ' '-') + + _bsel=1 + while true; do + # draw picker inline + goto "$_py" "$_px" + printf '%s+%s+%s' "${BOLD}${CYAN}" "$_hl" "${RESET}" + goto "$((_py+1))" "$_px" + _t=" BOOKMARKS"; _pl=$((_iw - ${#_t})); _p=$(printf '%*s' "$_pl" '') + printf '%s|%s%s%s%s|%s' "${BOLD}${CYAN}" "${RESET}" "$_t" "$_p" "${BOLD}${CYAN}" "${RESET}" + goto "$((_py+2))" "$_px" + printf '%s|%s|%s' "${BOLD}${CYAN}" "$_hl" "${RESET}" + _bi=0 + while IFS= read -r _bm; do + [ -z "$_bm" ] && continue + _bi=$((_bi+1)) + goto "$((_py+2+_bi))" "$_px" + _bt="${_bi} ${_bm}" + if [ "${#_bt}" -gt "$_iw" ]; then _bt=$(printf '%s' "$_bt" | cut -c1-$((_iw-1))); _bt="${_bt}~"; fi + _bpl=$((_iw - ${#_bt})); _bp=$(printf '%*s' "$_bpl" '') + if [ "$_bi" -eq "$_bsel" ]; then + printf '%s|%s%s%s%s|%s' "${BOLD}${CYAN}" "${REV}${YELLOW}" "$_bt" "$_bp" "${RESET}${BOLD}${CYAN}" "${RESET}" + else + printf '%s|%s%s%s%s|%s' "${BOLD}${CYAN}" "${RESET}" "$_bt" "$_bp" "${BOLD}${CYAN}" "${RESET}" + fi + done < "$BOOKMARK_FILE" + goto "$((_py+_ph))" "$_px" + printf '%s+%s+%s' "${BOLD}${CYAN}" "$_hl" "${RESET}" + IFS= read -r -n1 _bk 2>/dev/null || IFS= read -r _bk + case "$_bk" in + j) _bsel=$((_bsel+1)); [ "$_bsel" -gt "$_bc" ] && _bsel=$_bc ;; + k) _bsel=$((_bsel-1)); [ "$_bsel" -lt 1 ] && _bsel=1 ;; + "$(printf '\033')") + # use timeout to distinguish bare esc from arrow sequences + IFS= read -r -n2 -t 0.1 _seq 2>/dev/null || _seq="" + case "$_seq" in + '[A') _bsel=$((_bsel-1)); [ "$_bsel" -lt 1 ] && _bsel=1 ;; + '[B') _bsel=$((_bsel+1)); [ "$_bsel" -gt "$_bc" ] && _bsel=$_bc ;; + '[C') # right — open + _chosen=$(awk -v n="$_bsel" 'NR==n&&NF{print;exit}' "$BOOKMARK_FILE") + if [ -n "$_chosen" ] && [ -d "$_chosen" ]; then + PREV_CWD="$CWD"; CWD="$_chosen" + FILTER=""; SELECTED="" + SEL=0; OFFSET=0; PREV_SEL=0; PREV_OFFSET=0 + load_entries + else + INFO_MSG="not found: ${_chosen}"; NEED_FULL_REDRAW=1 + fi + return ;; + '[D') NEED_FULL_REDRAW=1; return ;; # left — close + *) NEED_FULL_REDRAW=1; return ;; # bare esc — close + esac ;; + "$(printf '\n')"|\ + "$(printf '\r')"|\ + l) + _chosen=$(awk -v n="$_bsel" 'NR==n&&NF{print;exit}' "$BOOKMARK_FILE") + if [ -n "$_chosen" ] && [ -d "$_chosen" ]; then + PREV_CWD="$CWD"; CWD="$_chosen" + FILTER=""; SELECTED="" + SEL=0; OFFSET=0; PREV_SEL=0; PREV_OFFSET=0 + load_entries + else + INFO_MSG="not found: ${_chosen}"; NEED_FULL_REDRAW=1 + fi + return ;; + q|Q|h) NEED_FULL_REDRAW=1; return ;; + esac + done +} + +do_toggle_select() { + entry=$(get_entry "$SEL") + [ -z "$entry" ] && return + toggle_selected "$entry" + _c=$(count_selected) + [ "$_c" -eq 0 ] && INFO_MSG="selection cleared" || INFO_MSG="${_c} selected" + SEL=$((SEL + 1)) + NEED_FULL_REDRAW=1 +} + +do_select_all() { + _c=$(count_selected) + if [ "$_c" -gt 0 ]; then + # deselect all + SELECTED="" + INFO_MSG="selection cleared" + else + # select all visible entries (skip ../) + SELECTED="" + _rest="$ENTRIES" + while [ -n "$_rest" ]; do + _line="${_rest%% +*}"; _next="${_rest#* +}" + [ "$_next" = "$_rest" ] && _next="" + _rest="$_next" + [ -z "$_line" ] && continue + [ "$_line" = "../" ] && continue + if [ -z "$SELECTED" ]; then SELECTED="$_line" + else SELECTED="$SELECTED +$_line"; fi + done + _c=$(count_selected) + INFO_MSG="selected all: ${_c} items" + fi + NEED_FULL_REDRAW=1 +} + +do_yank() { + _c=$(count_selected) + if [ "$_c" -gt 0 ]; then + CLIPBOARD=$(printf '%s\n' "$SELECTED" | while IFS= read -r _e; do + [ -z "$_e" ] && continue + printf '%s\n' "${CWD}/${_e%/}" + done) + CLIP_MODE="copy" + INFO_MSG="yanked ${_c} items" + SELECTED="" + else + entry=$(get_entry "$SEL") + [ -z "$entry" ] && return + CLIPBOARD="${CWD}/${entry%/}" + CLIP_MODE="copy" + INFO_MSG="yanked: ${entry}" + fi + NEED_FULL_REDRAW=1 +} + +do_cut() { + _c=$(count_selected) + if [ "$_c" -gt 0 ]; then + CLIPBOARD=$(printf '%s\n' "$SELECTED" | while IFS= read -r _e; do + [ -z "$_e" ] && continue + printf '%s\n' "${CWD}/${_e%/}" + done) + CLIP_MODE="cut" + INFO_MSG="cut ${_c} items" + SELECTED="" + else + entry=$(get_entry "$SEL") + [ -z "$entry" ] && return + CLIPBOARD="${CWD}/${entry%/}" + CLIP_MODE="cut" + INFO_MSG="cut: ${entry}" + fi + NEED_FULL_REDRAW=1 +} + +do_paste() { + if [ -z "$CLIPBOARD" ]; then + INFO_MSG="nothing to paste" + NEED_FULL_REDRAW=1 + return + fi + _ok=0; _fail=0 + printf '%s\n' "$CLIPBOARD" | while IFS= read -r _src; do + [ -z "$_src" ] && continue + _name="${_src##*/}" + _dest="${CWD}/${_name}" + if [ -e "$_dest" ]; then + _base="${_name%.*}"; _ext="${_name##*.}" + [ "$_ext" = "$_name" ] && _ext="" || _ext=".${_ext}" + _dest="${CWD}/${_base}_copy${_ext}" + fi + case "$CLIP_MODE" in + copy) cp -r "$_src" "$_dest" && _ok=$((_ok+1)) || _fail=$((_fail+1)) ;; + cut) mv "$_src" "$_dest" && _ok=$((_ok+1)) || _fail=$((_fail+1)) ;; + esac + done + CLIPBOARD=""; CLIP_MODE="" + INFO_MSG="pasted" + load_entries +} + +# --- main --- +trap 'restore_term; show_cursor; printf "\033[2J\033[H"; exit 0' INT TERM EXIT + +setup_term +hide_cursor +printf '\033[2J' # clear screen exactly once at startup +load_entries + +while true; do + draw + key=$(read_key) + + case "$key" in + j|"$(printf '\033[B')") + [ "$SEL" -lt $((COUNT - 1)) ] && SEL=$((SEL + 1)) ;; + k|"$(printf '\033[A')") + [ "$SEL" -gt 0 ] && SEL=$((SEL - 1)) ;; + g) SEL=0 ;; + G) SEL=$((COUNT - 1)) ;; + "$(printf '\033[6~')") SEL=$((SEL + $(term_rows) / 2)) ;; + "$(printf '\033[5~')") SEL=$((SEL - $(term_rows) / 2)) ;; + "$(printf '\n')"|\ + "$(printf '\r')"|\ + "$(printf '\033[C')"|\ + l) do_open ;; + "$(printf '\033[D')"|\ + h) do_go_back ;; + b) do_bookmark_add ;; + B) do_bookmark_jump ;; + '?') do_help ;; + R) load_entries; INFO_MSG="refreshed" ;; + /) do_search ;; + '.') do_toggle_hidden ;; + T) do_toggle_details ;; + i) do_info ;; + '!') do_shell ;; + o) do_open_with ;; + s) do_sort ;; + u) do_trash ;; + U) do_open_trash ;; + '`') do_jump_back ;; + '~') PREV_CWD="$CWD"; CWD="$HOME"; FILTER=""; SELECTED="" + SEL=0; OFFSET=0; PREV_SEL=0; PREV_OFFSET=0; load_entries ;; + c) do_copy_path ;; + "$(printf '\033')") do_clear_filter ;; + ' ') do_toggle_select ;; + a) do_select_all ;; + y) do_yank ;; + x) do_cut ;; + p) do_paste ;; + d) do_delete ;; + r) do_rename ;; + m) do_mkdir ;; + n) do_newfile ;; + q|Q) break ;; + esac +done + +restore_term +show_cursor +printf '\033[2J\033[H' +printf '%s\n' "$CWD"