commit 8b9e5f19cdc509be44abe39ef0289c28739a2794
parent b7da442922fa92d3926d66aa8d428e7f363703c3
Author: emmett1 <emmett1.2miligrams@protonmail.com>
Date: Sat, 14 Mar 2026 23:28:36 +0800
initial commit
Diffstat:
| A | Makefile | | | 33 | +++++++++++++++++++++++++++++++++ |
| D | README.md | | | 4 | ---- |
| A | README.txt | | | 167 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | sfm | | | 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"